Filter concepts by levelShowing all levels.

Django · DRF Authentication

DRF Authentication

Session, token, and JWT schemes; the class list and its order; OAuth2 and custom authentication

  • Authentication answers "who", not "may they"permission classes are a separate layer that runs afterwards
  • The class list is first-match-winsNone = "not my scheme"; AuthenticationFailed = "my scheme, bad credential"
  • No match is not an errorrequest.user becomes AnonymousUser — test is_authenticated, never `is None`
  • 401 vs 403 comes from the FIRST auth classno WWW-Authenticate to send means DRF must answer 403
  • A JWT is signed, not encryptedreadable by anyone holding it, and valid until exp no matter what

1.SessionAuthentication, TokenAuthentication, and JWT

An authentication class answers one question: who sent this request? SessionAuthentication reads Django's own session cookie, so it works for a browser front-end already logged in through the normal Django login page — and because it rides on a cookie, DRF still enforces CSRF on unsafe methods. TokenAuthentication reads an `Authorization: Token <key>` header and looks that key up in a database table, which suits a mobile app or a script that has no cookie jar. A JWT is a token whose contents are signed rather than stored: the server can check it with a signing key alone, so no database row is read per request — but nothing revokes it before it expires, which is why Simple JWT issues a short-lived access token alongside a longer-lived refresh token.

Key features

  • DRF's out-of-the-box `DEFAULT_AUTHENTICATION_CLASSES` is `SessionAuthentication` + `BasicAuthentication`; a real API almost always replaces it, globally in `REST_FRAMEWORK` or per-view with `authentication_classes`
  • SessionAuthentication enforces CSRF for `POST`/`PUT`/`PATCH`/`DELETE` — DRF only requires the CSRF token on *authenticated* requests, unlike plain Django which requires it on anonymous POSTs too
  • TokenAuthentication needs `rest_framework.authtoken` in `INSTALLED_APPS` and a migration; the header is literally `Authorization: Token <key>`, with the word `Token` and a space
  • A JWT is three base64url segments — header, payload, signature — joined by dots; the payload is *signed, not encrypted*, so anyone holding the token can read the claims inside it
  • Simple JWT (`djangorestframework-simplejwt`) ships `JWTAuthentication` plus `/token/` and `/token/refresh/` views, and reads the `Authorization: Bearer <token>` header, not `Token`
  • A JWT cannot be revoked by deleting a row — it stays valid until `exp`; Simple JWT's optional blacklist app makes *refresh* tokens revocable, which is why access-token lifetimes are kept to minutes

Choosing a scheme

Choosing a scheme
SchemeClient sendsPer-request DB readRevocable now?Fits
SessionAuthenticationsession cookie (automatic)yes — session rowyes, delete the sessiona browser front-end served from the same site
TokenAuthentication`Authorization: Token <key>`yes — token rowyes, delete the tokenmobile apps, scripts, server-to-server
JWT (Simple JWT)`Authorization: Bearer <jwt>`no — signature check onlyno, only on expirymany services validating one login independently
Where the truth about "who is this" lives

Session

Cookie holds a session key

the browser attaches it automatically

Row in django_session

one SELECT per request

CSRF token required

unsafe methods only, authenticated requests only

Revoke = delete the row

takes effect on the next request

DRF Token

Authorization: Token <key>

sent deliberately, never automatically

Row in authtoken_token

one SELECT per request

No CSRF exposure

a cross-site form cannot set a header

No expiry built in

you add rotation yourself

JWT

Authorization: Bearer <jwt>

header.payload.signature

Signature check only

zero database reads

Payload is readable

signed, not encrypted — never put secrets in it

Valid until exp

a deleted account still authenticates until then

  • Incoming request
  • Session — state on the server, pointer in a cookie
    • Cookie holds a session key — the browser attaches it automatically
    • Row in django_session — one SELECT per request
    • CSRF token required — unsafe methods only, authenticated requests only
    • Revoke = delete the row — takes effect on the next request
  • DRF Token — state on the server, key in a header
    • Authorization: Token <key> — sent deliberately, never automatically
    • Row in authtoken_token — one SELECT per request
    • No CSRF exposure — a cross-site form cannot set a header
    • No expiry built in — you add rotation yourself
  • JWT — state inside the token itself
    • Authorization: Bearer <jwt> — header.payload.signature
    • Signature check only — zero database reads
    • Payload is readable — signed, not encrypted — never put secrets in it
    • Valid until exp — a deleted account still authenticates until then

Command

python
class OrderViewSet(viewsets.ModelViewSet):
    authentication_classes = [JWTAuthentication, SessionAuthentication]
    permission_classes = [IsAuthenticated]

Session = server-side state behind an automatic cookie, so CSRF applies. Token = server-side state behind a deliberate header, so CSRF does not, but every request costs a database read. JWT = the state is in the signed token, so no database read at all — and no revocation before `exp`, which is why access tokens live for minutes and a refresh token does the long-lived work. A JWT payload is signed, not encrypted: never put anything secret in it.

2.Authentication order, anonymous users, and 401 vs 403

`authentication_classes` is a list, and DRF walks it in order. Each class returns a `(user, auth)` pair if it recognises the request, or `None` to say "not my scheme, try the next one" — the first pair wins and the rest are never called. If every class returns `None`, that is not an error: `request.user` becomes `AnonymousUser` and `request.auth` becomes `None`, and the request continues to the permission classes, which decide whether an anonymous caller is allowed. A class that recognises the scheme but finds the credential invalid raises `AuthenticationFailed` instead, which stops the walk immediately.

Key features

  • Order is first-match-wins: the first class returning a `(user, auth)` two-tuple sets `request.user`/`request.auth`, and no later class runs
  • Returning `None` means "this request is not for my scheme" and passes control on; raising `AuthenticationFailed` means "it is my scheme and the credential is bad" and stops the list
  • All classes returning `None` is a success path, not a failure — `request.user` is `AnonymousUser`, `request.auth` is `None`, and the permission classes decide from there
  • `request.user.is_authenticated` is the check to write; `request.user is None` never happens, because DRF always substitutes `AnonymousUser` (configurable via `UNAUTHENTICATED_USER`)
  • The response is 401 only if the *first* authentication class supplies a `WWW-Authenticate` header via `authenticate_header()`; otherwise an unauthenticated denial is returned as 403
  • `SessionAuthentication` deliberately does not implement `authenticate_header()`, so a session-only API answers unauthenticated requests with 403 — putting a token or JWT class first restores 401

What a request ends up with

What a request ends up with
Situationrequest.userrequest.authResponse if the view needs auth
No credentials sent`AnonymousUser``None`401 or 403 — decided by the first auth class
Valid tokenthe `User`the token / validated JWTproceeds to permission classes
Malformed or expired token— (never reached)—401 with `WWW-Authenticate`
Valid token, permission deniedthe `User`the token403 — authentication succeeded
One request through the authentication list, and how the status code is chosen
returns(user, auth)bad Bearertokenreturns Nonereturns(user, auth)returns Noneallowedanonymous +header availableauthenticated,or no header

Request arrives

cookie? Authorization header? nothing?

Class 1 · JWTAuthentication

reads Authorization: Bearer

Class 2 · SessionAuthentication

reads the session cookie

AnonymousUser

every class returned None — not an error

request.user + request.auth set

first match wins; later classes skipped

AuthenticationFailed raised

right scheme, bad credential

Permission classes run

a separate question from identity

401 Unauthorized

first auth class supplied WWW-Authenticate

403 Forbidden

identity known, or no WWW-Authenticate to send

200 — view runs

  • Request arrives — cookie? Authorization header? nothing?
    • leads to Class 1 · JWTAuthentication
  • Class 1 · JWTAuthentication — reads Authorization: Bearer
    • leads to request.user + request.auth set (returns (user, auth))
    • on error, leads to AuthenticationFailed raised (bad Bearer token)
    • leads to Class 2 · SessionAuthentication (returns None)
  • Class 2 · SessionAuthentication — reads the session cookie
    • leads to request.user + request.auth set (returns (user, auth))
    • leads to AnonymousUser (returns None)
  • AnonymousUser — every class returned None — not an error
    • leads to Permission classes run
  • request.user + request.auth set — first match wins; later classes skipped
    • leads to Permission classes run
  • AuthenticationFailed raised — right scheme, bad credential
    • on error, leads to 401 Unauthorized
  • Permission classes run — a separate question from identity
    • leads to 200 — view runs (allowed)
    • on error, leads to 401 Unauthorized (anonymous + header available)
    • on error, leads to 403 Forbidden (authenticated, or no header)
  • 401 Unauthorized — first auth class supplied WWW-Authenticate
  • 403 Forbidden — identity known, or no WWW-Authenticate to send
  • 200 — view runs

Command

python
authentication_classes = [JWTAuthentication, SessionAuthentication]
# first match wins; None = "not my scheme"; AuthenticationFailed = "bad credential"

The authentication list is first-match-wins: `None` means "not my scheme", a `(user, auth)` tuple wins, and `AuthenticationFailed` stops everything. No match is not an error — you get `AnonymousUser`, so always test `request.user.is_authenticated`, never `is None`. 401 versus 403 is decided by whether the FIRST authentication class can supply a `WWW-Authenticate` header, which is why a session-only API answers 403 where you expected 401.

3.OAuth2 concepts, custom authentication classes, and the integrations

OAuth2 is a delegation protocol: a user lets one application act on their behalf at another, without handing over a password. Four parties are involved — the resource owner (the person), the client (your app), the authorization server (the issuer of tokens), and the resource server (the API). The common browser flow is authorization code with PKCE: the client sends the user to the authorization server, gets a short-lived code back on a redirect, and exchanges that code for an access token over a back-channel request. When none of the shipped schemes fit — an HMAC-signed webhook, a legacy header, a partner API key — you subclass `BaseAuthentication`, implement `authenticate(request)` returning `(user, auth)` or `None`, and add `authenticate_header()` so DRF can answer with 401 rather than 403.

Key features

  • The four OAuth2 roles are resource owner (the user), client (the app asking), authorization server (issues tokens), resource server (the API accepting them) — one Django project can be both of the last two, or neither
  • Authorization code + PKCE is the flow for anything user-facing; client credentials is the flow for machine-to-machine, where there is no user to redirect
  • Scopes are strings the token carries (`read`, `orders:write`) — the resource server enforces them, and DRF exposes them through `request.auth`, not `request.user`
  • A custom class subclasses `BaseAuthentication` and implements `authenticate(self, request)` → `(user, auth)` tuple, `None`, or `raise AuthenticationFailed`
  • `authenticate_header(self, request)` returns the `WWW-Authenticate` value; omit it and every unauthenticated denial on that view becomes 403 instead of 401
  • `django-oauth-toolkit` provides `OAuth2Authentication` and makes the project an authorization server; `django-allauth` and `social-auth-app-django` make it an OAuth *client* for social login — different jobs, often confused

Which OAuth2 grant, and when

Which OAuth2 grant, and when
GrantUse it forUser present?Notes
Authorization code + PKCEweb apps, SPAs, mobile appsyesthe default for anything user-facing; PKCE is required for public clients
Client credentialsservice-to-service callsnothe token represents the application, so `request.user` may be anonymous
Refresh tokenrenewing an expired access tokenno (already consented)rotate on use, and treat the refresh token as the long-lived secret
Implicit / passwordnothing new—both discouraged by the OAuth 2.0 Security BCP — do not choose them for new work
Authorization code with PKCE — who ever sees the password
User
Your client app
Authorization server
Your API
  1. 1clicks "Sign in"
  2. 2redirect + code_challengethe challenge is a hash of a secret the client keeps
  3. 3login + consent screenthe password is typed HERE and nowhere else
  4. 4approves the requested scopes
  5. 5redirect back with a one-time code
  6. 6code + code_verifier → tokenback-channel; a stolen code alone is useless without the verifier
  7. 7access token (scoped, expiring) + refresh token
  8. 8Authorization: Bearer <access token>
  9. 9200, or 403 if the scope is missing
  1. User → Your client app: clicks "Sign in"
  2. Your client app → Authorization server: redirect + code_challenge (the challenge is a hash of a secret the client keeps)
  3. Authorization server → User: login + consent screen (the password is typed HERE and nowhere else)
  4. User → Authorization server: approves the requested scopes
  5. Authorization server → Your client app: redirect back with a one-time code
  6. Your client app → Authorization server: code + code_verifier → token (back-channel; a stolen code alone is useless without the verifier)
  7. Authorization server → Your client app: access token (scoped, expiring) + refresh token
  8. Your client app → Your API: Authorization: Bearer <access token>
  9. Your API → Your client app: 200, or 403 if the scope is missing

Command

python
class SignatureAuthentication(BaseAuthentication):
    def authenticate(self, request):
        ...            # -> (user, auth) | None | raise AuthenticationFailed

    def authenticate_header(self, request):
        return 'Signature realm="api"'

OAuth2 exists so a password never reaches the client — the user authenticates at the authorization server and the client receives a scoped, expiring token instead. Use authorization code + PKCE for anything user-facing, client credentials for machine-to-machine. Enforce scopes against `request.auth`, never `request.user`. A custom scheme is three methods: `authenticate()` returning a tuple or `None` (never raising on absence), and `authenticate_header()` so denials are 401 rather than 403.

Remember

  • Session = server state behind an automatic cookie, so CSRF applies. Token = server state behind a deliberate header. JWT = state inside the signed token, so no database read and no revocation before exp.
  • DRF walks authentication_classes in order and stops at the first class returning a (user, auth) tuple; a class that does not recognise the request must return None, not raise.
  • A 403 where you expected 401 is a list-order question: DRF returns 401 only when the first class can supply a WWW-Authenticate header, and SessionAuthentication cannot.
  • OAuth2 scopes live on the token — enforce them against request.auth, never request.user, or every token becomes a full-access token.

Pro tip

  • Keep ACCESS_TOKEN_LIFETIME in minutes: it is the exact window during which a disabled account still authenticates, because nothing reads the database while a JWT is inside its lifetime.
  • request.successful_authenticator names the class that actually authenticated the request — the direct way to debug list order instead of guessing from headers.

Next up

  • DRF Permissions
  • DRF Filtering, Search, and Ordering
  • Django Security

Sources

  • DRF Authentication — django-rest-framework.org/api-guide/authentication/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.