skip to content

Why does Django REST Framework answer 403 instead of 401 to unauthenticated requests under its default authentication classes, and how do you change that?

level: middleimportance: should knowfreq 40%

answer

  1. 401 needs a challenge header
  2. only one class is asked
  3. session scheme has no challenge
  4. reorder or override the header

basics

~20 s

DRF 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 lines
python
from 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

for a junior

Know that 401 means missing or bad credentials and 403 means not allowed, and that DRF's defaults answer anonymous denials with 403.

for a middle

Explain handle_exception(): the first class's authenticate_header() either supplies WWW-Authenticate for a 401 or the status is coerced to 403.

for a senior

Choose the class order per client type, check the side effects of reordering, and pin the statuses in contract tests.

for a principal

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.