Thoryn
  • architecture
  • oauth2
  • bff
  • engineering

One hostname, two backends — the BFF pattern, properly

The console talks to the BFF, the BFF talks to the gateway — both behind one hostname so cookies stay first-party and refresh tokens never reach the browser.

17 Jun 2026 · Mark Bakker

The customer self-service console doesn't talk to the API gateway. The console talks to the BFF. The BFF talks to the gateway. Both live behind the same hostname — console.thoryn.dev — so cookies are first-party and refresh tokens never reach the browser. This is the backend-for-frontend pattern, done properly.

The decision fits in two diagrams and three sentences, then takes a week to land in Helm because the trade-offs you skipped over the first time turn out to matter. Here is what we picked, what we didn't, and why.

Why a BFF at all

A SPA talking directly to a public API is fine until you remember refresh tokens. A fifteen-minute access token can't carry a working day on its own, so either the SPA gets a refresh token or something on the server side does.

Refresh tokens in a browser are a long-lived credential in the most hostile execution environment we ship code into. XSS, a malicious npm transitive dependency, a leaked debug log — any of those exfiltrates a refresh token that mints access tokens for as long as the rotation allows. Token rotation helps; it doesn't make the token a non-issue.

The BFF moves the token off the browser. The console gets a HttpOnly; Secure; SameSite=Lax session cookie; the BFF holds the rest in an encrypted session store. JavaScript cannot read, exfiltrate, or exchange them. The blast radius of an XSS bug drops to what the user's session can do right now, not what their identity can do for the rest of the rotation window. That's the only argument for a BFF that actually matters.

One hostname, two backends

One hostname, two backends — console.thoryn.dev path-routes to console and BFF; api.thoryn.dev fronts the gateway for CLI and partners

The console and the BFF live behind the same hostname. The ingress is path-based:

console.thoryn.dev/         →  Next.js console
console.thoryn.dev/api      →  BFF
console.thoryn.dev/oauth2   →  BFF
console.thoryn.dev/login    →  BFF

A single Kubernetes Ingress, four rules, longest-prefix wins. Two pods, two Services, one origin.

Same-origin buys us three things a two-hostname split cannot. Cookies set on console.thoryn.dev are first-party — not covered by the cross-site cookie phase-out, no SameSite=None needed. Every fetch('/api/clients') is same-origin, so no CORS preflight on every API call. And the OIDC redirect dance — /oauth2/authorization/hub, /login/oauth2/code/hub — runs on the SPA's own origin, so the post-login redirect lands on the same page the user was on.

The path-based ingress does the splitting before any request reaches application code. The console and the BFF don't share code, a process, or a port. They share a hostname and a TLS certificate. That is enough.

What we rejected

The cleaner-looking alternative was to give the BFF its own subdomain — bff.thoryn.dev — and let the console call across origins. Cleaner DNS, cleaner Helm, cleaner mental model in a slide. The kind of architecture that reads well in an ADR. Pay the bill, though.

Cross-origin means the BFF's cookie has to be SameSite=None; Secure, because the console is the cross-site requester from the cookie's perspective. SameSite=None cookies are third-party cookies in the browser's accounting, and the browser landscape has spent five years ratcheting down on those — Safari partitions them, Firefox blocks them by default, Chrome's phase-out has been delayed twice but not cancelled. Building a cookie surface on the assumption that third-party cookies will keep working is a footgun that triggers in some random Chrome release in two years.

Cross-origin also means CORS preflight on every API call, and a separately-attackable hostname with its own subdomain-takeover and stale-DNS failure modes. None of it is hard; all of it is one more thing to misconfigure.

We picked same-origin. The DNS is slightly less tidy. Everything downstream of DNS is much tidier.

The two-ingress topology

The customer plane is two ingresses, not one. console.thoryn.dev is the browser audience — the path-routed split between console and BFF, behind a session cookie. api.thoryn.dev is the non-browser audience — the API gateway, fronting the product API, accepting Authorization: Bearer …. Same product API behind both, two completely different auth shapes at the edge.

The CLI doesn't go through the BFF. The BFF is built around browser-session cookies and an interactive OIDC flow; a native binary on a laptop already has a perfectly good local credential store. The CLI authenticates per RFC 8252 §7.3 — loopback redirect, PKCE, tokens straight into the OS keychain — and sends bearer tokens directly to api.thoryn.dev. CI/CD and partner integrations follow the same path.

Scope namespace

The hub mints customer-plane tokens under a tenant: scope namespace — tenant:clients.read, tenant:users.write, tenant:audit.read. Permissions ride as scopes; there's no parallel roles claim, and Spring Security maps scopes to authorities natively.

The hub also mints a tnt claim — the tenant ID — at token-issue time. The gateway short-circuits requests where tnt is missing or malformed before they reach the product API; then the product API re-checks tnt against the resource path. Same check, twice, in two processes. Defence in depth, not a single perimeter that has to be perfect.

What it took to deploy

The Helm chart for the customer plane: four service manifests, the path-routed ingress for console.thoryn.dev, a separate ingress for api.thoryn.dev, and a database migration registering the BFF as a confidential authorization-code client.

The interesting part is the ingress. nginx-ingress matches longest-prefix, so /api, /oauth2, /login would win over / regardless of order. We list them long-prefix-first anyway — the manifest reads top-to-bottom in the order the request will route, and a future reader doesn't have to remember which controller's matching semantics we relied on.

Ten manifests, one migration, two hostnames, one customer plane.

Closing

The question to start from when designing a browser-facing OAuth surface is where does the refresh token live? If the answer is "in the browser", you have a different threat model than ours — be honest about it. If the answer is "on a server", the next question is which hostname does that server share with the SPA? Same-origin is worth more than it looks like on the diagram.

One hostname, two backends, path-based routing, first-party cookies, refresh tokens server-side. The backend-for-frontend pattern, properly. To see the customer-plane surface in action, request access.