Stack: DRF · Project: TicketFlow Status: Published — opening the security module Prerequisite: Lesson 17 — Webhooks
Objectives
- Compare server-session and stateless-token auth with their real impact (revocation, scale, CSRF/XSS).
- Implement token auth with refresh in DRF (SimpleJWT) and decide where the token lives on the frontend.
- Choose TicketFlow's authentication with interview-grade arguments.
1. The two families
Cookie session (Django default): the server keeps the state (session table), the client carries only an opaque ID in an HttpOnly cookie. Revocation: delete the row — instant. Scale: sessions in a shared DB/Redis (or sticky sessions, to be avoided).
Stateless token (JWT): the server signs {user_id, exp, scopes}; it stores nothing. Every request validates the signature. Revocation: NOT trivial (the token lives until it expires) → mitigated with short expirations + refresh token + a blacklist (which is state again: admit it). Scale: any pod validates without a shared lookup.
| Cookie session | JWT access+refresh | |
|---|---|---|
| Instant revocation | trivial | only with blacklist/rotation |
| Horizontal scale | shared state | stateless |
| CSRF | a risk (mitigate SameSite/token) | in a header, immune |
| XSS | HttpOnly cookie steals little | a JS-side token steals everything |
| Mobile apps | cookies meh | native |
The myth to dismantle in interviews: "JWT = stateless = better". Short access token + revocable refresh + blacklist = state back on the server. The real question is where you want the state and what you revoke: buying tickets with revocable money wins session/cookie for the web; tokens for mobile and third-party APIs.
2. SimpleJWT on TicketFlow
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
"rest_framework_simplejwt.authentication.JWTAuthentication",
]
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10), # short: a theft's damage expires
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True, # every refresh issues a new refresh
"BLACKLIST_AFTER_ROTATION": True, # the old one dies: revocable
}Endpoints: POST /api/auth/token/ (login), POST /api/auth/token/refresh/. Minimal payload: user_id, exp; no sensitive data (the JWT is base64-readable; a signature is not encryption).
3. Where the token lives (the XSS decision)
- localStorage: convenient but all JS (including a third-party XSS in your bundle) reads it and exports it.
- HttpOnly + SameSite cookie: JS cannot read it; CSRF is mitigated with SameSite=Lax/Strict (Lesson 01). The access token in an HttpOnly cookie cuts XSS theft.
- SPA memory + refresh in an HttpOnly cookie: the modern pattern — access in memory (dies with the tab), silent refresh via a rotated HttpOnly cookie. An XSS steals the live session's access (not the long-lived refresh).
Rule: refresh token always in an HttpOnly cookie with rotation and blacklist; short access in memory or an HttpOnly cookie. localStorage only if your threat model explicitly accepts it.
4. TicketFlow's choice (and its why)
Web + mobile over the same API: short JWT + rotated refresh in an HttpOnly cookie for the web; the same endpoints serve mobile (mobile stores the refresh in the OS keychain, not localStorage). Django sessions remain for the admin (/admin/), because Django ships them hardened and it is another channel. Documenting the decision (ADR-0008 in the exercises) is worth as much as implementing it.
Self-assessment
- Why is "JWT eliminates server state" half a lie when there is refresh + blacklist?
- An XSS lands in your SPA: what can it steal with the token in localStorage, in an HttpOnly cookie, in memory + refresh in a cookie?
- Why is the access token 10 minutes and not 7 days, and what does refresh rotation balance?
- Why is the signed JWT NOT secret, and what must it never carry inside?
- What would you pick for Django's
/admin/and why is there no need to change it?
Continue with the exercises. The solutions only after trying it yourself.