Django REST Framework API key authentication without a user row
Checking an API key in a DRF permission class leaves request.user anonymous on a 200 response, which quietly turns your per-customer rate limit into a per-IP one. Here is the authentication class that fixes it, and the 401 you are probably returning as a 403.

Sorower
Co-founder

In this article
- The fork every DRF API key tutorial takes without telling you
- Here is what a successful request looks like
- What the permission class actually costs you
- Then it gets worse
- request.user does not have to be a User
- The Django REST Framework API key authentication class, in full
- The 401 that is actually a 403
- Now your throttles work
- A few things people ask at this point
- What still breaks at customer number two
- The managed version
- Key takeaways
The endpoint returned 200. The API key was valid. Everything looked correct, right up until a customer emailed to say they were getting 429s on their first request of the day.
They were. Somebody else's traffic had already drained the bucket they were sharing.
That is the failure mode hiding inside almost every Django REST Framework API key authentication setup: the key gets checked, the request gets served, and Django never actually learns who made it. request.user stays anonymous on a successful response, and every piece of DRF that keys off identity quietly falls back to something else. Usually an IP address. Sometimes an IP address your client controls.
This post is about the one design decision that causes it, and the roughly forty lines that fix it. Everything below was run against Django 6.0.7, DRF 3.17.1 and Python 3.14.5.
The fork every DRF API key tutorial takes without telling you
DRF gives you two places to check an API key, and they are not interchangeable.
A permission class answers one question: is this request allowed? Its has_permission() returns a boolean. That is the whole contract.
An authentication class answers a different question: who is this? The DRF docs are explicit about the shape. The authenticate() method "should return a two-tuple of (user, auth) if authentication succeeds, or None otherwise," and DRF "will set request.user and request.auth using the return value of the first class that successfully authenticates."
The most popular package in this space, djangorestframework-api-key, ships a permission class: HasAPIKey. That is a defensible choice, and the package's docs give a reason for keeping keys away from users at all, warning that "associating API keys to users, directly or indirectly, can present a security risk."
The trouble is not the choice. The trouble is that nobody follows it downstream.
Here is what a successful request looks like
@api_view(["GET"])
@authentication_classes([]) # nothing sets request.user
@permission_classes([HasAPIKey]) # this only returns True/False
def whoami(request):
return Response({
"user": str(request.user),
"is_authenticated": bool(request.user.is_authenticated),
"auth": str(request.auth),
})
Send a perfectly valid API key:
HTTP 200 OK
{"user": "AnonymousUser", "is_authenticated": false, "auth": "None"}
A 200, from an authenticated client, that Django considers anonymous. The reason is visible in one line of the package's source: has_permission() ends with return self.model.objects.is_valid(key). It resolves the key, confirms it, and throws the identity away, because a boolean is all the interface can carry.
A permission class cannot tell your application who is calling. It was never designed to. That is the entire root cause of everything below.
What the permission class actually costs you
Anonymous request.user sounds cosmetic until you look at what reads it. Here is DRF's UserRateThrottle, verbatim:
def get_cache_key(self, request, view):
if request.user and request.user.is_authenticated:
ident = request.user.pk
else:
ident = self.get_ident(request) # <-- the IP address
return self.cache_format % {'scope': self.scope, 'ident': ident}
AnonymousUser.is_authenticated is False and AnonymousUser.pk is None, so every API-key request takes the else branch. Your per-user rate limit is a per-IP rate limit wearing a name badge.
I set a limit of 3/min and pointed two different customers, holding two different valid keys, at the same endpoint from the same address:
key1 request 1: 200
key1 request 2: 200
key1 request 3: 200
key2 request 1: 429 <-- key2's first request, ever
Customer Beta got throttled by Customer Alpha's traffic. In a test client both requests come from 127.0.0.1, which is contrived. In production it is not contrived at all: behind a load balancer, an egress NAT, or a corporate proxy, "same address" is the normal case rather than the exception.
Then it gets worse
If the IP is the quota key, the obvious question is who decides the IP. DRF's get_ident() reads X-Forwarded-For, and its behaviour depends on the NUM_PROXIES setting, which is None by default. With it unset, the identifier is built from the whole forwarded header when one is present.
Same 3/min limit, same client, one header rotated per request:
request 1 (X-Forwarded-For: 203.0.113.1): 200
request 2 (X-Forwarded-For: 203.0.113.2): 200
request 3 (X-Forwarded-For: 203.0.113.3): 200
request 4 (X-Forwarded-For: 203.0.113.4): 200
request 5 (X-Forwarded-For: 203.0.113.5): 200
request 6 (X-Forwarded-For: 203.0.113.6): 200
Six for three. Hold the header constant and the fourth request gets its 429 as designed. Oof.
To be fair to DRF, this is documented behaviour and NUM_PROXIES exists precisely to fix it: set it to the number of proxies in front of you and get_ident() counts in from the right end of the chain instead of trusting the whole thing. Set it today regardless of anything else in this post. But notice what you are doing, which is carefully hardening the wrong identifier. If your quota key is something the caller can influence, it is not a quota. The customer's key is sitting right there in the request, unforgeable and already validated. Use that.
request.user does not have to be a User
Here is the belief that pushes people toward permission classes in the first place: my consumers are servers, they don't have accounts, so I can't use an authentication class.
DRF never required a User. It requires the first element of that two-tuple, and it sets it on request.user. What that object is is entirely up to you. The other API key package in this space, djangorestframework-simple-apikey, already does this and says so plainly in its docs, noting that you get the key's entity at request.user even though it "might not necessarily be an User." Its maintainers call the naming counter-intuitive and have discussed exposing a request.entity instead. They are right that it reads oddly. They are also right that it works.
In practice the object needs two things: an is_authenticated property returning True so permission classes like IsAuthenticated pass, and a pk so throttles have something stable to key on.
class Consumer:
"""A machine consumer. Deliberately not a row in auth_user."""
def __init__(self, consumer_id, name, plan):
self.id = consumer_id
self.pk = consumer_id # what UserRateThrottle keys on
self.name = name
self.plan = plan
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def __str__(self):
return self.name
Not a model. Not a migration. Just an identity your request path can carry.
The Django REST Framework API key authentication class, in full
Keys are high-entropy secrets that arrive on every single request, so they get stored as a fast hash with an indexed prefix, not run through a password KDF. (That argument deserves its own post, and it is the same reasoning we walked through for FastAPI.) The prefix is what makes the lookup a single indexed hit instead of a table scan.
import hashlib
import secrets
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class ConsumerKeyAuthentication(BaseAuthentication):
keyword = "Bearer"
def authenticate(self, request):
header = request.headers.get("Authorization", "")
if not header.startswith(self.keyword + " "):
# Not our scheme. Return None so other classes get a turn.
return None
key = header[len(self.keyword) + 1:].strip()
if not key:
raise AuthenticationFailed("No API key provided.")
# Keys are issued as "<prefix>.<secret>", e.g. "ak_live_9f2c.Hq7...".
# Only the prefix is indexed; the secret is never stored in the clear.
prefix, _, _ = key.partition(".")
digest = hashlib.sha256(key.encode()).hexdigest()
try:
record = APIKeyRecord.objects.select_related("consumer").get(
prefix=prefix, revoked_at__isnull=True,
)
except APIKeyRecord.DoesNotExist:
raise AuthenticationFailed("Invalid API key.")
if not secrets.compare_digest(record.digest, digest):
raise AuthenticationFailed("Invalid API key.")
if not record.consumer.is_active:
raise AuthenticationFailed("This account is suspended.")
consumer = Consumer(record.consumer.id, record.consumer.name,
record.consumer.plan)
return (consumer, record)
def authenticate_header(self, request):
return f'{self.keyword} realm="api"'
Three details in there are load-bearing, and the last one is the one people miss.
Returning None when the header isn't yours is not a rejection. Per the docs, "if authentication is not attempted, return None. Any other authentication schemes also in use will still be checked." Raise AuthenticationFailed instead and you have just broken session auth for your own admin.
Comparing digests with secrets.compare_digest rather than == keeps the comparison constant-time. Same reasoning as crypto/subtle in Go or MessageDigest.isEqual in Java.
And then authenticate_header, which looks like boilerplate and is not.
The 401 that is actually a 403
Delete authenticate_header from the class above and send a request with no credentials at all. You get this:
HTTP 403 Forbidden
{"detail": "Authentication credentials were not provided.",
"code": "not_authenticated"}
Read that twice. The status code says you are not allowed. The body says you did not log in. They disagree, and the status code is the one your client's HTTP library will branch on.
Add the method back and the same request returns what it should:
HTTP 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
{"detail": "Authentication credentials were not provided.",
"code": "not_authenticated"}
The rule comes straight from the DRF docs: 401 responses must always carry a WWW-Authenticate header, so a scheme that cannot produce one "will return HTTP 403 Forbidden responses when an unauthenticated request is denied access." No header, no 401. And because "the first authentication class set on the view is used when determining the type of response," a single class missing this method at the front of your list silently changes the status code for every view behind it.
Why it matters more than it looks: 401 means your credential is missing or wrong, fix it and retry. 403 means your credential is fine, you just can't have this. Client SDKs retry those differently, refresh logic branches on them, and your support inbox pays for the confusion. We wrote up the same category of mistake for 429 responses; the pattern is identical. The status code is an API, and a wrong one sends clients down the wrong path with total confidence.
Now your throttles work
With request.user holding a real identity, the fix is anticlimactic. UserRateThrottle just starts working, because request.user.is_authenticated is now True and request.user.pk is your consumer id. Same 3/min limit, same two customers, same address:
alpha request 1: 200
alpha request 2: 200
alpha request 3: 200
alpha request 4: 429
beta request 1: 200 <-- unaffected by alpha
If you want the scope named honestly rather than borrowing the user bucket, a custom throttle is about eight lines:
from rest_framework.throttling import SimpleRateThrottle
class ConsumerRateThrottle(SimpleRateThrottle):
scope = "consumer"
def get_cache_key(self, request, view):
consumer = getattr(request, "user", None)
if consumer is None or not getattr(consumer, "is_authenticated", False):
return None # not ours; let AnonRateThrottle handle it
return self.cache_format % {"scope": self.scope, "ident": consumer.pk}
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {"consumer": "600/min", "anon": "20/min"},
"NUM_PROXIES": 1, # set this to your real proxy depth
}
Returning None from get_cache_key means "this throttle has no opinion here," which lets anonymous traffic fall through to a separate, much tighter bucket. Throttled responses come back as a 429 with a Retry-After header, which is what a well-behaved client is waiting for.
A few things people ask at this point
Can I keep HasAPIKey and just write a smarter throttle? You can, but the throttle has to re-resolve the key itself, which means a second lookup on every request and two places that can disagree about who the caller is. The identity belongs at the layer DRF designed for identity.
Does this break session auth or the browsable API? No, as long as authenticate() returns None for requests that aren't carrying your scheme. List your class alongside SessionAuthentication and each takes the requests it recognises.
Won't a database hit per request hurt? It will eventually. The prefix column makes it a single indexed lookup rather than a scan, which buys you a lot of runway, and a short cache in front keyed on the prefix buys more. The catch is that the cache TTL becomes your revocation delay: a key you delete stays usable until the cached copy expires. That is a real trade-off to make deliberately rather than discover during an incident.
What still breaks at customer number two
Everything above fixes identity. It does not fix the rest of the job, and it is worth being honest about the gap before you commit to owning it.
- Quota, as opposed to rate. Throttles cap how fast a customer calls you. They say nothing about how much they have bought this month. Those are different axes and they need different storage.
- Rotation. The moment a customer needs to swap a key without downtime, you need two valid keys at once and an overlap window. We covered that pattern in full.
- Where the limit lives. Per key or per customer? Get this wrong and every customer who wants a second key accidentally doubles their allowance. The seam matters.
- Usage records. Somebody will dispute an invoice. Throttle counters live in a cache and expire; they are not an audit trail.
None of these are hard on their own. They are just a steady drip of work that arrives one support ticket at a time, which is how a two-day project turns into a quarter.
If you want the conceptual version of the authentication-versus-permission split, this walkthrough covers it well:
The managed version
The pattern above is the right shape whether you build it or buy it. If you would rather not own the storage, rotation and metering, ReqKey does the validation and the usage accounting, and the Python SDK ships Django middleware.
# pip install "reqkey[django]"
# settings.py
REQKEY = {
"API_ID": "api_payments",
"MODE": "both", # validate the key + log the request
"KEY_LOCATION": "header",
"KEY_NAME": "X-Acme-Key",
"KEY_SCHEME": "raw",
"CREDITS": 1,
"EXCLUDE_PATHS": ("/health", "/admin/*", "/static/*"),
}
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"reqkey.django.ReqKeyMiddleware",
]
The middleware attaches its decision to the request, so the DRF bridge is short. This is the same authentication class as before with the lookup replaced:
class ReqKeyAuthentication(BaseAuthentication):
def authenticate(self, request):
decision = getattr(request, "reqkey", None)
if decision is None or not decision.allowed:
return None # middleware already rejected it upstream
# validate() confirms the key and meters the credit, but it does not
# hand back a consumer id, so this mapping stays yours. See below.
consumer = self.resolve_consumer(request)
# None for consumers on an unlimited plan.
consumer.credits_remaining = decision.credits_remaining
return (consumer, decision.request_id)
def authenticate_header(self, request):
return 'Bearer realm="api"'
Two things worth knowing before you wire this up, because both would otherwise be surprises.
First, POST /key/validate returns the API identity and the credit state, but it does not hand back a consumer id. If your view logic needs to know which customer is calling, you keep that mapping yourself or resolve it once via POST /key/details (which does return consumerId) and cache it. That is why resolve_consumer above is your code and not ours.
Second, MODE: "both" validates the key and logs the request, which is two billable ReqKey calls for one incoming customer request. That is the right default for analytics and the wrong default for a cost estimate, so do the arithmetic against the pricing page with the doubling included.
What you stop building is the part this whole post has been circling. Credits are held at the consumer, so every key a customer owns draws on one shared pool, and a consumer can carry its own rate limit expressed as a number of validations per window. The limit follows the customer instead of the key, and it never has to guess from an IP address. The concepts page has the full model.
Key takeaways
- Check API keys in an authentication class, not a permission class. A permission class returns a boolean, which means the identity is resolved and then discarded, and every downstream feature that needs to know the caller has to guess.
request.userdoes not need a database row. Any object withis_authenticatedand apksatisfies DRF, so machine consumers get a real identity without polluting your user table.- Implement
authenticate_header()or ship the wrong status code. Without it DRF answers missing credentials with a 403 whose own body says the credentials were never provided, and clients branch on the status, not the body. - Never let an IP be your quota key. Customers behind one load balancer share a bucket, and with
NUM_PROXIESunset a rotatingX-Forwarded-Forhands out fresh buckets on request. SetNUM_PROXIES, then throttle on the consumer anyway. - Rate and quota are different problems. A throttle caps requests per minute; it has no idea how much a customer bought this month. Decide where each one lives before customer number two arrives.
If you get to the part where you are writing your own key storage, rotation windows and monthly credit accounting, that is the point where buying starts to look reasonable. ReqKey's free tier includes 100,000 requests a month, which is enough to run the throttling experiments in this post against a real consumer pool and see whether the seam fits your API before you commit to maintaining one.



