Restrict API keys by IP address¶
A key can be limited to specific IP addresses or CIDR networks — IPv4 and IPv6, mixed freely in the same list.
instance, raw_key = OrganizationAPIKey.generate_key(
name="Office network only",
tenant=org,
allowed_ips=["192.168.1.10", "10.0.0.0/8", "2001:db8::/32"],
)
Leave allowed_ips empty (the default) for no restriction — every key
created before this field existed behaves this way and keeps working
unchanged.
How entries are validated¶
generate_key() parses every entry with Python's standard library
ipaddress module — ipaddress.ip_network(entry, strict=False) — up
front, and raises ValueError immediately if one doesn't parse:
OrganizationAPIKey.generate_key(name="k", tenant=org, allowed_ips=["not-an-ip"])
# ValueError: 'not-an-ip' is not a valid IP address or CIDR network
No custom parsing — a single address ("192.168.1.10") and a network in
CIDR notation ("10.0.0.0/8") are both accepted, because ip_network()
treats a bare address as a network with a full-length mask.
Checking a request's IP against the allowlist¶
api_key.is_ip_allowed("192.168.1.10") # True
api_key.is_ip_allowed("203.0.113.99") # False, if allowed_ips is set and doesn't cover it
is_ip_allowed() is a plain model method — no request object, no framework
dependency. An empty allowed_ips always returns True. A malformed
client_ip (not something ipaddress.ip_address() can parse) always
returns False rather than raising.
Resolving the client's IP from a request¶
get_client_ip() returns request.META["REMOTE_ADDR"] — the actual TCP
connection's source address — unless you've explicitly opted into
trusting a different header via TENANT_API_KEY_TRUSTED_PROXY_HEADER.
Trusting a proxy header¶
If your Django app sits behind a reverse proxy or load balancer,
REMOTE_ADDR is the proxy's address, not the real client's — you need the
proxy to forward the original address in a header (commonly
X-Forwarded-For), and you need Django to read it.
This is unset by default, and that's deliberate, not an oversight.
X-Forwarded-For and headers like it are ordinary HTTP request headers —
any client can set one to whatever they want. If Django trusted it
unconditionally, an attacker could set X-Forwarded-For: 192.168.1.10
themselves and walk straight through an IP allowlist that's supposed to
block them. Trusting the header is only safe when a proxy you control is
the one setting it, and stripping any client-supplied copy of that header
before your proxy adds its own — otherwise a client can still forge the
value your proxy passes through untouched.
When the setting is on, get_client_ip() picks one entry from the header
by counting from the right:
# HTTP_X_FORWARDED_FOR: "192.168.1.10, 198.51.100.7"
# ^ sent by the client ^ appended by your proxy
get_client_ip(request) # "198.51.100.7" (the default: one trusted proxy)
Every proxy appends the address of the peer it received the request from, so anything to the left was written by the client (or an earlier, untrusted hop) and is never used. Only the entries your own proxies added — on the right — mean anything. That's why the default is the rightmost entry, and why reading the leftmost one would let any caller who holds a stolen key prepend an address from the allowlist.
If more than one trusted proxy sits in front of Django, say how many:
The client is then the 2nd entry from the right. If the header has fewer
entries than that (the request didn't pass through every expected proxy) or
is missing, get_client_ip() falls back to REMOTE_ADDR. A proxy that
overwrites the header with a single address, or a single-valued header
like X-Real-IP, works with the default.
A value that isn't an IP address is returned as-is and simply never matches an allowlist entry, so a restricted key fails closed.
Enforcing the restriction¶
allowed_ips is enforced during authentication, in both Django REST
Framework and Django Ninja: a key used from an address outside its
allowlist is refused with a 403 before your view (or any permission class)
runs. There is nothing to add to a view, so a view can't accept the key and
forget to check.
class OrdersView(APIView):
authentication_classes = [TenantAPIKeyAuthentication]
permission_classes = [HasAPIKeyScope] # no IP class needed
required_scopes = ["orders:read"]
The client address comes from get_client_ip(request) (above). A key with
allowed_ips set and no known client address — for example calling
TenantAPIKeyAuthentication().authenticate_credentials(raw_key) without a
request — is refused rather than waved through. HasAllowedIP remains for
compatibility, but only repeats the check authentication already made; it's
deprecated.
If you use the helpers yourself: api_key.is_ip_allowed(get_client_ip(request)).
Upgrading from 0.4: before 0.5, allowed_ips was only checked where you
had added HasAllowedIP (DRF) or an inline check (Ninja). Keys with
allowed_ips set are now enforced everywhere, so any client calling from an
address not on the list — or every client, if you're behind a proxy and
haven't set TENANT_API_KEY_TRUSTED_PROXY_HEADER — starts getting 403s. Find
the affected keys first: OrganizationAPIKey.objects.exclude(allowed_ips=[]).
Next¶
- Rate limiting — the next check in the policy chain, after IP restriction and before scopes.
- Security — IP spoofing as a threat, and why trusting a proxy header is opt-in.