Skip to content

Case study

A token the browser never holds

Frontend Engineer · frontend architecture ownerOct 2022 — present4 min read

  • Next.js App Router
  • TypeScript
  • TanStack Query
  • Go gateway
Auth, RBAC and the data layer for a multi-tenant support platform, designed so the browser holds no credential and navigation cannot disagree with its guards.

Context

A multi-tenant platform where support agents handle customer conversations, telephony and account administration in one console. Agents sign in once and work for a whole shift; tenants differ in which modules they have and which roles exist inside them. The frontend is Next.js on the App Router in front of a Go gateway.

Two things about that shape drive everything below. Sessions are long, so token expiry happens mid-work rather than between tasks. And permissions vary per tenant, so "what can this person see" is data, not a constant.

Constraint

Three, in the order they mattered.

The browser must not hold an access token. Anything readable by JavaScript is readable by any script that reaches the page, and the usual answer — harden the app until injection is impossible — is a bet you have to win every day, forever.

Expiry must be invisible. An agent mid-call does not care that a token lapsed. Any design where expiry produces a visible error, a lost draft or a re-login is a design that fails on the day it matters.

Navigation and authorisation must not be able to disagree. A menu item that survives the permission behind it sends someone to a 403 they did not ask for, and that costs trust in the whole product rather than in one page.

Approach

A backend-for-frontend, not a token in storage. A same-origin route layer holds the session, keeps the token in an httpOnly cookie the page cannot read, and attaches the Authorization header server-side. The client sends a cookie it cannot inspect; the proxy exchanges it for a header the browser never sees. Client-side CORS disappears as a side effect, because every request is same-origin.

One refresh, never a storm. When a token lapses mid-page, every request already in flight fails at once. An in-flight flag and a request queue mean the first 401 starts a refresh and the rest await the same promise, then replay. The interactive version makes the difference visible in about three seconds.

One permission map, projected twice. Navigation is that map filtered; route guards are that map checked; middleware checks it again before the route is reached. Adding a module is one edit rather than two edits and a thing to remember. The playground shows what the alternative costs.

Server-first data. Lists are prefetched on the server and the client cache is seeded with exactly the filter set that was rendered — derived from the URL, so a reload or a shared link produces the same result — with cache lifetimes tuned per how volatile the data actually is, from seconds for live queues up to several minutes for administrative data that changes weekly.

Trade-offs

The proxy is a hop, and hops cost. Every request pays an extra network leg and the session has to live somewhere. That is real latency traded for removing a category of vulnerability rather than managing it. On a console where people work for hours, the trade is easy; on a public marketing site it would not be.

Collapsing refreshes makes one request slower to make the rest possible. Everything queued behind the refresh waits for it. That is the cost, and it buys a failure mode that cannot happen rather than one that is merely rare.

Deriving navigation from permissions constrains the navigation. Ordering, grouping, and "visible but disabled" all have to become properties of the map instead of decisions made in the menu. More design up front, in exchange for a class of drift that becomes unrepresentable.

Tuned cache lifetimes are a maintenance surface. They are correct when set and quietly wrong when the data's volatility changes. They need a reason written down, not a number.

Outcome

The browser never holds a credential, so token theft through injected script is not a risk that has to be mitigated — it is a risk the architecture does not have. Expiry became invisible to agents: concurrent 401s resolve into one refresh and the original requests complete, so a lapsed token no longer produces a re-login or a lost draft.

Navigation and route authorisation cannot contradict each other, because they are the same map read twice rather than two lists maintained in parallel. And the data layer stopped refetching on hydration, because the client cache is seeded with the exact query the server already answered.

What I'd change today

The first version coupled navigation visibility to route authorisation directly. Adding a permission meant editing two places and remembering to. Today I would derive both from the permission map from the start and let the navigation be a pure projection of it — which is what the second version does, and what the demo shows. The lesson generalises past this feature: when two pieces of code answer the same question, the bug is not that they disagree, it is that they were ever allowed to.

I would put a typed contract between the proxy and the client sooner. The route layer started as a thin pass-through and grew response shaping over time, which meant the client's idea of a response and the proxy's drifted in small ways that only showed up at call sites. Generating the client types from the gateway's schema, rather than writing them alongside, would have caught that at build time instead of in review.

I would treat cache lifetimes as documented policy, not constants. They were tuned once, correctly, and nothing in the code says why any particular value is what it is. A short comment per query — what makes this data volatile — would have made them maintainable by someone other than the person who set them.