A practical JWT auth playbook for Django with TypeScript SPAs Features JWT Header Access, Cookie Refresh

Updated: October 02, 2025, 06:18 PM IST

This guide documents a battle‑tested pattern for web apps where the frontend is rendered by Django templates (or any SPA) and calls a REST API secured by JWTs. It assumes mainstream web‑app threat models and focuses on staying resilient against XSS and CSRF while keeping the UX smooth. Jwt Auth Playbook — Django + Type Script (header Access, Cookie Refresh)

“Header Access, Cookie Refresh — A practical JWT auth playbook for Django + TypeScript SPAs.” It covers:

  • Architecture & threat model
  • Exact cookie/CORS/CSRF settings to use
  • Backend endpoint contracts (login/refresh/logout)
  • Frontend behavior (single-flight refresh, one-retry rule, per-tab storage)
  • Security headers (CSP, etc.), ops hygiene, tests, pitfalls, and a print-ready checklist

Architecture

  • Access token (short‑lived, 5–10 minutes) → sent in Authorization: Bearer … headers.
  • Refresh token (long‑lived, 7–30 days) → stored only as an HttpOnly, Secure cookie.
  • Frontend storage of access → sessionStorage (per‑tab) or pure in‑memory.
  • Refresh flow → on 401 from API, call /auth/refresh which reads the cookie and returns a new access token.
  • CORS → exact allowlist, credentials: true only for your frontend origin.
  • CSRF → not required for the refresh endpoint (no readable cross‑site response without CORS), but still required for your state‑changing same‑origin views.

ASCII map:

bash
Copy Copied!
[Browser Tab]
sessionStorage: access
Cookie (HttpOnly, Secure): refresh
|
| Authorization: Bearer <access>
v
[API / protected] —— on 401 ——> [API /auth/refresh]
^
| reads HttpOnly refresh cookie
| returns { access: "…" }

Why this pattern

Goal: prevent long‑term compromise if an XSS ever lands. JavaScript can read sessionStorage but cannot read HttpOnly cookies. Keeping the refresh token in a cookie breaks the attacker’s persistence; at worst they get a short‑lived access token until it expires.

Trade‑offs:

  • Slightly more plumbing (refresh endpoint, retry once) for much better risk control.
  • Per‑tab persistence (via sessionStorage) means new tabs may trigger one silent refresh—acceptable and predictable.

Token roles & lifetimes

  • Access token: 5–10 minutes. Short window = limited damage.
  • Refresh token: 7–30 days. Match your risk appetite and “keep me signed in” UX.
  • Rotation: If you rotate refresh tokens on every use, keep proactive refresh off to reduce churn. If you don’t rotate, proactive refresh can smooth UX.

Browser storage: sessionStorage vs localStorage vs memory

  • sessionStorage (recommended): per‑tab, survives reloads, cleared when the tab closes.
  • localStorage: shared across tabs; bigger blast radius if XSS. Avoid for access tokens.
  • In‑memory only: safest against XSS, but a full reload forces a refresh each time. Good for high‑security apps.

Cookies: flags that matter

Set these on the refresh cookie:

  • HttpOnly = true → JS can’t read it.
  • Secure = true → cookie only over HTTPS.
  • Path = "/" → or tighter if you mount auth under /auth.
  • SameSite = "Lax" if frontend and API share site/subdomain; SameSite = "None" (requires Secure) if they are on different sites.
  • Reasonable Max-Age / Expires to match your refresh lifetime.

CORS: exactness beats convenience

If API and frontend are on different origins:

  • Access-Control-Allow-Origin: https://your-frontend.example (no *).
  • Access-Control-Allow-Credentials: true.
  • Allow only necessary methods/headers.
  • Do not allow untrusted origins to hit auth endpoints.
  • Add Vary: Origin so caches behave correctly.

CSRF: what needs it and what doesn’t

  • Refresh endpoint: may be AllowAny and no CSRF because the response body is not readable cross‑site without your CORS allowlist, and it does not mutate server state beyond issuing a new access JWT.
  • Session‑based or same‑origin POST/PUT/PATCH/DELETE views still need CSRF protection.
  • If your frontend posts to Django views on the same origin, include the CSRF token header.

Backend endpoints (shape & semantics)

  1. POST /auth/login
    • Validates credentials.
    • Sets the refresh cookie (HttpOnly, Secure, SameSite, Max‑Age).
    • Returns JSON: { "access": "<jwt>" }.
    • Headers: Cache-Control: no-store.
  1. POST /auth/refresh
    • Reads the refresh cookie.
    • If valid: returns { "access": "<jwt>" }.
    • If invalid/expired: 401.
    • Do not require Authorization header; do accept credentials (cookie).
    • Headers: Cache-Control: no-store.
  1. POST /auth/logout
    • Clears the refresh cookie (set Max-Age=0).
    • If using rotation/blacklist: mark the refresh as invalid.
    • 204 No Content is a good response.

Frontend behavior (TypeScript outline)

Core ideas (implementation-agnostic):

  • Keep access in memory and optionally mirror to sessionStorage per tab.
  • Attach Authorization: Bearer <access> to every API request.
  • If a request returns 401:
    1. Single‑flight refresh: ensure only one refresh runs; others wait.
    2. Call /auth/refresh with credentials: 'include' and without any stale Authorization header.
    3. If refresh succeeds, retry the original request once.
    4. If refresh fails, clear local state and route to login.
  • Optional: proactive refresh a few seconds before exp if you’re not rotating refresh tokens.
  • On app boot: hydrate access from sessionStorage; if expired/near expiry, refresh once.

Security headers (worth the effort)

Add defense‑in‑depth headers on the frontend origin:

  • Content‑Security‑Policy: start strict and iterate, e.g.
    • default-src 'self';
    • script-src 'self' 'strict-dynamic' 'nonce-<generated-per-request>';
    • object-src 'none'; base-uri 'none'.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • X-Frame-Options or frame-ancestors in CSP.
  • Permissions-Policy: disable what you don’t use.

And on auth responses specifically:

  • Cache-Control: no-store
  • Vary: Origin

Operational hygiene

  • HTTPS everywhere in production.
  • Brute‑force protection on login.
  • Audit log refresh events (user id, IP, UA, timestamps) to investigate anomalies.
  • Clock skew: allow ~30–60s skew when validating exp to reduce edge 401s.

Step‑by‑step implementation (Django + DRF + SimpleJWT)

  1. Install & wire SimpleJWT for access/refresh token issuance.
  2. Login view: on success, set the refresh cookie and return {access}.
  3. Refresh view: read refresh from cookie, return new {access} (no auth header required).
  4. Logout view: clear the cookie; optionally blacklist current refresh.
  5. CORS: allow only your frontend origin; set supports_credentials = true.
  6. Security headers: add CSP and no-store on auth responses.
  7. Settings sanity: production DEBUG = False, correct ALLOWED_HOSTS, secure cookie flags, SECURE_* settings enabled.

TypeScript fetch wrapper (conceptual)

  • A thin request(url, options) that:
    • auto‑adds Authorization if access is present;
    • sets credentials: 'include' so the refresh cookie is sent when needed;
    • on 401, performs one shared refresh and retries once;
    • never sends Authorization to the refresh endpoint;
    • stores/clears access in a single place and mirrors it to sessionStorage if desired.

Quick self‑tests (copy/paste into DevTools)

  • document.cookie.includes('refresh') → false (HttpOnly cookie isn’t readable by JS).
  • sessionStorage.getItem('access_token') → non‑empty when logged in.
  • Reload the page: no refresh call (access persists per tab).
  • Open a new tab: first protected call triggers a refresh once.
  • Manually expire access (short TTL in dev): next request 401 → silent refresh → retry → succeeds.
  • Clear cookies and retry: refresh 401 → app routes to login.

Common pitfalls & fixes

  • 401 loop: always retry once after a successful refresh; otherwise bail out and log out.
  • CORS with credentials: Access-Control-Allow-Origin cannot be * when Allow-Credentials: true.
  • SameSite=None without Secure: modern browsers drop the cookie; set Secure.
  • Sending stale Authorization to refresh: skip the header on refresh calls.
  • LocalStorage for access: increases risk window under XSS; prefer sessionStorage or memory.

When to choose a different model

  • Highly sensitive apps: consider pure in‑memory access (no persistence) + shorter TTLs + stricter CSP.
  • Native/mobile clients: platform keychains/secure storage and PKCE flows may be more appropriate.

Checklist (print‑worthy)

Backend

Frontend

Ship this and you’ll have a robust, user‑friendly JWT auth setup that holds up under real‑world conditions while keeping the blast radius of XSS as small as possible.