Store authentication tokens securely
Sensitive authentication tokens are stored in httpOnly cookies rather than localStorage or sessionStorage to prevent theft via cross-site scripting attacks (OWASP A07).
- Store session tokens and JWTs in httpOnly cookies — not localStorage
- localStorage is readable by any JavaScript on the page, including injected XSS code
- Combine httpOnly with Secure and SameSite=Strict (or Lax) cookie flags
- Use short-lived access tokens and rotate refresh tokens on each use
- Protect every state-changing request with CSRF defenses, not cookie flags alone
Rule Details
Where you store authentication credentials determines your entire XSS attack surface. HTTP cookies (opens in new tab) and browser storage APIs do not offer the same guarantees, and the OWASP Session Management Cheat Sheet (opens in new tab) treats that distinction as a core architectural decision rather than an implementation detail.
Code Examples
The server sets the token as a cookie, the browser sends it automatically on every request, and no JavaScript can read it. That separation is exactly what reduces the A07 authentication-failure (opens in new tab) risk from token theft after an XSS bug.
Server: Set the cookie after authentication
// app/api/auth/login/route.ts (Next.js)
import { NextRequest, NextResponse } from 'next/server'
import { signJwt } from '@/lib/jwt'
export async function POST(request: NextRequest) {
const { email, password } = await request.json()
const user = await authenticateUser(email, password)
if (!user) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}
const accessToken = signJwt({ sub: user.id }, { expiresIn: '15m' })
const refreshToken = signJwt({ sub: user.id, type: 'refresh' }, { expiresIn: '7d' })
const response = NextResponse.json({ user: { id: user.id, name: user.name } })
// Access token — short-lived, httpOnly
response.cookies.set('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 15, // 15 minutes
path: '/',
})
// Refresh token — longer-lived, restricted path
response.cookies.set('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/api/auth', // Only sent to the refresh endpoint
})
return response
}Server: Validate the cookie on protected routes
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@/lib/jwt'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('access_token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const payload = verifyJwt(token)
const response = NextResponse.next()
// Forward user ID to route handlers via header
response.headers.set('x-user-id', payload.sub as string)
return response
} catch {
// Token expired or invalid — attempt refresh
return NextResponse.redirect(new URL('/api/auth/refresh', request.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}Server: Refresh token rotation
// app/api/auth/refresh/route.ts
export async function POST(request: NextRequest) {
const refreshToken = request.cookies.get('refresh_token')?.value
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
}
try {
const payload = verifyJwt(refreshToken)
// Invalidate the old refresh token (token rotation)
await invalidateRefreshToken(refreshToken)
// Issue new token pair
const newAccessToken = signJwt({ sub: payload.sub }, { expiresIn: '15m' })
const newRefreshToken = signJwt(
{ sub: payload.sub, type: 'refresh' },
{ expiresIn: '7d' }
)
const response = NextResponse.json({ ok: true })
response.cookies.set('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 15,
})
response.cookies.set('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7,
path: '/api/auth',
})
return response
} catch {
// Refresh token invalid/expired — force re-login
const response = NextResponse.json({ error: 'Session expired' }, { status: 401 })
response.cookies.delete('access_token')
response.cookies.delete('refresh_token')
return response
}
}Clearing tokens on logout
// app/api/auth/logout/route.ts
export async function POST() {
const response = NextResponse.json({ ok: true })
response.cookies.set('access_token', '', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 0,
path: '/',
})
response.cookies.set('refresh_token', '', {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 0,
path: '/api/auth',
})
return response
}Why It Matters
localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability — including one in a third-party script — can exfiltrate all tokens silently. httpOnly cookies are completely invisible to JavaScript; even if an attacker executes arbitrary code on the page, they cannot read the cookie. This single architectural choice eliminates the most common token theft vector.
Storage Mechanism Comparison
| Storage | Readable by JS | Persists across tabs | Sent automatically | XSS risk |
|---|---|---|---|---|
localStorage | Yes | Yes | No | High |
sessionStorage | Yes | No (tab-scoped) | No | High |
document.cookie (without httpOnly) | Yes | Yes | Yes | High |
| httpOnly cookie | No | Yes | Yes | Low |
| Memory (JS variable) | Yes (same tab) | No | No | Medium |
When Is localStorage Acceptable?
Some tokens have lower sensitivity and are acceptable in localStorage:
| Token type | localStorage OK? | Reasoning |
|---|---|---|
| Session token / JWT (auth) | No | High-value target for XSS |
| Refresh token | No | Grants long-lived access |
| Short-lived CSRF token | Yes (with care) | Intentionally readable by JS |
| OAuth state parameter | Yes | Short-lived, non-secret |
| UI preferences, theme | Yes | Not a security credential |
| Analytics/telemetry IDs | Yes | Not a security credential |
CSRF Protection
httpOnly cookies are sent automatically — including on cross-site requests if SameSite is None. Always use SameSite=Strict or Lax, and add a CSRF token for every state-changing request when cookies can be sent by the browser:
// Generate a CSRF token and store it in a separate, readable cookie
response.cookies.set('csrf_token', generateCsrfToken(), {
httpOnly: false, // Must be readable by JS to include in request headers
secure: true,
sameSite: 'strict',
maxAge: 60 * 60,
})
// Client reads the CSRF token and sends it as a header
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('csrf_token='))
?.split('=')[1]
await fetch('/api/transfer', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken ?? '' },
})Also validate the Origin or Referer header server-side for sensitive browser-initiated requests so your server rejects forged submissions even if client code is bypassed.
An httpOnly cookie prevents JavaScript from reading the token, but the browser still sends it on cross-site requests. Always combine httpOnly with SameSite=Strict or SameSite=Lax to prevent cross-site request forgery. They are complementary protections, not alternatives.
Exceptions
- Client-side storage for non-sensitive UI state is not equivalent to storing credentials, session identifiers, or long-lived secrets.
- Framework defaults are not exceptions by themselves; only documented constraints with compensating controls should suppress the finding.
- When several storage protections fail together, prioritize the control that most directly prevents credential theft or replay.
Standards
- Align the implementation with OWASP: Session Management Cheat Sheet and verify the effective response or browser behavior, not only the configuration file.
- Align the implementation with OWASP: A07:2021 – Identification and Authentication Failures and verify the effective response or browser behavior, not only the configuration file.
- Align the implementation with MDN: Using HTTP cookies and verify the effective response or browser behavior, not only the configuration file.
Verification
- Open DevTools → Application → Cookies and confirm session tokens have the
HttpOnlyflag checked. - In the browser Console, run
document.cookieand verify the session token does not appear. - Verify
localStorageandsessionStoragedo not contain tokens: runObject.keys(localStorage)andObject.keys(sessionStorage)in the Console. - Test the login → refresh → logout cycle and confirm cookies are set, updated, and cleared correctly.
- Trigger one state-changing request without the CSRF header and confirm the server rejects it.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Check whether authentication tokens are stored in httpOnly cookies or in JavaScript-accessible storage like localStorage. Verify that POST, PUT, PATCH, and DELETE requests also require a CSRF token or an equivalent anti-forgery control.
Fix
Auto-fix issues
Move token storage from localStorage/sessionStorage to httpOnly cookies set by the server, and ensure the cookies have Secure and SameSite flags. Add CSRF protection to every state-changing route that relies on browser-sent credentials.
Explain
Learn more
Explain why localStorage is vulnerable to XSS token theft and how httpOnly cookies mitigate this attack vector.
Review
Code review
Review authentication flows, token storage calls, and API client code. Flag any use of localStorage.setItem, sessionStorage.setItem, or document.cookie for storing access tokens, JWTs, or session identifiers. Also flag credentialed mutations that lack a CSRF token, Origin check, or same-site enforcement.