Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

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).

Utilities
Quick take
Typical fix time 30 min
  • 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
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.

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.

// 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
}
// 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

StorageReadable by JSPersists across tabsSent automaticallyXSS risk
localStorageYesYesNoHigh
sessionStorageYesNo (tab-scoped)NoHigh
document.cookie (without httpOnly)YesYesYesHigh
httpOnly cookieNoYesYesLow
Memory (JS variable)Yes (same tab)NoNoMedium

When Is localStorage Acceptable?

Some tokens have lower sensitivity and are acceptable in localStorage:

Token typelocalStorage OK?Reasoning
Session token / JWT (auth)NoHigh-value target for XSS
Refresh tokenNoGrants long-lived access
Short-lived CSRF tokenYes (with care)Intentionally readable by JS
OAuth state parameterYesShort-lived, non-secret
UI preferences, themeYesNot a security credential
Analytics/telemetry IDsYesNot 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.

httpOnly cookies do not protect against CSRF

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

  1. Open DevTools → ApplicationCookies and confirm session tokens have the HttpOnly flag checked.
  2. In the browser Console, run document.cookie and verify the session token does not appear.
  3. Verify localStorage and sessionStorage do not contain tokens: run Object.keys(localStorage) and Object.keys(sessionStorage) in the Console.
  4. Test the login → refresh → logout cycle and confirm cookies are set, updated, and cleared correctly.
  5. 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.

Sources

References used to support the guidance in this rule.

Rules that often go hand-in-hand with this one.

Set Secure, HttpOnly, and SameSite flags on session cookies

All session and authentication cookies are issued with the Secure, HttpOnly, and an appropriate SameSite flag to prevent interception, JavaScript exfiltration, and cross-site request forgery.

Security
Implement a content security policy

A Content Security Policy is implemented to prevent XSS attacks and control resource loading.

Security
Set a Referrer-Policy header

The Referrer-Policy header controls how much referrer information is sent when navigating from your site to another, protecting user privacy and preventing leaking sensitive URL parameters.

Security

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385