Lesson 18 · FE Lead Interview · 2026-06-12
XSS-resistant (HttpOnly) ← can't also be JS-accessible
CSRF-resistant (not a cookie) ← can't also be HttpOnly
JS-accessible (needed for Bearer headers) ← readable by XSS
Win: memory + HttpOnly cookie (two-token split)
| Where | XSS | CSRF | Persists |
|---|---|---|---|
| Memory (JS var) | Low* | None | No |
| HttpOnly cookie | None | SameSite | Yes |
| localStorage | High | None | Yes |
| sessionStorage | High | None | Tab only |
| IndexedDB | High | None | Yes |
*Memory: no persist — attacker can't exfiltrate a string after page close
Header — {"alg":"RS256","typ":"JWT"} → Base64URL
Payload — claims (sub, roles, exp, iat) → Base64URL — NOT encrypted
Signature — RS256(header + "." + payload, privateKey) → Base64URL
| Step | Create (auth server) | Verify (API server) |
|---|---|---|
| 1 | Base64URL(header) → H | Split on "." → H, P, S |
| 2 | Base64URL(payload) → P | Recompute: RS256(H+"."+P, publicKey) |
| 3 | S = RS256(H+"."+P, privateKey) | Timing-safe compare S === expected |
| 4 | JWT = H + "." + P + "." + S | Decode P → check exp, iss, aud |
| JWT | Opaque | |
|---|---|---|
| Verify cost | Crypto | DB lookup |
| Revocable | Not before expiry | Instant |
| Stateless | Yes | Needs store |
| Use for | Access (15 min) | Refresh (7–30d) |
Path=/auth/refresh)Authorization: Bearer <jwt>POST /auth/refresh| Attribute | Protects against | Notes |
|---|---|---|
HttpOnly | XSS token theft | JS-blind; essential for auth cookies |
Secure | MITM | HTTPS only; required with SameSite=None |
SameSite=Lax | CSRF | Default Chrome 80+; sent on top-level GET nav |
SameSite=Strict | CSRF (max) | Breaks email-link logins and SSO redirects |
SameSite=None | — | Requires Secure; needs separate CSRF defense |
Path=/auth/refresh | CSRF scope | Limits cookie to refresh endpoint only |
Domain=.example.com | — | Cross-subdomain; any subdomain can read it |
Set-Cookie: refresh=<token>;
HttpOnly;
Secure;
SameSite=Lax;
Path=/auth/refresh;
Domain=.example.com;
Max-Age=2592000
let refreshPromise = null;
async function getAccessToken() {
if (!isExpired(token)) return token;
if (!refreshPromise) {
refreshPromise = callRefresh()
.then(t => {
token = t;
refreshPromise = null;
return t;
});
}
return refreshPromise;
}
X-CSRF-Token headerJSON.parse(atob(token.split('.')[1])) reads the full payload in plain text. Signature = tamper-proof, not secret. Need secret payload? Use JWE.
RS256 > HS256: RS256 = private key signs, public key verifies — API servers never hold the secret. HS256 = same secret both sides — any compromised service can forge tokens.
Intra-tab (concurrent fetches): singleton promise — one module-level refreshPromise, all callers share it.
Cross-tab (two tabs expire together): singleton fails — each tab has its own heap. Fix: Web Locks (turn-taking) + BroadcastChannel (share new token). Locks alone still fails — Tab B's in-memory token stays stale until the broadcast updates it.
Cross-browser / device: not a race. Server issues one refresh token row per session; each device rotates independently.
Token exfiltrated → replayed from any machine → dwell time = token expiry (hours/days). Memory: dies with tab.
Email verification links are fine — well-designed tokens are self-contained; server finds the user via the token, no session needed. Works from any device.
What actually breaks: OAuth/SSO redirects (return hop from Google/IdP is cross-site → cookie dropped → login state lost) and stateful resume links (cart, multi-step flows that require an existing session).
Fix: use Lax — allows cookie on cross-site top-level GET navigations.
ACAO: * forbidden with HttpOnly credentials. Refresh endpoint needs explicit origin allowlist.
Also applies here: 401 from auth endpoint without CORS headers = CORS error masks real 401.