Whenever a new team asks me to review their SPA architecture, one question comes up almost every time. Should the UI talk to a public API directly, or should everything sit behind a trusted backend using the Backend for Frontend pattern. Both work. Both are used in production everywhere. But they carry very different risk profiles, and picking one without understanding the trade off is how teams end up patching security holes a year into a project.
This article lays out both architectures side by side, based on what actually goes wrong and goes right when you run them in production, not just what looks clean on a whiteboard.
The BFF Setup
In a BFF architecture, the UI and the API are deployed as one trusted application. There is no separate public API sitting on its own domain. The server renders or hosts the UI, holds the OpenID Connect client credentials, and every downstream call happens from a trusted backend rather than from the browser.

Because the whole thing is one deployment, the browser only ever sees a same site, HTTP only cookie. No access token, no refresh token, nothing a script running in the page could exfiltrate through an XSS bug. If the app also uses SignalR, the same cookie protects the WebSocket connection, so there is no token sitting in a query string either, which is a detail people miss until a penetration test flags it.
Since the backend is trusted, you also get room to add stronger protections without touching the UI code. Mutual TLS between the BFF and downstream APIs, tighter OIDC FAPI style requirements, a properly locked down Content Security Policy using nonces instead of unsafe-inline. None of this is available to you once tokens are sitting in browser storage.
The SPA with a Public API Setup
The alternative is the classic pattern most tutorials teach first. A single page application calling a public API directly, each deployed and scaled independently. This is genuinely simpler to reason about at a glance, and it lets the UI call APIs on other domains without any proxy in between, which matters if you are integrating several backend services owned by different teams.

The cost is that access tokens, and sometimes refresh tokens, live in the browser and get attached to requests through JavaScript. Any XSS vulnerability anywhere in your dependency tree, not just your own code, becomes a token theft opportunity. Reference tokens with an introspection endpoint help here, since a stolen reference token is useless without a call back to the authorization server, but that adds a network round trip on every API call that a self-contained JWT would not need.
Logout is the other place this pattern gets awkward. An SPA can only really do a front-channel logout. Getting a proper single sign-on backchannel logout working across multiple relying parties from pure client side JavaScript is not something I have seen done cleanly in practice.
Where BFF Wins
- One trusted application instead of a public UI plus a separate public API, which shrinks the attack surface
- No access or refresh tokens ever stored in the browser
- SignalR and other WebSocket connections protected by the same cookie, no token in the URL
- Backchannel and single sign-on logout become possible, not just front-channel
- Tighter CSP achievable since the app can use nonces and does not need to allow cross-origin script execution for token handling
- Downstream APIs can stay in a private network zone instead of being exposed publicly
- One deployment to manage instead of two, which in my experience translates directly into lower DevOps overhead
Where BFF Falls Short
- Calling a downstream API on a different domain now needs a reverse proxy such as YARP, an on-behalf-of flow, or a direct service to service call, adding a hop
- Progressive Web App support is not automatic the way it is with a pure SPA
- Every state changing request needs anti-forgery token validation or forced CORS preflight, on top of same-site cookie protection
- Invalidating a cookie session cleanly needs extra logic if you want immediate revocation across all active sessions
So Which One Should You Actually Pick
For an internal line of business application, an admin console, or anything where the data behind the API matters, I lean towards BFF almost every time. The reduced attack surface and the fact that a single deployment is genuinely less to operate outweighs the extra hop for downstream calls. This has held up across more than one production system I have supported over a few years, not just a one-off project.
For a public facing product where the UI genuinely needs to call several independently owned APIs across domains, or where PWA offline behaviour is a hard requirement, the classic SPA with a public API is still a reasonable, well understood choice, provided you take reference tokens and revocation seriously rather than treating a long-lived JWT as good enough.
Neither pattern is free of trade offs, and anyone telling you one is universally correct is skipping over real constraints someone else is dealing with. Match the architecture to what your APIs actually need to do, not to whichever pattern is trending in conference talks that year.
Leave a Reply