← All posts

A BFF for many tenants, and the one request that arrives with no cookie

Authagonal·July 28, 2026

Single-page apps were taught to hold their own tokens. The app runs the OAuth flow in the browser, gets an access token and usually a refresh token back, keeps them somewhere in JavaScript, and attaches them to every API call. It is well documented, it is what most tutorials show, and it puts your longest-lived credential in the one place you cannot defend: a runtime that will cheerfully execute anything that finds its way onto the page. One successful injection, one compromised dependency somewhere in your build, and nobody needs to phish anybody. They read the token and leave, and it keeps working until it expires.

The backend-for-frontend pattern moves the credential out of reach. The browser talks to a small server that belongs to your app, and that server is the confidential OAuth client. It runs the code exchange, it holds the access and refresh tokens, and it hands the browser nothing but an opaque cookie. This is a description of how we built ours, and of the one part that turned out to be genuinely interesting: making it serve more than one tenant.

What the browser ends up with

A cookie called __Host-agbff, marked HttpOnly, SameSite=Lax, scoped to the whole path, with no expiry so it dies with the browser session. Its value is 256 bits of randomness and nothing else. It is not a token, it does not decode into anything, and stealing it off the wire is not a thing you can do to a __Host- cookie over TLS.

Everything real sits server-side, in a session record in a distributed cache: the access token, the refresh token, the id token, when the access token expires, and which tenant the session belongs to. The login itself is an ordinary authorization code flow with PKCE, run by a confidential client that authenticates to the token endpoint with its secret. The id token is validated properly on the way back in, issuer, audience, signature against the published keys, lifetime, and then a constant-time comparison of the nonce against the value stashed before the redirect. Only after all of that does a session exist and a cookie get set.

A header that is checked for existence, and nothing else

The browser calls the BFF for its own user info and to reach the proxied API, and those calls carry a custom header, x-authagonal-bff. Its value is irrelevant. Presence is the whole check.

That looks lazy and is not. The entire class of cross-site request forgery relies on a form post, an image tag, or a navigation that another origin can trigger while your cookie rides along, and none of those can set a custom header. The moment attacking JavaScript tries to add one it stops being a simple request and becomes a preflighted one, which your CORS policy declines. The header is not a secret to be guessed, it is proof that the request came from code rather than from markup.

It is required on the scripted calls and deliberately not on the ones that are top-level navigations by nature: starting a login, coming back from the identity provider, following a logout link. Demanding a custom header on a browser navigation would just break the navigation.

Refresh, exactly once

Every proxied request may discover that the access token is about to expire, and a busy page makes several requests at once. Refresh them all naively and you get a small disaster: several concurrent refreshes with a rotating refresh token, each one invalidating the others, ending with the user logged out by their own traffic.

So refresh is single-flight, per session. The first request through takes a gate keyed by the session id and everyone else waits. The subtle part is what the waiters do when they get in: they re-read the session from the store before deciding anything, because the request that held the gate first has very likely already refreshed, and the value they were holding when they queued is stale. Refresh once, then everybody uses the result.

It is also rotation-aware in the boring, necessary sense. If the token endpoint hands back a new refresh token, it replaces the stored one. If refresh fails with a token error, the session is deleted and the user is logged out, because a refresh token that has been rejected is not going to be accepted on the next attempt, and retrying is just a slower way to be logged out.

One BFF, many tenants

A single-tenant BFF resolves everything from configuration at startup: one authority, one client id, one secret. Serving many tenants from one deployment breaks that, because now the BFF has to work out which issuer a login belongs to, and it has to do so before any session exists.

Selection happens at the login endpoint, through a query parameter you get to name, so it can be ?slug= or ?org= or whatever your product already calls a customer. A resolver turns that key into a tenant config: authority, client id, secret, scopes. If it resolves to nothing, the login is refused there and then.

Then the key has to survive a round trip to an identity provider that knows nothing about it, and come back. There is no session yet to hold it, so it rides in the correlation cookie, the short-lived encrypted cookie that already carries the PKCE verifier, the state, and the nonce for exactly this reason. Fifteen minutes, one per login attempt. On the way back the cookie is decrypted, the tenant is resolved again from the key inside it, and the code is exchanged against that tenant's token endpoint. Only then is the tenant key written into the session, where it becomes the durable answer.

From that point every path re-resolves from the session rather than from anything the browser said: refresh, logout, the proxy. And when a tenant stops resolving, because it was deleted or disabled, the session is destroyed rather than quietly falling back to some default tenant. An unresolvable tenant is a logout, not a shrug.

Back-channel logout is where this design earns its keep. The identity provider POSTs a logout token straight to your server when a session ends elsewhere. No cookie. No session. No browser involved at all. In a single-tenant BFF that is unremarkable, since there is only one issuer it could be from. In a multi-tenant one you have to answer "which tenant is this for?" before you can answer "is this even real?", and the only material available is the token itself, which you have not verified and therefore cannot trust.

The ordering that makes this safe is worth stating precisely. Read the issuer claim out of the unverified token, and use it for exactly one thing: choosing which tenant's configuration to load. Then verify the token's signature against that tenant's published keys and its client id as the audience. A forged issuer picks the tenant it is pretending to be, and then fails verification against that tenant's keys, because the attacker does not have them. Nothing was ever accepted on the strength of the untrusted claim. The claim only decided who gets to judge it, and the judge is a trust anchor we already had.

After that come the checks that make a logout token a logout token: it must not carry a nonce, since it is not authenticating anybody; it must carry the back-channel logout event; and it must identify either a specific session or a subject. A session id kills one session. A subject kills every session that user has, which is the form we emit, because "log me out everywhere" is what people actually mean when they click log out everywhere. That is why the session store keeps an index by session id and another by subject, rather than only by cookie value.

Single tenant stays boring

All of this is inert for the common case. The default resolver returns the same configuration no matter what key it is handed, and its resolve-by-issuer returns that configuration unconditionally, because there is only one. A single-tenant BFF behaves exactly as it did before the seam existed: no tenant parameter, nothing extra in the cookie, no new failure modes. The multi-tenant machinery is opt-in and invisible until you name a query parameter.

Separate selection from trust

When you make something multi-tenant, the exercise worth doing is to enumerate every entry point and ask what identifies the tenant on each one. Browser navigations carry a cookie you issued and control. Redirect callbacks carry state you signed. Server-to-server callbacks carry only what the caller chose to send, and the caller might be lying.

The awkward entry point, the one with nothing trustworthy on it, is the one whose design decides whether the whole system is sound, and it is usually the one nobody thinks about until late. The pattern that got us through it generalises: separate selection from trust. It is fine to route on untrusted data as long as routing only chooses which trust anchor is allowed to make the final call, and the final call is made by something the attacker cannot forge.

If you would rather not write all of that yourself, Authagonal ships the BFF for .NET and for Node, and the portal will provision the confidential client it needs, redirect URI, back-channel logout endpoint and all, in one click.