Filter concepts by levelShowing all levels.

Django · Section 47

DRF Authentication

Level
advanced
Read
30 min
Concepts
3

Authentication in DRF answers one question — who sent this request — and hands the answer to the permission layer as `request.user` and `request.auth`. Three schemes cover almost every project: SessionAuthentication reads Django's session cookie and therefore still enforces CSRF on unsafe methods; TokenAuthentication reads an `Authorization: Token <key>` header and looks the key up in a table; and a JWT (in practice, Simple JWT) carries a signed payload so a request can be authenticated with no database read at all — at the price of no revocation before the token expires. `authentication_classes` is a list walked in order: the first class returning a `(user, auth)` tuple wins, a class returning `None` says "not my scheme, keep going", and one raising `AuthenticationFailed` stops the walk. Every class returning `None` is a success, not a failure — `request.user` becomes `AnonymousUser`, so identity checks are always `is_authenticated` and never `is None`. Whether an unauthenticated denial comes back as 401 or 403 is decided entirely by whether the FIRST class in the list can supply a `WWW-Authenticate` header. OAuth2 layers delegation on top: a scoped, expiring token issued by an authorization server so a password never reaches the client, with scopes enforced against `request.auth`. When no shipped scheme fits, `BaseAuthentication` is a three-method contract.

What is true here

  1. Session, DRF token, and JWT differ on where identity is stored: a server row behind a cookie, a server row behind a header, or the signed token itself.
  2. The authentication list is first-match-wins; None means "not my scheme" and raising AuthenticationFailed short-circuits everything after it.
  3. No authentication is not an error — request.user is AnonymousUser, and permission classes decide what happens next.
  4. 401 versus 403 depends on whether the first authentication class supplies a WWW-Authenticate header, not on the permission that failed.
  5. OAuth2 issues scoped, expiring tokens so a password never reaches the client; scopes are enforced from request.auth.

What you will be able to do

  • Choose between session, token, and JWT authentication from the revocation and statelessness trade-off, not by habit
  • Order `authentication_classes` deliberately, and predict whether a denial will be 401 or 403
  • Write view code that handles anonymous requests correctly instead of assuming an authenticated user
  • Explain the OAuth2 roles and the authorization-code-with-PKCE flow, and enforce scopes from `request.auth`
  • Implement a custom `BaseAuthentication` class that composes correctly with the rest of the list
What runs before your view, and what each layer decides

Request

a cookie, an Authorization header, or nothing at all

Authentication classes — WHO is this?

walked in order; first (user, auth) tuple wins; None passes control on

request.user + request.auth

always set — AnonymousUser and None when nothing matched

Permission classes — MAY they?

a separate question; failure here is 403 when the caller is identified

Throttle classes — how often?

keyed on the user id when authenticated, on the IP when not

The view

reaches this line only if all three layers allowed it

  1. Request — a cookie, an Authorization header, or nothing at all
  2. Authentication classes — WHO is this? — walked in order; first (user, auth) tuple wins; None passes control on
  3. request.user + request.auth — always set — AnonymousUser and None when nothing matched
  4. Permission classes — MAY they? — a separate question; failure here is 403 when the caller is identified
  5. Throttle classes — how often? — keyed on the user id when authenticated, on the IP when not
  6. The view — reaches this line only if all three layers allowed it

The three schemes

Session cookies, DRF tokens, and JWTs — and where each one keeps the truth about who you are.

SessionAuthentication, TokenAuthentication, and JWT

coreintermediate

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.

Think of it as

The three schemes differ on one axis: where the truth about "who is this" lives. A session keeps it server-side and hands the client an opaque pointer (the cookie) — revoking a login is deleting one row, and the browser attaches the cookie automatically, which is exactly why CSRF becomes a concern. A DRF token also keeps it server-side, but the client sends it deliberately in a header instead of the browser sending it automatically, so CSRF does not apply — the cost is a database lookup on every request and a token that never expires unless you build expiry yourself. A JWT moves the truth into the token itself: the payload carries the user id and an expiry, and a signature proves the server minted it, so a request can be authenticated with zero database access. That is the whole trade — you buy statelessness and pay for it with revocation, because a signed token stays valid until its `exp` passes no matter what happens to the account behind it. Short access-token lifetimes are the standard mitigation, not a detail.

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

What we're doing: Wire Simple JWT globally, expose the token endpoints, and keep session auth for the browsable API.

config/settings.py + config/urls.pypython
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
}

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
}

# urls.py
urlpatterns = [
    path("api/token/", TokenObtainPairView.as_view()),
    path("api/token/refresh/", TokenRefreshView.as_view()),
]
3–4
Order matters — DRF tries each class in turn and stops at the first that returns a user. JWT first means an API client with a Bearer header never touches the session machinery.
10
Fifteen minutes is the revocation window: a disabled account keeps working for at most that long, because nothing checks the database while an access token is still inside its lifetime.
12
ROTATE_REFRESH_TOKENS issues a fresh refresh token on every refresh, so a stolen refresh token is usable only until the real client refreshes next.

Why this works: Keeping SessionAuthentication second costs nothing for API clients (it is only reached when no Bearer header is present) and keeps DRF's browsable API usable while logged into the admin — a real convenience during development that does not weaken the API path.

Storing a JWT in `localStorage` and treating the payload as private

Wrong

javascript
localStorage.setItem("jwt", token);
// payload was minted server-side as:
//   {"user_id": 42, "email": "a@example.com", "is_admin": true, "plan": "internal"}

Better

javascript
// Keep claims to what the API needs to identify the caller.
//   {"user_id": 42, "exp": 1757030400}
// Store it where script injected into the page cannot read it:
// an HttpOnly, Secure, SameSite cookie set by the server.

What you see: Any script that runs on the page — an injected one, a compromised dependency — reads `localStorage.jwt` and replays it, and anyone who obtains the token can also base64-decode the middle segment and read every claim in it verbatim.

Why: A JWT signature guarantees the server minted the token; it guarantees nothing about confidentiality, because the payload is base64url — an encoding, not encryption. Two consequences follow: claims are public to whoever holds the token, and the token is a bearer credential, so wherever it is stored is exactly as sensitive as a password. `localStorage` is readable by any JavaScript in the origin, which makes an XSS bug into a full account takeover that outlives the page.

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

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

Together

python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
}

Remember: 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.

See also: authentication order anonymous users and failures · login logout and authentication backends · cookie security and csrf

Advertisement

How DRF resolves an identity

List order, the anonymous path, and why an unauthenticated denial is sometimes 403.

Authentication order, anonymous users, and 401 vs 403

coreintermediate

`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.

Think of it as

Separate three outcomes that look alike from the outside. "No credentials at all" is silence — every class returns `None`, and DRF hands the view an anonymous user rather than an error, because plenty of endpoints are meant to be public. "Credentials present but wrong" is a raised `AuthenticationFailed`, which is a definite no and short-circuits the list. "Credentials fine, but you may not do this" never involves authentication at all — it comes later, from a permission class. The status code follows that split: 401 means *identify yourself*, 403 means *I know who you are and the answer is still no*. DRF picks between them using the FIRST authentication class in the list — specifically, whether that class implements `authenticate_header()` and so can supply a `WWW-Authenticate` value. If it cannot, an unauthenticated denial comes back as 403, because a 401 without that header would be a protocol violation. This is why a puzzling 403-instead-of-401 is almost always a list-order question, not a permissions question.

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

What we're doing: Read the identity DRF resolved, without assuming a request was authenticated.

orders/views.pypython
class OrderListView(generics.ListAPIView):
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get_queryset(self):
        if not self.request.user.is_authenticated:
            return Order.objects.filter(is_public=True)
        return Order.objects.filter(customer=self.request.user)

    def list(self, request, *args, **kwargs):
        response = super().list(request, *args, **kwargs)
        response["X-Auth-Scheme"] = type(request.successful_authenticator).__name__
        return response
5–6
`is_authenticated` is a property on both `User` and `AnonymousUser`, so this branch works without a `None` check — DRF guarantees `request.user` is always an object.
8
The authenticated branch scopes rows to the caller. Doing this in `get_queryset()` rather than in a permission class is what makes a list endpoint safe: object-level permissions are not consulted for list responses.
12
`request.successful_authenticator` is the class instance that actually authenticated the request — the direct way to tell which entry in the list won, rather than inferring it from the headers.

Why this works: Branching on `is_authenticated` inside `get_queryset()` keeps one endpoint serving both audiences with different row sets, and leaves the 401/403 decision entirely to DRF — the view never inspects headers or invents a status code of its own.

Treating an anonymous request as an authentication error inside the view

Wrong

python
def get_queryset(self):
    if self.request.user is None:          # never true — DRF substitutes AnonymousUser
        raise AuthenticationFailed("login required")
    return Order.objects.filter(customer=self.request.user)

Better

python
permission_classes = [IsAuthenticated]   # DRF returns the right 401/403 itself

def get_queryset(self):
    return Order.objects.filter(customer=self.request.user)

What you see: The guard never fires, so an anonymous request reaches `Order.objects.filter(customer=AnonymousUser())` and raises `TypeError: Field 'id' expected a number but got <django.contrib.auth.models.AnonymousUser>` — a 500, from what should have been a clean 401.

Why: `request.user` is never `None` in DRF: when no authentication class matches, it is set to `UNAUTHENTICATED_USER`, which defaults to `AnonymousUser`. An identity check therefore has to be `is_authenticated`, and the enforcement belongs in a permission class rather than the view — that is the layer DRF consults when deciding between 401 and 403, and reimplementing it in `get_queryset()` produces the wrong status code even when the check is written correctly.

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

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

Together

python
def get_queryset(self):
    if self.request.user.is_authenticated:
        return Order.objects.filter(customer=self.request.user)
    return Order.objects.none()

Remember: 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.

See also: session token and jwt authentication · oauth2 and custom authentication classes · login logout and authentication backends

Advertisement

OAuth2 and custom schemes

Delegated access with scoped tokens, and the `BaseAuthentication` escape hatch for everything else.

OAuth2 concepts, custom authentication classes, and the integrations

coreadvanced

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.

Think of it as

OAuth2 exists to remove the password from places it should never reach. Without it, letting a third party read your calendar means giving that third party your calendar password — unscoped, unrevocable, and shared. OAuth2 replaces that with a token that is scoped ("read calendar events only"), time-limited, and revocable independently of the account. The redirect dance is not ceremony: it exists so the credential is typed into the authorization server, never into the client, and PKCE closes the remaining hole where a stolen authorization code could be redeemed by an attacker who does not hold the original code verifier. Two roles then blur in practice and should not: `django-oauth-toolkit` makes your Django project an *authorization server* that issues tokens; `django-allauth`/`social-auth-app-django` make it a *client* that signs users in with Google or GitHub. Custom authentication sits underneath all of this — it is the escape hatch for a credential shape nobody standardised, and its contract is exactly three methods, so the rest of DRF keeps working unchanged.

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

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

What we're doing: Authenticate a partner webhook by HMAC signature, so DRF resolves an identity instead of the view parsing headers.

integrations/authentication.pypython
class SignatureAuthentication(BaseAuthentication):
    def authenticate(self, request):
        signature = request.headers.get("X-Partner-Signature")
        if signature is None:
            return None

        partner_id = request.headers.get("X-Partner-Id", "")
        partner = Partner.objects.filter(external_id=partner_id, is_active=True).first()
        if partner is None:
            raise AuthenticationFailed("Unknown partner.")

        expected = hmac.new(partner.secret.encode(), request.body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, signature):
            raise AuthenticationFailed("Signature mismatch.")

        return (partner.service_user, partner)

    def authenticate_header(self, request):
        return 'Signature realm="webhooks"'
4–5
No signature header means "this request is not for my scheme" — return `None` so the next class gets a turn. Raising here would break every other authentication method on the same view.
12
The signature is computed over `request.body`, the raw bytes, not over parsed data — re-serialising JSON reorders keys and changes whitespace, so the digest would never match.
13
`hmac.compare_digest` compares in constant time. `==` leaks how many leading bytes matched through timing, which is enough to reconstruct a signature byte by byte.
16
The second element of the tuple lands on `request.auth`, so the view can read which partner signed the call without a second query.
19
Without `authenticate_header()`, DRF has no challenge to send and answers unauthenticated requests with 403 instead of 401.

Why this works: Putting this in an authentication class rather than in the view means every permission class, throttle, and log line downstream sees a real `request.user` — the whole framework keeps working, and no view has to re-derive identity from headers.

Raising `AuthenticationFailed` when the scheme simply is not present

Wrong

python
def authenticate(self, request):
    signature = request.headers.get("X-Partner-Signature")
    if signature is None:
        raise AuthenticationFailed("Signature required.")   # stops the whole list

Better

python
def authenticate(self, request):
    signature = request.headers.get("X-Partner-Signature")
    if signature is None:
        return None                                          # try the next class

What you see: Adding the class to `DEFAULT_AUTHENTICATION_CLASSES` breaks every other endpoint: logged-in browser requests and Bearer-token requests all start returning 401 "Signature required.", because the first class in the list now rejects anything without a partner signature.

Why: The `None`-versus-raise distinction is the entire contract of the list. `None` means "not my scheme, keep walking"; raising means "it is my scheme and the credential is bad", which short-circuits every remaining class. A class that raises on absence has effectively made itself the only authentication method the project has.

Authorization code with PKCE — who ever sees the password
User
Your client app
Authorization server
Your API
  1. 1. clicks "Sign in"
  2. 2. redirect + code_challengethe challenge is a hash of a secret the client keeps
  3. 3. login + consent screenthe password is typed HERE and nowhere else
  4. 4. approves the requested scopes
  5. 5. redirect back with a one-time code
  6. 6. code + code_verifier → tokenback-channel; a stolen code alone is useless without the verifier
  7. 7. access token (scoped, expiring) + refresh token
  8. 8. Authorization: Bearer <access token>
  9. 9. 200, 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

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 newboth discouraged by the OAuth 2.0 Security BCP — do not choose them for new work

Together

python
class OrderViewSet(viewsets.ModelViewSet):
    authentication_classes = [OAuth2Authentication]
    permission_classes = [TokenHasScope]
    required_scopes = ["orders:write"]

Remember: 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.

See also: authentication order anonymous users and failures · session token and jwt authentication · object level and resource level authorization

Advertisement