localStorage vs IndexedDB for Auth Tokens
Engineers reaching for a place to keep a bearer token usually frame the decision as “localStorage or IndexedDB?” — synchronous and simple versus asynchronous and capacious. This guide answers that question, but the honest conclusion is uncomfortable: for a raw bearer token, neither is safe, because both are readable by any JavaScript running on your origin. A single cross-site scripting (XSS) flaw drains either store identically. The right move is to stop choosing between two equally exposed surfaces and adopt the architecture that keeps the credential out of script reach entirely. For foundational context review localStorage vs sessionStorage, and for the full threat model see Storage Security, Encryption & Privacy.
The specific problem
You need somewhere to keep a token so authenticated requests survive page reloads. The candidates differ in mechanics: localStorage is a synchronous string-only key/value map with roughly 5 MB of capacity; IndexedDB is an asynchronous, transactional, structured database that holds far more and stores native JavaScript values. On capacity and ergonomics they are genuinely different tools. On the only axis that matters for a credential — who can read it — they are identical. Both expose their contents to every script executing on the origin, so the framing “which one is safer for tokens” has no winning answer.
Root-cause analysis: both stores are script-readable
The Web Storage and IndexedDB specifications grant access to any same-origin script context. There is no per-script isolation, no capability check, and no way to mark a value “off limits to other code on this page.” That is by design: these APIs are general-purpose client storage, not credential vaults.
The consequence is that the structured clone storage in IndexedDB buys you nothing against the dominant token-theft vector. If an attacker injects script — through a vulnerable dependency, an unescaped DOM sink, or a compromised third-party tag — that script can call localStorage.getItem('token') or open your IndexedDB database and read the object store, then fetch() the value to an attacker-controlled server. The token remains valid until it expires, regardless of whether the user later logs out.
| Property | localStorage token |
IndexedDB token | HttpOnly cookie + in-memory |
|---|---|---|---|
| API model | Synchronous, string only | Asynchronous, structured | Cookie set by server; token in JS variable |
| Capacity | ~5 MB per origin | Hundreds of MB+ | N/A (token tiny) |
| Readable by page script | Yes | Yes | Cookie: no · access token: only this page’s memory |
| Survives reload | Yes | Yes | Cookie yes; access token re-fetched silently |
| Exfiltrated by one XSS | Yes | Yes | Refresh cookie: no · access token: only if live in memory |
| Valid after logout | Until expiry | Until expiry | Server revokes refresh cookie immediately |
| CSRF exposure | None (not auto-sent) | None | Cookie auto-sent — needs SameSite + CSRF defense |
The last two columns are the point. Moving a token from localStorage into IndexedDB is lateral motion: it changes the API you call, not the attacker’s ability to steal the credential.
The fix: HttpOnly cookie plus in-memory access token
The defensible architecture splits the credential into two parts. A long-lived refresh credential lives in an HttpOnly, Secure, SameSite cookie that the server sets and JavaScript cannot read. A short-lived access token lives only in a module-scoped variable in memory — never written to localStorage, IndexedDB, or sessionStorage. On reload the in-memory token is gone, so the app silently exchanges the cookie for a fresh access token before its first authenticated call. This is the same pattern recommended in Securing Auth Tokens in Browser Storage.
// auth.ts — the access token never touches persistent storage.
let accessToken: string | null = null;
let expiresAt = 0;
let refreshInFlight: Promise<string> | null = null;
// Exchange the HttpOnly refresh cookie for a short-lived access token.
async function refreshAccessToken(): Promise<string> {
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // sends the HttpOnly, SameSite refresh cookie
headers: { 'X-Requested-With': 'fetch' }, // helps the server reject CSRF
});
if (!res.ok) {
accessToken = null;
throw new Error('Session expired — re-authentication required');
}
const body = (await res.json()) as { accessToken: string; expiresIn: number };
accessToken = body.accessToken;
expiresAt = Date.now() + body.expiresIn * 1000;
return accessToken;
}
// Single-flight: concurrent callers share one refresh round-trip.
async function getAccessToken(): Promise<string> {
if (accessToken && Date.now() < expiresAt - 5_000) return accessToken;
if (!refreshInFlight) {
refreshInFlight = refreshAccessToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
// Use it: attach the in-memory token to outbound requests.
export async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
const token = await getAccessToken();
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(input, { ...init, headers });
if (res.status === 401) {
// Access token rejected; force one refresh then retry once.
accessToken = null;
const retryToken = await getAccessToken();
headers.set('Authorization', `Bearer ${retryToken}`);
return fetch(input, { ...init, headers });
}
return res;
}
// On explicit logout, clear memory and ask the server to revoke the cookie.
export async function logout(): Promise<void> {
accessToken = null;
expiresAt = 0;
await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
}
Because the access token lives only in memory, there is nothing in localStorage or IndexedDB for an attacker’s persistent payload to scrape on a later visit. The refresh cookie is HttpOnly, so script cannot read it at all. The cost is a single refresh round-trip after each reload — a latency trade that eliminates an entire class of theft.
Verification
Confirm nothing sensitive landed in script-readable storage:
// Run in DevTools console after login. Both should be empty of credentials.
console.assert(
!Object.keys(localStorage).some((k) => /token|jwt|auth|bearer/i.test(k)),
'A token-like key leaked into localStorage',
);
const dbs = await indexedDB.databases?.() ?? [];
console.info('IndexedDB databases present:', dbs.map((d) => d.name));
// Open each and confirm no object store holds a raw access/refresh token.
In the DevTools Application panel, inspect Local Storage, Session Storage, and IndexedDB: none should contain a bearer or refresh token. Under Cookies, the refresh cookie must show HttpOnly ✓, Secure ✓, and a SameSite value of Lax or Strict. In the Network panel, /auth/refresh should fire once after a hard reload and carry the cookie automatically.
Edge cases and a fallback
When a token in IndexedDB is defensible. IndexedDB token storage is acceptable only when the value is not a usable bearer credential on its own: for example a short-lived, narrowly-scoped, app-encrypted token (sealed with the AES-GCM pattern from Storage Security, Encryption & Privacy) whose decryption key is held in memory and never persisted. Even then, an XSS attacker who can call your own decrypt helper wins — encryption protects against disk forensics and shared-device reads, not against script in your origin. Reserve this only for tokens that are useless without a server-side second factor.
CSRF trade-off. Moving the credential into a cookie reintroduces cross-site request forgery risk, because cookies are sent automatically on cross-site requests. Mitigate with SameSite=Lax or Strict, a CSRF token or custom request header the server validates, and by scoping the cookie path. This is a well-understood, bounded problem — unlike unbounded XSS token exfiltration, which has no clean mitigation once the token is script-readable.
No backend to set cookies. If you genuinely cannot issue HttpOnly cookies (a pure static front end against a third-party API), minimize the blast radius: hold the token only in memory for the tab’s lifetime, accept that reload re-authenticates, and never persist it to localStorage or IndexedDB. A token that lives only in memory dies when the tab closes, denying a later-loaded payload anything to steal.
Frequently Asked Questions
Is IndexedDB more secure than localStorage for tokens because it is asynchronous?
No. The asynchronous, structured nature of IndexedDB changes the API, not the access control. Both stores are readable by any script on the origin, so one XSS flaw drains either identically. Use the Securing Auth Tokens in Browser Storage pattern instead.
If both are unsafe, where should the token actually live?
Keep the long-lived refresh credential in an HttpOnly, Secure, SameSite cookie that JavaScript cannot read, and hold only a short-lived access token in an in-memory variable. After a reload, silently exchange the cookie for a new access token before the first authenticated request.
Doesn't using a cookie expose me to CSRF?
It can, but CSRF is a bounded, well-understood problem. Set SameSite=Lax or Strict, validate a CSRF token or custom header server-side, and scope the cookie path. That is a far better position than the unbounded token exfiltration you get from any script-readable store.
Related
- Securing Auth Tokens in Browser Storage — the full in-memory plus HttpOnly architecture and XSS threat model.
- Storage Security, Encryption & Privacy — what each storage primitive actually guarantees for sensitive data.
- Why a JWT in localStorage Is XSS-Vulnerable — the concrete exfiltration attack walkthrough.
- localStorage vs sessionStorage — the parent guide on Web Storage trade-offs.
- Encrypting Browser Storage with Web Crypto — when app-encrypting a non-bearer token in IndexedDB is defensible.