skip to content

An unauthenticated client calling a Django REST Framework endpoint gets 403 instead of the expected 401; how does DRF choose between them, and how do you change it?

level: seniorimportance: should knowfreq 34%

answer

  1. who failed, and was anyone tried
  2. a header decides the code
  3. the first authenticator speaks
  4. session auth has no challenge

basics

~20 s

DRF raises NotAuthenticated when authenticators exist but none succeeded, then returns 401 only if the first authentication class supplies a WWW-Authenticate header; otherwise it coerces the status to 403. SessionAuthentication, first by default, supplies none; put a header-based class first.

solid answer

~40 s

Two steps decide it. When a permission's `has_permission()` fails, `APIView.permission_denied()` raises `NotAuthenticated` if the view has authenticators and none succeeded, else `PermissionDenied` (403). Then `handle_exception()` asks the **first** authentication class for `authenticate_header()`: if it returns a value, the response is 401 with that `WWW-Authenticate` header; if not, the status is coerced to 403. The default `DEFAULT_AUTHENTICATION_CLASSES` is `[SessionAuthentication, BasicAuthentication]`, and `SessionAuthentication` returns no header, so anonymous requests get 403. To get 401, list a header-based class first — `TokenAuthentication`, `BasicAuthentication` or a custom class implementing `authenticate_header()` — globally or per view. With `authentication_classes = []` every denial is `PermissionDenied`, so 403. `AuthenticationFailed` from bad credentials follows the same header rule.

code

python · 9 lines
python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",   # first: supplies WWW-Authenticate
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

go deeper

for a junior

Know the codes: 401 means not authenticated, 403 means authenticated but not allowed, and DRF may send 403 for both in some configurations.

for a middle

Explain permission_denied() choosing NotAuthenticated or PermissionDenied, and handle_exception() using the first authenticator's authenticate_header() to keep 401 or coerce to 403.

for a senior

Diagnose client complaints about 403 versus 401 from the authentication class order, fix it per view or globally, and test status and WWW-Authenticate together.

for a principal

Choose an authentication layout per client type, browser sessions versus tokens, so status semantics stay predictable across the whole API.

## The symptom A mobile client calls `GET /convert/` without a token and gets **403 Forbidden**. The client team expected **401 Unauthorized**, which their HTTP library uses to trigger a token refresh. The view has `permission_classes = [IsAuthenticated]` and the project uses DRF's default authentication classes. Nothing is broken; this is Django REST Framework's (DRF's) documented rule. ## Step 1: which exception is raised `APIView.initial()` authenticates, then calls `check_permissions()`. When a permission's `has_permission()` returns `False`, `permission_denied()` decides: 1. If `request.authenticators` is non-empty **and** `request.successful_authenticator` is `None` — authentication was attempted and nobody was identified — it raises `NotAuthenticated` (default status 401, detail *Authentication credentials were not provided.*). 2. Otherwise — the user was authenticated, or the view has no authenticators — it raises `PermissionDenied` (403, *You do not have permission to perform this action.*). Bad credentials take a different path to the same place: an authenticator raises `AuthenticationFailed` (401, *Incorrect authentication credentials.*) while `request.user` is evaluated. ## Step 2: which status is sent `handle_exception()` treats `NotAuthenticated` and `AuthenticationFailed` specially. It calls `get_authenticate_header()`, which asks the **first** authenticator in the view's list for `authenticate_header(request)`: - a string, such as `Basic realm="api"` or `Token`: the response is **401** with that value in `WWW-Authenticate`; - `None`: the exception's status is **coerced to 403**. The reason is protocol hygiene: a 401 response is expected to carry a `WWW-Authenticate` challenge, so DRF only sends 401 when it has one. | First authentication class | `authenticate_header()` | Anonymous request to `IsAuthenticated` view | |---|---|---| | `SessionAuthentication` | none | 403 | | `BasicAuthentication` | `Basic realm="api"` | 401 | | `TokenAuthentication` | `Token` | 401 | | custom class without the method | none (base returns nothing) | 403 | | no classes (`[]`) | — | 403 (`PermissionDenied` raised directly) | ## Why defaults give 403 `DEFAULT_AUTHENTICATION_CLASSES` defaults to `SessionAuthentication` followed by `BasicAuthentication`. Session comes first, so the header lookup returns `None`, and every unauthenticated denial is sent as 403 — even though `BasicAuthentication` is in the list and could have challenged. ## How to change it 1. **Reorder**: put the header-based class your clients use first, globally in `REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"]` or per view in `authentication_classes`. 2. **Split by client**: browser-facing views keep session auth (and 403); API views for token clients list the token class first. 3. **Custom scheme**: implement `authenticate_header()` on your authentication class to return its scheme name, e.g. `Bearer realm="api"`. 4. **Do not patch statuses in the exception handler** unless you also add a correct `WWW-Authenticate` header; otherwise you publish a 401 without a challenge. ## A worked trace An anonymous `GET /convert/` with no `Authorization` header, on a view with `IsAuthenticated`: 1. With the defaults, both authenticators return `None`; `successful_authenticator` is `None`; `IsAuthenticated` fails; `permission_denied()` raises `NotAuthenticated`; the first authenticator is `SessionAuthentication`, which has no header; the response is **403** with the detail *Authentication credentials were not provided.* 2. With `TokenAuthentication` listed first, the same steps run until the header lookup, which returns `Token`; the response is **401** with `WWW-Authenticate: Token` and the same detail. The detail text is identical in both cases — only the status and header differ, which is why clients that branch on status notice and humans reading the body do not. ## Things to verify after the change - Clients receive 401 **with** `WWW-Authenticate` for missing or bad credentials, and 403 for authenticated users lacking permission. - Browsable-API and session users still work: a header-based class listed first does not stop `SessionAuthentication` from authenticating requests that carry a session cookie. - Tests assert both the status and the header, so a later reorder of the authentication list cannot silently flip 401 back to 403.

  • An authenticated user without permission gets 403; could reordering authentication classes turn that into 401?
    No. `permission_denied()` raises `PermissionDenied` whenever an authenticator succeeded, and `handle_exception()` only adjusts `NotAuthenticated` and `AuthenticationFailed`. The authentication order affects only unauthenticated or badly authenticated requests.
  • Why does DRF coerce to 403 rather than send 401 without a header?
    A 401 response is meant to carry a `WWW-Authenticate` challenge telling the client how to authenticate. Session authentication has no such challenge — the remedy is logging in through a page — so DRF reports the denial as 403 instead of sending an incomplete 401.

saying these in an interview costs you the question

  • DRF always returns 401 for anonymous users and 403 for authenticated ones.
  • The last authentication class in the list decides the WWW-Authenticate header.
  • Setting authentication_classes = [] makes anonymous requests get 401.
  • SessionAuthentication sends a WWW-Authenticate header pointing at the login page.
  • The 401/403 choice is made by the IsAuthenticated permission class.