CSRF and token refresh flow
CSRF = attacker tricks the user's browser into sending an authenticated request to your site. Defense: SameSite cookies (Lax/Strict), a CSRF token verified server-side for state-changing requests, and `Origin`/`Referer` checks. Token refresh: short-lived access token (~15min) + long-lived refresh token (HttpOnly cookie, rotated on each use, server-side revocable). On 401, hit /refresh, get a new access token, retry the request — with a mutex so concurrent requests don't trigger multiple refreshes.
Two adjacent problems that often get conflated.
CSRF — Cross-Site Request Forgery
Attacker hosts evil.com. Victim is logged into bank.com. While the victim visits evil.com, the attacker's page makes a request to bank.com/transfer — the victim's browser auto-attaches the bank.com cookies (that's the whole point of cookies). The request looks legitimate to the bank.
<!-- on evil.com -->
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="9999">
</form>
<script>document.forms[0].submit()</script>Defenses (use all three).
1. SameSite cookies. The browser refuses to send the cookie on a cross-site request.
Set-Cookie: session=...; HttpOnly; Secure; SameSite=LaxStrict— never sent cross-site. Most secure but breaks normal links (visitingbank.comafter clicking from email shows you logged out).Lax(default in modern browsers) — sent on top-level GET navigations, not on cross-site POST/iframe/XHR. Sufficient for most apps.None; Secure— sent everywhere. Needed for cross-origin SPA → API auth.
2. CSRF token for state-changing requests. Server issues a random token per session, embeds it in forms / sends as a header. Each POST/PUT/DELETE must include it; server verifies.
GET /form → response includes <input name="csrf" value="xyz...">
POST /save → body must include csrf=xyz... matching the sessionFor SPAs: csrf-token cookie + matching X-CSRF-Token header (the "double-submit cookie" pattern). The attacker can't read the cookie (different origin), so can't put it in a header.
3. Origin / Referer check on the server. Reject requests with unexpected origins. Cheap defense-in-depth.
Beyond classic CSRF: the SPA evolution.
- Modern browsers default cookies to
SameSite=Lax. Many apps no longer need a CSRF token. - Cross-site requests via
fetchneedcredentials: "include"AND a CORS allow-list. The default same-site browser policy + CORS removes most of the old attack surface. - JSON-only APIs are harder to CSRF (a form can't
Content-Type: application/jsonwithout a preflight). But don't rely on this; serve a token.
Token refresh flow
The 2026 standard for cookie-or-header-based auth:
Login → server sets:
- access_token (JWT, 15 min, HttpOnly cookie or returned in body)
- refresh_token (opaque, 30 days, HttpOnly cookie, DB-backed)
Each API call → access_token verified statelessly
server returns 401 when expired
On 401 → client hits POST /refresh (refresh cookie auto-sent)
server validates refresh against DB, rotates it
- issues a new access_token AND a new refresh_token
- invalidates the old refresh
client retries the original requestWhy rotate refresh tokens. If a refresh leaks (e.g., backup snapshot), and the attacker uses it once, the legitimate user's next refresh will fail (token already rotated). Server sees the reused token, revokes the whole token family (force logout). This is OAuth 2.1's "refresh token reuse detection."
The client mutex.
A naive implementation: every concurrent request that hits 401 triggers its own /refresh — multiple refreshes race, only one wins the rotation, others get logged out.
let refreshPromise: Promise<void> | null = null;
async function refresh() {
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
async function api(path: string, opts?: RequestInit): Promise<Response> {
let res = await fetch(path, { ...opts, credentials: "include" });
if (res.status === 401) {
await refresh();
res = await fetch(path, { ...opts, credentials: "include" });
}
return res;
}All concurrent 401s wait on the same refresh promise.
Where it gets subtle.
- Multi-tab. Tab A refreshes; tab B has a stale access token in memory but the new refresh cookie. Solution: use
BroadcastChannelor storage events to notify tabs of the new access token; or always read access from cookies. - Background tabs. Refresh interval ≠ access expiry. Don't pre-emptively refresh on a timer — refresh on demand (401) or on user activity. Idle tabs shouldn't keep tokens alive forever.
- Logout. Server-side: delete the session / blocklist the refresh family. Client-side: clear in-memory tokens, hit /logout to clear cookies. Broadcast to other tabs.
- WebSocket reconnect after refresh. WS connections authenticated at handshake hold the old token. Tear down and reconnect with the new one.
Refresh + CSRF. The /refresh endpoint is a state-changing request that runs with cookie credentials — it IS a CSRF target. Protect it with the same CSRF defenses (SameSite + token).
Senior framing. The interviewer wants to see: (1) cookie flags as the primary defense, (2) CSRF token only when SameSite isn't enough, (3) refresh-rotation with reuse detection, (4) client-side concurrency control, (5) multi-tab coordination. The candidate who only knows "CSRF tokens" is mid; the one who knows when they're now unnecessary is senior.
Follow-up questions
- •When is a CSRF token not necessary in modern browsers?
- •Why rotate refresh tokens, and how is reuse detected?
- •Why do concurrent 401s need a mutex on the client?
- •How do you coordinate refresh across multiple tabs?
Common mistakes
- •Storing refresh tokens in localStorage — XSS reads them.
- •Not rotating refresh tokens — one leak is forever.
- •Multiple parallel /refresh requests — race conditions log users out.
- •Forgetting to protect /refresh itself against CSRF.
Performance considerations
- •Short access tokens limit DB lookups (verify-only) but increase refresh frequency.
- •Track refresh failure rate as a key auth health metric.
Edge cases
- •Cross-origin SPA + API — needs `SameSite=None; Secure; Partitioned`.
- •User clicks back/forward across login boundary — handle stale UI gracefully.
- •Mobile native — replace cookies with secure storage; same refresh model.
Real-world examples
- •Auth0, Clerk, Supabase Auth — all default to rotated refresh + access tokens.
- •OAuth 2.1's refresh-token reuse detection is the standardized version of this.