Handling JWT tokens in a modern BFF architecture
How I keep JWTs out of the browser’s reach: BFF-issued sessions, httpOnly cookies, short-lived access tokens, and same-origin rewrites.
If the browser can read your access token, so can a script you did not write. That is the starting point for how I handle JWTs on modern web products.
A Backend for Frontend (BFF) sits on the same site as the UI, owns the token exchange, and talks to APIs with credentials the browser never sees. Next.js rewrites to an Express or .NET BFF are a clean fit. I have shipped this shape on AI product work where auth, credits, and rate limits cannot live in the client.
What goes wrong with the SPA token pattern
The old pattern is familiar. Login returns a JWT. The SPA stores it in localStorage or memory and sends Authorization headers on every fetch.
localStorage survives XSS. Memory dies on refresh and pushes people back to long-lived tokens or silent refresh hacks. Cross-origin API calls force CORS and often widen cookie or header exposure. For a product app on one domain, that complexity is rarely worth it.
The BFF pattern I use
The browser only talks to its own origin. Paths like /bff/* rewrite to the BFF service. Login and refresh happen on the BFF. The BFF sets an httpOnly Secure cookie and keeps access tokens short-lived.
Downstream APIs trust the BFF. The browser never holds a bearer token it can print to the console. If you need a public SPA for a separate admin tool, give that surface its own auth story. Do not reuse the consumer cookie jar without thinking.
- Same site for UI and BFF (or a strict first-party cookie setup).
HttpOnly, Secure cookies for session material.SameSite=Lax or Strict chosen on purpose, not by habit.- Short access token lifetime. Rotating refresh where you support it.
Access token versus refresh token
Access tokens authorize API calls. Keep them short, minutes not days. Refresh tokens mint new access tokens. Treat them as secrets with rotation and reuse detection if your identity stack supports it.
In a BFF design the refresh token stays on the server side of the cookie boundary. The browser sends the cookie. The BFF attaches the access token to upstream calls or exchanges it just in time. Either way, JavaScript does not get a string it can exfiltrate easily.
CSRF when cookies carry the session
httpOnly cookies remove a class of XSS token theft. They bring CSRF back into the conversation. SameSite helps a lot for simple site flows. For state-changing POSTs I still like an anti-forgery token or a custom header that only same-origin JavaScript can set, depending on the stack.
GET requests should not mutate state. That rule alone kills a lot of CSRF pain.
Logout, expiry, and clock skew
Logout must clear the cookie and invalidate server-side refresh state when you have it. Expiry should fail closed. Clock skew between services needs a small leeway, not a five-minute shrug that keeps dead sessions alive.
On the UI, treat 401 from /bff as a signal to return to login or run a single refresh attempt. Do not loop refresh forever. Users notice the spinner more than your cleverness.
What I validate on every protected BFF route
Signature and issuer. Audience if you set one. Expiry. Then application authorization: this user, this resource, this action. A valid JWT is not permission to do everything.
Plan limits, roles, and tenancy checks stay in the handler or a shared middleware. The token proves identity. Your product rules decide access.
A practical baseline
Put the BFF on the same origin through rewrites. Issue httpOnly Secure cookies. Keep access tokens short. Rotate refresh tokens. Rate-limit auth and sensitive routes. Log auth failures without logging token values.
That is modern JWT handling for web products. Not because it is fashionable. Because the browser is a hostile place to store secrets, and a BFF is the simplest honest answer I know.