Module 4 · Authentication and security

Lesson 18 — Sessions vs tokens

JWT and its risks, refresh tokens and what fits each client best.

Published
In this lesson
  1. Objectives
  2. 1. The two families
  3. 2. SimpleJWT on TicketFlow
  4. 3. Where the token lives (the XSS decision)
  5. 4. TicketFlow's choice (and its why)
  6. Self-assessment

Stack: DRF · Project: TicketFlow Status: Published — opening the security module Prerequisite: Lesson 17 — Webhooks


Objectives

  1. Compare server-session and stateless-token auth with their real impact (revocation, scale, CSRF/XSS).
  2. Implement token auth with refresh in DRF (SimpleJWT) and decide where the token lives on the frontend.
  3. 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 sessionJWT access+refresh
Instant revocationtrivialonly with blacklist/rotation
Horizontal scaleshared statestateless
CSRFa risk (mitigate SameSite/token)in a header, immune
XSSHttpOnly cookie steals littlea JS-side token steals everything
Mobile appscookies mehnative

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

python
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

  1. Why is "JWT eliminates server state" half a lie when there is refresh + blacklist?
  2. 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?
  3. Why is the access token 10 minutes and not 7 days, and what does refresh rotation balance?
  4. Why is the signed JWT NOT secret, and what must it never carry inside?
  5. 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.