“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:
[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)
- POST /auth/login
- Validates credentials.
- Sets the refresh cookie (HttpOnly, Secure, SameSite, Max‑Age).
- Returns JSON: { "access": "<jwt>" }.
- Headers: Cache-Control: no-store.
- 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.
- 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:
- Single‑flight refresh: ensure only one refresh runs; others wait.
- Call /auth/refresh with credentials: 'include' and without any stale Authorization header.
- If refresh succeeds, retry the original request once.
- 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)
- Install & wire SimpleJWT for access/refresh token issuance.
- Login view: on success, set the refresh cookie and return {access}.
- Refresh view: read refresh from cookie, return new {access} (no auth header required).
- Logout view: clear the cookie; optionally blacklist current refresh.
- CORS: allow only your frontend origin; set supports_credentials = true.
- Security headers: add CSP and no-store on auth responses.
- 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.