Why does Django REST Framework answer 403 instead of 401 to unauthenticated requests under its default authentication classes, and how do you change that?
answer
- 401 needs a challenge header
- only one class is asked
- session scheme has no challenge
- reorder or override the header
basics
~20 sDRF asks only the first authentication class for a WWW-Authenticate value. The default list starts with SessionAuthentication, which has none, so DRF turns 401 into 403. Listing TokenAuthentication or BasicAuthentication first, or overriding authenticate_header(), restores 401.
solid answer
~40 s`NotAuthenticated` and `AuthenticationFailed` both default to 401, but `handle_exception()` calls `authenticate_header()` on the **first** authentication class of the view. If it returns a string, DRF sends 401 with that `WWW-Authenticate` value; if it returns `None`, DRF rewrites the response to 403. `SessionAuthentication` does not override the method, and it is first in the shipped default list, so anonymous denials become 403. `BasicAuthentication` returns `Basic realm="api"` and `TokenAuthentication` returns `Token`. To give mobile clients 401, put the token class first globally or on the relevant views, or override `authenticate_header()` in a custom class. An authenticated user denied by a permission always gets 403.
code
python · 13 linesfrom rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
class MobileProfile(APIView):
# Token first: anonymous or bad-token requests get 401 + WWW-Authenticate: Token
authentication_classes = [TokenAuthentication, SessionAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({"username": request.user.get_username()})go deeper
Know that 401 means missing or bad credentials and 403 means not allowed, and that DRF's defaults answer anonymous denials with 403.
Explain handle_exception(): the first class's authenticate_header() either supplies WWW-Authenticate for a 401 or the status is coerced to 403.
Choose the class order per client type, check the side effects of reordering, and pin the statuses in contract tests.
Set one error contract across services so clients can rely on 401 for re-authentication and 403 for access denial.
## The symptom A mobile team reports that an expired or missing token makes Django REST Framework (DRF) answer **403 Forbidden**, while their client only re-authenticates on **401 Unauthorized**. The same API answers 401 in another project. The difference is not a bug; it is how DRF chooses between the two codes. ## Where the status code comes from When a request is unauthenticated and a permission check denies it, DRF raises `NotAuthenticated` (provided the view has authentication classes at all). When credentials are present but invalid, an authentication class raises `AuthenticationFailed`. Both exception classes default to **401**. Then `APIView.handle_exception()` asks the view for an authenticate header: 1. `get_authenticate_header()` takes the **first** authentication class on the view and calls its `authenticate_header(request)`. 2. If that returns a string, DRF keeps **401** and sends it as the `WWW-Authenticate` response header. 3. If it returns `None`, DRF **rewrites the status to 403** and sends no such header. HTTP requires a 401 response to carry `WWW-Authenticate`, telling the client which scheme to use; DRF will not send 401 when the first scheme has nothing to put there. ## What each built-in class says | Class | `authenticate_header()` | Unauthenticated denial when it is first | |---|---|---| | `SessionAuthentication` | not overridden — `None` | **403**, no header | | `BasicAuthentication` | `Basic realm="api"` | 401 | | `TokenAuthentication` | `Token` (its `keyword`) | 401 | | custom `BaseAuthentication` subclass | `None` unless you override it | 403 | DRF's shipped `DEFAULT_AUTHENTICATION_CLASSES` lists `SessionAuthentication` first, then `BasicAuthentication`. So with default settings, an anonymous request refused by `IsAuthenticated` receives **403**, even though basic authentication is enabled. Note that the **first class decides even when a later class raised the error**. If the list is `[SessionAuthentication, TokenAuthentication]` and the token class raises `AuthenticationFailed` for an unknown key, the response is still 403. ## What is always 403 - An **authenticated** user who fails a permission check gets 403 regardless of scheme — credentials were fine, access is not. - A **CSRF failure** under session authentication is raised as `PermissionDenied`, so it is 403. - A permission's own denial message for an identified user is 403. ## Ways to get 401 where clients need it 1. **Reorder**: put `TokenAuthentication` first in the list for API-first projects. Browser sessions still work, because a request without an `Authorization` header falls through to the session class. 2. **Per view or per API area**: set `authentication_classes` on the views mobile clients call, with the token class first. 3. **Custom class**: override `authenticate_header()` on your own authentication class to return the scheme name you want advertised. Reordering has one side effect to check: with the token class first, a browser request that carries both a session and a garbled `Authorization` header now fails on the header instead of being identified by the session. ## A worked comparison Three requests against a view protected by `IsAuthenticated`, under two class orders: | Request | `[Session, Basic]` (shipped default) | `[Token, Session]` | |---|---|---| | No credentials at all | 403, no header | 401, `WWW-Authenticate: Token` | | `Authorization: Token <unknown key>` | not read by either class — 403 | 401 with "Invalid token." | | Valid token, but the permission denies | not applicable | 403 | The middle row shows a second effect: under the shipped default, nothing reads a token header, so a token client is simply anonymous. Adding `TokenAuthentication` *after* `SessionAuthentication` makes the unknown key raise, but the response is still 403, because the session class is first. ## Why it matters - Clients use the distinction: 401 means "get new credentials", 403 means "you are known but not allowed". A mobile app that sees 403 for an expired token shows a permissions error instead of the login screen. - Monitoring that counts 401s as authentication failures undercounts when everything arrives as 403. - Contract tests should pin the expected status for anonymous, bad-credential and forbidden cases, so a settings change that reorders classes is caught.
- In DRF, if SessionAuthentication is first and TokenAuthentication raises AuthenticationFailed for an unknown key, what status does the client see?403. The exception defaults to 401, but `handle_exception()` asks only the first class for an authenticate header; `SessionAuthentication` returns `None`, so DRF changes the status to 403 and omits `WWW-Authenticate`. Which class raised the error does not matter.
- How would you make a custom DRF authentication class produce 401 responses?Override `authenticate_header(self, request)` to return the scheme string to advertise, for example a Bearer challenge with an `api` realm, and list the class first on the views concerned. Without the override, `BaseAuthentication.authenticate_header()` returns `None` and unauthenticated denials become 403.
saying these in an interview costs you the question
- DRF always returns 401 when the request has no credentials.
- The class that raised AuthenticationFailed decides between 401 and 403.
- BasicAuthentication in the default list guarantees 401 responses.
- An authenticated user denied by a permission gets 401.
- 403 without WWW-Authenticate means DRF is misconfigured.