skip to content

DRF's built-in TokenAuthentication keys never expire and are shared across a user's devices; how would you add expiry with a custom authentication class, and what must authenticate() return or raise?

level: seniorimportance: should knowfreq 36%

answer

  1. one row per user, no TTL
  2. None, tuple or exception
  3. reuse the parent lookup
  4. check the created timestamp

basics

~20 s

Subclass TokenAuthentication and override authenticate_credentials() to reject tokens whose created time is too old. authenticate() must return None when its credentials are absent, (user, auth) on success, and raise AuthenticationFailed for invalid or expired ones.

solid answer

~40 s

The authtoken `Token` has one row per user through a one-to-one field, a 40-hex-character key stored as the primary key, a `created` timestamp and no expiry, and `obtain_auth_token` uses `get_or_create`, so every device shares one key. I subclass `TokenAuthentication`, call the parent's `authenticate_credentials()` — which rejects unknown keys and inactive users — then compare `token.created` with a lifetime, delete the token and raise `AuthenticationFailed` if it is too old. The contract for any `BaseAuthentication`: return `None` when no credentials of your scheme are present so the next class runs, return `(user, auth)` on success, and raise `AuthenticationFailed` when credentials are present but bad. Login then deletes and recreates the token. Per-device tokens, hashed storage or JWTs need a custom model or a third-party package.

code

python · 18 lines
python
from datetime import timedelta

from django.utils import timezone
from rest_framework.authentication import TokenAuthentication
from rest_framework.exceptions import AuthenticationFailed

TOKEN_LIFETIME = timedelta(days=14)


class ExpiringTokenAuthentication(TokenAuthentication):
    keyword = "Bearer"

    def authenticate_credentials(self, key):
        user, token = super().authenticate_credentials(key)
        if token.created < timezone.now() - TOKEN_LIFETIME:
            token.delete()
            raise AuthenticationFailed("Token has expired.")
        return user, token

go deeper

for a junior

Know that DRF's built-in tokens have no expiry and that a custom class subclasses BaseAuthentication or TokenAuthentication.

for a middle

Explain the three return paths of authenticate() and why returning a tuple for missing credentials breaks the loop.

for a senior

Add expiry and rotation cleanly, name the remaining gaps (one token per user, plaintext keys, lookups per request) and decide when a package or JWT is warranted.

for a principal

Own the credential lifecycle for native clients: lifetime, rotation, revocation and storage, and whether to build or adopt.

## What the built-in token actually is Django REST Framework's (DRF) `rest_framework.authtoken` app is deliberately simple. Its `Token` model has: - a **`key`**: 40 hexadecimal characters from `secrets.token_hex(20)`, stored **as the primary key, in plain text**; - a **`user`**: a `OneToOneField`, so each user has **at most one token**; - a **`created`** timestamp — and nothing that expires it. `obtain_auth_token` calls `Token.objects.get_or_create(user=user)`, so every device that logs in receives the **same key**. Deleting the row logs out every device at once, and a key that leaks stays valid until someone deletes it. DRF's own documentation calls this "a fairly simple implementation" and points to third-party packages for per-client tokens and expiry. ## The contract of a custom authentication class Any scheme — including a stricter token — is a subclass of `BaseAuthentication` with an `authenticate(self, request)` method. DRF's loop gives each return path a precise meaning: | Situation | What `authenticate()` should do | Effect | |---|---|---| | No credentials for this scheme | return `None` | the next class is tried | | Credentials present and valid | return `(user, auth)` | loop stops; `request.user`, `request.auth` set | | Credentials present but invalid, expired or for an inactive user | raise `AuthenticationFailed` | loop stops; error response, even on views that allow anyone | Two further points: - Override **`authenticate_header()`** to return the scheme name if you want 401 responses when your class is listed first; otherwise unauthenticated denials become 403. - Do not return `(AnonymousUser(), None)` for "no credentials". A tuple counts as success: the loop stops, later classes never run, and DRF records your class as the successful authenticator. ## Adding expiry by extending TokenAuthentication The smallest change reuses DRF's header parsing and only tightens `authenticate_credentials()`: 1. Call the parent, which looks the key up with `select_related("user")`, rejects unknown keys and inactive users, and returns `(user, token)`. 2. Compare `token.created` with the current time and an allowed lifetime. 3. If the token is too old, delete it and raise `AuthenticationFailed("Token has expired.")`. 4. Otherwise return the tuple unchanged. The client then needs a way back in: typically the login endpoint deletes any existing token and creates a fresh one, so a new `created` timestamp starts the clock again. Subclassing `ObtainAuthToken` and replacing its `post()` is enough. ## Limits of this approach Expiry fixes one gap and leaves others: - **One token per user** still means one device's re-login rotates the key for all devices. Per-device tokens need your own model with a foreign key instead of a one-to-one, or a package built for it; DRF's docs point to django-rest-knox for per-client tokens and expiry. - **Plain-text keys** in the database mean a database read exposes usable credentials. Storing only a one-way digest of each key is the stronger design; how to size and store API keys belongs to token and API-key design. - **A database lookup per request** is inherent to opaque tokens. The alternative, a JWT add-on such as djangorestframework-simplejwt, validates a signed token without a database query and plugs into the same `DEFAULT_AUTHENTICATION_CLASSES` list — at the price of harder revocation. Token formats and verification rules belong to JWT's own topics. ## Where the class plugs in The finished class is used like any built-in one: 1. Reference it in `DEFAULT_AUTHENTICATION_CLASSES` by dotted path, or set it in `authentication_classes` on the views that serve native clients. 2. Keep `SessionAuthentication` in the list if the browser app uses the same endpoints; the two do not interfere, because each returns `None` for requests that carry the other's credential. 3. Point the mobile client's login at the rotating login view, and have the client treat an expiry response as a prompt to log in again. ## Operational checks - Make expiry visible in responses: clients should treat the 401 or 403 from an expired token as "log in again". - Log authentication failures at a level that shows a spike in expired or unknown tokens without logging the keys themselves. - If an `AttributeError` escapes your `authenticate()`, DRF re-raises it as `WrappedAttributeError`, so the bug is not mistaken for a missing `request.user`; fix the cause rather than catching it. - Keep the class cheap: it runs on every request that reaches it, so avoid extra queries beyond the single token lookup.

  • Why should a custom DRF authentication class return None rather than (AnonymousUser(), None) when its header is absent?
    A returned tuple counts as successful authentication: the loop stops, later classes never run, and `request.successful_authenticator` names your class. A browser with a valid session would be treated as anonymous. Returning `None` tells DRF the scheme was not attempted, so the next class gets its turn and the anonymous fallback applies only if none succeeds.
  • What changes if you replace DRF's TokenAuthentication with a JWT add-on?
    The add-on is another authentication class in `DEFAULT_AUTHENTICATION_CLASSES`; it validates a signed token without the per-request database lookup, and `request.auth` holds whatever the add-on returns, typically its validated token. You gain expiry built into the token and no token table on the hot path, but lose instant revocation by deleting a row, which then needs a blocklist or short lifetimes.

saying these in an interview costs you the question

  • Built-in DRF tokens expire after SESSION_COOKIE_AGE.
  • Each device that calls obtain_auth_token receives its own token.
  • Return (AnonymousUser(), None) when no credentials are present.
  • Raising AuthenticationFailed makes DRF try the next class.
  • Built-in tokens are stored hashed, so a database leak exposes nothing usable.