← Back to blog

The JWT Should Never Touch the Browser: the BFF Pattern

The one place you shouldn’t keep a token

Almost every SPA starts the same way: the user logs in, the API hands back a JWT, and the frontend stashes it in localStorage to send with every request. It works in the demo, it works in production, and it works right up until the day someone finds an XSS. Then it stops working in the worst possible way.

The root problem: a token reachable from JavaScript is a token stealable from JavaScript. And in a modern web app, with dozens of third-party dependencies, assuming you’ll never run code you don’t control is optimism, not security.

The good news is that there’s a mature pattern that solves this without giving up a modern SPA: the Backend-for-Frontend (BFF). The idea in one sentence: the token lives on the frontend’s server; the browser only ever receives a cookie it can’t read.

Why localStorage is the wrong place

localStorage (and sessionStorage, and any JS variable) is readable by any script running on the page. That includes:

  • A compromised dependency in your supply chain (the attack du jour).
  • A third-party analytics or ads snippet.
  • Any reflected or stored XSS that slips through a loose validation.

If the attacker can run localStorage.getItem('token'), they walk off with your whole session, usually without leaving a trace. Cookies marked httpOnly, on the other hand, are not accessible from JavaScript by design: not even document.cookie sees them. An XSS is still serious, but it’s no longer a silent, trivial session theft.

httpOnly is not optional

If a session token is readable by JavaScript, assume an XSS will steal it. The mitigation isn’t “have no XSS” (impossible to guarantee), it’s that the token isn’t where the XSS can reach it. That means httpOnly cookies, and that means the browser can’t handle the token directly — you need an intermediary on the server.

The BFF pattern: the frontend’s backend keeps the secret

Backend-for-Frontend is exactly what it sounds like: a server layer that belongs to the frontend and mediates between the browser and your API. In today’s stack you get it almost for free: Next.js route handlers, a Remix server, or any process that serves your SPA can act as a BFF.

The division of responsibilities looks like this:

  • The browser talks only to the BFF, and only with cookies. It never sees a JWT.
  • The BFF stores the tokens, attaches them to outbound calls, and refreshes them when they expire.
  • The API receives a plain old Authorization: Bearer …, neither knowing nor caring that there’s a BFF behind it.

How a request flows

Let’s follow a call to /api/profile from the browser:

1. Browser  ──fetch('/api/proxy/profile')──►  BFF
   (the httpOnly cookie rides along on its own, the JS never touches it)

2. The BFF reads the token from the cookie, validates the session, and forwards:
   ──GET https://api.internal/profile
     Authorization: Bearer <access-token>──►  API

3. API responds 200 ──►  BFF ──►  Browser

The browser never builds the Authorization header; it doesn’t even have the material to do so. The BFF is the one that injects it, server-side, where the token is safe. A generic proxy covers most routes:

// Route handler acting as a proxy: browser -> BFF -> API
export async function proxy(req: Request, path: string) {
  const accessToken = await getAccessTokenFromSession(req); // server-side

  const upstream = await fetch(`${API_BASE}/${path}`, {
    method: req.method,
    headers: {
      ...forwardableHeaders(req),
      Authorization: `Bearer ${accessToken}`, // injected here, never on the client
    },
    body: req.body,
  });

  return new Response(upstream.body, { status: upstream.status });
}

Two tokens, two places

A robust scheme uses two tokens with different lifetimes, and each one lives where it belongs:

  • Access token — short-lived (minutes). Used for every API call. Lives in the server’s memory or, at most, in an encrypted session cookie on the BFF side. Never in the browser in the clear.
  • Refresh token — long-lived (days or weeks). Used to obtain new access tokens. Lives in an httpOnly, Secure, SameSite=Strict cookie, ideally with the __Host- prefix.
The __Host- prefix

A cookie with the __Host- prefix is only valid if it was served over HTTPS, without a Domain attribute, and with Path=/. The browser rejects any cookie of that name that doesn’t meet all three conditions, which shuts the door on fixation attacks and on malicious subdomains trying to overwrite it. It’s a free line of defense: all it costs is the name.

Refresh without the stampede

When the access token expires, the API responds 401. The correct reaction is transparent: you refresh the token and retry the original request once. But there’s a concurrency trap: if the page fires off five calls at once and they all get a 401, five simultaneous refreshes compete, and some invalidate the others’ tokens. The result: a storm of errors and, sometimes, the user kicked out.

The solution is single-flight: a single refresh in flight at a time. The first request that gets a 401 kicks off the refresh; the rest wait on that same refresh instead of launching their own. When it finishes, they all retry with the new token.

let refreshing: Promise<void> | null = null;

async function refreshOnce(): Promise<void> {
  // If a refresh is already underway, join it instead of starting another.
  if (!refreshing) {
    refreshing = doRefresh().finally(() => { refreshing = null; });
  }
  return refreshing;
}

A single line of concurrency control saves you a whole class of intermittent bugs that only show up under load.

The OIDC callback with no token in the URL

If you use federated login (OIDC/SSO), there’s a classic temptation: having the provider redirect back with the token in the URL (#access_token=…). Don’t do it. Whatever goes in the URL ends up in the browser history, in the server logs, in the Referer of the next request.

The correct flow (Authorization Code): the provider redirects with a single-use code, the BFF exchanges that code for the tokens server-side, and responds by planting the httpOnly cookies. The browser never sees a token in any URL. The code expires in seconds and can only be exchanged once.

What about CSRF?

Moving the session into cookies reopens a question localStorage didn’t have: Cross-Site Request Forgery. You already have the main defense with SameSite=Strict (or Lax), which keeps the cookie from riding along on requests initiated from other sites. For sensitive operations, add a second layer — a double-submit anti-CSRF token or the Origin header verified at the BFF. It’s not much work, and it’s the fair toll for not having stealable tokens in the browser.

Migrating without exposing

If you’re coming from a server-rendered framework with old-school sessions (server cookies) and you migrate to a SPA, the lesson is not to lose what you already had right. A modern SPA does not force you to put the token in the browser; it forces you to put a BFF in front. The user gains a reactive interface, and you keep the same secure session model as always: the secret on the server, an opaque cookie on the client.

Frequently asked questions

What is the Backend-for-Frontend (BFF) pattern?

It's a server layer that belongs to the frontend and mediates between the browser and the API. It stores the tokens server-side and injects them into outbound calls, so the browser authenticates with an opaque httpOnly cookie and never handles the JWT directly.

Why is storing the JWT in localStorage insecure?

Because localStorage is accessible from any JavaScript running on the page, including compromised third-party dependencies and XSS attacks. If the attacker can read the token, they walk off with the entire session. An httpOnly cookie, by contrast, isn't readable from JavaScript by design.

Where are the access token and refresh token stored with a BFF?

The access token (short-lived) lives in memory or in an encrypted session on the server side; it's used to call the API. The refresh token (long-lived) lives in an httpOnly, Secure, SameSite=Strict cookie, ideally with the __Host- prefix. Neither of them reaches the browser in the clear.

Does the BFF pattern need CSRF protection?

Yes. Because you're using cookies for the session, you have to mitigate CSRF. The first line is SameSite=Strict (or Lax), which keeps the cookie from riding along on requests from other sites. For sensitive operations it's worth adding a double-submit anti-CSRF token or verifying the Origin header at the BFF.

Conclusion

The BFF pattern isn’t an exotic technique; it’s the application to SPAs of something the web has done well with server-side sessions for decades: the secret stays on the server and the browser carries only an opaque reference. What changed was that, at some point, we decided SPAs “needed” the token on the client. They don’t.

Put a BFF in front, keep the access token in memory, the refresh token in an httpOnly cookie with the __Host- prefix, solve the refresh with single-flight, and exchange the OIDC codes server-side. The day an XSS shows up — and it will — your session will still be yours.