Implementing the BFF Pattern for SPAs
The backend-for-frontend exists to answer one question: where do the tokens live? Its answer — on a server the browser talks to over its own origin, never in the page — removes the entire class of bugs where an injected script exfiltrates a credential. This guide is part of the OIDC and OAuth 2.0 implementation track and covers the pattern end to end: what the BFF owns, how requests are proxied, what it costs, and where teams get it wrong.
Prerequisites
- A working authorization code flow with PKCE, which the BFF will run on the server side.
- A session store you can revoke, such as the one described in managing session state in Redis.
- The ability to serve the single-page application and the BFF from the same registrable origin, or to make them same-site.
What the BFF Owns
The division of responsibility is the whole design. The browser holds one opaque session cookie and nothing else. The BFF holds the tokens, performs the code exchange, refreshes credentials, and attaches them to outbound calls. The resource APIs never see the browser at all.
Proxying Without Leaking
The BFF exposes a narrow, same-origin API surface and forwards to the real services. The forwarding rules are where security is either preserved or quietly discarded.
Allowlist the routes you proxy rather than forwarding anything under a prefix. An open proxy that attaches your access token to whatever path the browser asks for is a confused deputy: a bug or an injected script can reach internal services the user was never meant to touch, with a valid credential attached by you.
Strip and re-set headers deliberately. The inbound request’s Authorization header, if any, should be discarded — the browser has no business supplying one — and the outbound token attached from the session. Forward a correlation identifier so traces join up, and do not forward cookies to upstream services, which have no use for them and should not receive session material.
const PROXY_ROUTES = new Map([
["GET /api/invoices", "https://api.internal/invoices"],
["POST /api/invoices", "https://api.internal/invoices"],
]); // explicit allowlist — no wildcards
export async function proxy(req: Request, res: Response) {
const target = PROXY_ROUTES.get(`${req.method} ${req.path}`);
if (!target) return res.status(404).end();
const session = await sessions.require(req); // 401 if absent or expired
const accessToken = await tokens.freshFor(session); // refreshes if under a minute remains
const upstream = await fetch(target, {
method: req.method,
headers: {
authorization: `Bearer ${accessToken}`, // set by us, never forwarded
"content-type": req.get("content-type") ?? "application/json",
"x-correlation-id": req.correlationId,
},
body: ["GET", "HEAD"].includes(req.method) ? undefined : JSON.stringify(req.body),
signal: AbortSignal.timeout(5_000),
});
res.status(upstream.status);
res.set("cache-control", "private, no-store"); // never cache an authenticated response
upstream.body?.pipeTo(streamTo(res));
}
The Cookie Is Now the Credential
Moving tokens to the server means the session cookie carries all the risk the tokens used to, so it gets the full treatment: __Host- prefix, HttpOnly, Secure, SameSite=Lax, Path=/, no Domain, and an explicit Max-Age matched to the absolute session timeout. The reasoning behind each is in configuring secure cookie flags in production.
Because the cookie is sent automatically, the BFF inherits CSRF exposure that a bearer-token API did not have. Every state-changing route needs an anti-forgery token in addition to SameSite, as described in mitigating CSRF attacks in modern SPAs. This is the trade the pattern makes, and it is a good one: CSRF has a complete, cheap defence, and token exfiltration does not.
Refresh, Concurrency and Long-Lived Connections
The BFF refreshes access tokens on the user’s behalf, which means a burst of parallel requests from one page can all notice an expiring token at once. Guard the refresh with a per-session lock so one exchange happens and the others wait, otherwise rotation with reuse detection will see several presentations of the same refresh token and revoke the family — signing the user out for being active. The pattern is covered in secure token refresh and rotation.
WebSockets need explicit handling because the upgrade request carries cookies but subsequent frames carry nothing. Authenticate at upgrade time against the session, attach the identity to the connection, and re-check periodically — a connection opened an hour ago is still using the authorization decision made when it opened, which is wrong if the session has since ended. A simple approach is to close connections whose session no longer exists on a short timer, and to close them immediately when a logout for that session is observed.
Server-sent events have the same property and the same fix. Long polling does not, because each poll is a fresh request that revalidates naturally.
Scaling and Operating the BFF
The BFF is now on the critical path for every request the application makes, so it needs the operational attention that implies. It is stateless apart from the session store, which means horizontal scaling is straightforward as long as the store is shared and connection pools are sized per process rather than per route.
Two failure modes deserve pre-agreed answers. When the session store is unavailable, fail closed — return a maintenance response rather than treating requests as anonymous. When the identity provider is unavailable, existing sessions should keep working from their current access tokens until those expire, while new logins fail; that graceful degradation is only possible if refresh failures are distinguished from session failures in your code.
Frequently Asked Questions
Does a BFF mean I need a separate service per frontend?
Not necessarily a separate deployment, but a separate surface. The value comes from the API being shaped for one client and owned by the team that owns that client, so it can change at the frontend’s pace. Running it as a module inside an existing gateway is fine; sharing one generic API across web, mobile and partners and calling it a BFF is not, because the coupling that makes the pattern useful is exactly what you have given up.
Can the SPA and the BFF be on different domains?
They can be on different subdomains of the same registrable domain, which keeps the cookie same-site. Genuinely different domains force SameSite=None, which switches off the browser protection you were relying on and puts you at the mercy of third-party cookie policy. If the frontend is served from a CDN, put the BFF on a subdomain of the same domain rather than accepting a cross-site cookie.
What stops the BFF becoming an open proxy?
An explicit route allowlist and per-route authorization. Forwarding everything under a prefix with your access token attached turns the BFF into a confused deputy: whatever the browser asks for, it asks upstream with full credentials. List the routes you proxy, check the caller’s permission for each, and return 404 for anything unlisted.
How do I handle file uploads and downloads through a BFF?
Stream rather than buffer, and cap the size explicitly, or a handful of large uploads will exhaust memory on the proxy tier. For downloads of user content, prefer issuing a short-lived signed URL from the BFF and letting the browser fetch directly from storage — that keeps large transfers off the request path entirely while the authorization decision still happens on your server.
Is a BFF worth it for a small application?
If the application is already server-rendered, you effectively have one and should keep the tokens server-side. For a small pure single-page app calling one API, the honest answer is that the BFF adds a component to operate; the question is whether you would rather run that component or accept that any injected script can steal a working credential. For anything handling money, personal data, or administrative capability, the component is the cheaper of the two.
Migrating an Existing SPA to a BFF
Most teams adopt the pattern with a working application already in production, so the migration matters more than the greenfield design. It can be done incrementally, and the order that keeps users signed in is the one below.
Start by putting the BFF in front of the existing API as a pure pass-through, on the same origin as the frontend, without changing authentication at all. That establishes the routing, the deployment, the observability and the latency budget while the credential model is unchanged, so any problem you hit is an infrastructure problem rather than an authentication one.
Next, add the login flow to the BFF and issue a session cookie alongside the existing tokens. For a period both credential types work: the frontend keeps using its token for calls it already makes, while new calls go through the proxied, cookie-authenticated routes. Instrument which path each request took, because that ratio is your migration progress.
Then move the frontend’s calls over route by route, starting with reads and finishing with the sensitive writes. As each route moves, its anti-forgery protection must already be in place — the CSRF work lands before the traffic, not after it, or you spend a window with cookie-authenticated endpoints and no defence.
Finally, stop issuing tokens to the browser, delete the client-side storage code, and remove the token-accepting paths from the API once telemetry shows no traffic on them. That last deletion is the one that actually closes the exposure; leaving a dormant token path means an attacker who finds it can still ask for a portable credential.
Common Mistakes
Four patterns account for most disappointing BFF implementations. The first is a wildcard proxy route that forwards anything under /api/ with the access token attached, which recreates the confused-deputy problem the pattern was supposed to remove. The second is caching authenticated responses — at the CDN, in the framework, or in the proxy itself — which can serve one user’s data to another and is invisible until it happens. The third is keeping the refresh logic in the frontend, so the browser still holds a refresh credential and the BFF is decorative. The fourth is forgetting that the BFF is now a single point of failure for the whole application, and giving it no health checks, no autoscaling and no capacity plan.
Each has an easy check. Enumerate the proxied routes and confirm every one is explicit. Fetch an authenticated response twice as two different users and confirm the second is not the first. Grep the frontend for any reference to a token. And look at whether the BFF has the same monitoring as the services behind it.
Local Development and Testing
The pattern’s dependence on same-site cookies makes local development the place where teams quietly break it. Running the frontend on one port and the BFF on another is cross-origin in a browser’s eyes even on localhost, which tempts people into SameSite=None, permissive CORS, or disabling security attributes “just in development” — and those settings have a habit of reaching production through a shared configuration file.
The fix is to make development look like production. Run the frontend’s dev server behind the BFF, or put both behind a local reverse proxy on one origin, so cookies behave identically to the deployed environment. Use a locally trusted certificate so Secure cookies work, and keep one configuration file with environment overrides that never turn off an attribute — only change values such as the domain.
Testing follows the same principle. Integration tests should drive a real browser against a running BFF, complete a real login against a test tenant, and assert on the cookie attributes as well as on the response body. Contract tests between the BFF and upstream services keep the proxy honest when an API changes shape. And a small set of security assertions — no token in any response body, no authenticated response cacheable, unlisted proxy routes returning 404 — protects the properties that make the pattern worth having.
When the BFF Is the Wrong Answer
The pattern is not free, and there are cases where it is the wrong shape. A public API consumed by third-party integrations needs real OAuth clients with their own credentials, not a session cookie; putting a BFF in front of that is an impedance mismatch that ends in a bespoke token-issuing layer nobody wanted to build. A mobile application has a secure key store and no cookie jar worth relying on, so it should hold tokens directly and use the platform’s protections rather than proxying everything through a server.
Similarly, if your application is server-rendered, you already have the property the BFF provides: tokens live on the server and the browser holds a session cookie. Adding a separate proxy tier in that case adds a hop and an operational component without changing the security posture.
The question to ask is whether tokens would otherwise end up in a browser. If the answer is yes, the pattern earns its cost; if the answer is no, spend the effort on the controls that do apply — cookie attributes, anti-forgery tokens, and session revocation.
Deciding What the BFF Should Not Do
Because the BFF sits on every request, it attracts responsibilities: response shaping, aggregation across services, caching, feature flags, experiment assignment. Some of that is exactly what it is for — combining three upstream calls into one payload for a screen is the original justification for the pattern. Other additions turn it into a second application with its own business logic, at which point it needs the testing, the release process and the on-call rotation of one.
Draw the line at state. The BFF should hold session state and nothing else; the moment it owns business data, you have created a service whose failure loses information rather than merely interrupting requests. Aggregation, translation and protocol adaptation are fine because they are stateless and derivable; a write path that updates something only the BFF knows about is not.
Keep authorization decisions upstream too. It is tempting to filter responses in the proxy because it is the last place before the browser, but a filter there means the upstream service returned data the caller was not entitled to, and any other consumer of that service inherits the exposure. The proxy enforces coarse access to a route; the service enforces what its data means.
One further boundary is worth stating explicitly: the BFF should not become the place where new features are added because it is the easiest tier to deploy. That pressure is real — it sits between the frontend team and everything else — and it is how a proxy becomes a monolith over a couple of quarters. Reviewing what the BFF does every few months, and pushing anything stateful back into a service that owns that state, keeps it the thin, understandable layer that made the pattern attractive in the first place.
Booking that review as a recurring calendar item, rather than trusting anyone to notice the drift, is what keeps the boundary from eroding one convenient exception at a time.
Related
- Implementing authorization code flow with PKCE — the flow the BFF runs server-side.
- Revoking tokens on logout in a BFF — completing the teardown the pattern makes possible.
- Mitigating CSRF attacks in modern SPAs — the exposure a cookie-carrying BFF takes on.
- Managing session state in Redis — the store behind the BFF’s session cookie.