Aller au contenu
Cette page n'a pas encore été traduite.

The BFF Pattern in OAuth 2.0 and OpenID Connect

Backend-For-Frontend is the architecture in which a server-side component, not the browser, is the OAuth client. It runs the Authorization Code Flow, keeps the access token and refresh token, and hands the browser nothing but a session cookie. Every call the frontend makes to a protected API goes through it.

The pattern exists because of one fact that no browser-side mechanism changes: whatever the legitimate frontend code can reach, injected code running in the same origin can reach too. Moving the tokens out of that origin is the only measure that removes them from the attacker's reach. Everything else makes them harder to use.

TL;DR

OAuth 2.0 for Browser-Based Applications names three architectures for browser applications, calls each a different trade-off between security and simplicity, and ranks them by security. BFF is the most secure of the three and the most demanding of the backend. It gives that backend three jobs:

  • Be the confidential client towards the authorization server.
  • Hold the tokens in a cookie-based session, so no token is ever exposed to the frontend application.
  • Proxy the frontend's API calls, attaching the access token server-side.

The browser keeps a session cookie and nothing else. An attacker with script execution in the page can still act as the user through that cookie, but cannot take a token away, cannot use one from another machine, and cannot reach an API the backend does not expose.

What counts as a BFF

The name is overloaded in general architecture writing, where a BFF often means any per-client API aggregation layer. In OAuth the term is narrower, and the distinguishing property is not proxying but client identity: the backend becomes the OAuth client for the frontend application. An API gateway that forwards a token the browser supplied is not a BFF, however much proxying it does.

Three responsibilities follow from that, as set out in section 6.1 of the browser-based apps document (revision 27, July 2026; it is a draft and its section numbers move):

  • It authenticates to the authorization server as a confidential client, with credentials the browser never sees.
  • It manages access and refresh tokens within a cookie-based session, so no token is exposed to the browser application.
  • It forwards frontend requests to the resource server, adding the correct access token on the way.

Where the session actually lives

"Cookie-based session" covers two arrangements with opposite failure modes, and the specification permits both.

  • A server-side session puts only an identifier in the cookie. Revocation is immediate and complete, and the cost is that more than one instance needs sticky sessions or session replication. The browser-based apps document recommends this shape only for small deployments.
  • A client-side session carries the tokens in the cookie itself, encrypted. Nothing to share between instances, and two consequences to plan for: an ID token plus an access token plus a refresh token exceeds the 4096-byte cookie limit, so the framework splits the cookie and request headers grow accordingly, and every instance must share the key that decrypts it.

The pattern's guarantee is the same either way, because in both the frontend cannot read a token or send one anywhere itself. Where the tokens do ride in the cookie, the specification asks that its contents be encrypted, and notes that encryption does not change the security properties of the pattern - it keeps tokens out of the user's disk in plaintext. What the cookie still is, in both arrangements, is the whole of the caller's authority, which is why the next section is about the cookie.

The guarantee rests on the cookie, and the specification is prescriptive about it. The cookie MUST be Secure and MUST be HttpOnly. The second is what stops injected code reading the session and replaying it from somewhere else, which is the difference between an attacker driving the page while it is open and an attacker holding the session outright. A path of /, no Domain attribute and a __Host- name prefix are recommended alongside.

Because every call from the frontend is authenticated by that cookie, the backend also MUST carry a CSRF defence. A forged cross-site request to a BFF does not merely change local state: the backend attaches the real access token and calls the resource server on the attacker's behalf.

  • SameSite is the cheapest layer, and the right value depends on how the authorization response comes back. With response_mode=form_post the callback is a cross-site POST that the framework's correlation cookies already handle, so Strict works. With response_mode=query the callback is a cross-site navigation, and Strict can strand the user in a login loop; Lax is the usual compromise there.
  • SameSite alone is not enough when the backend shares a site with anything else. app.example.com and bff.example.com are the same site, so a subdomain takeover reaches the cookie. That is the ordinary production layout.
  • Requiring a custom request header on every call forces a preflight, which turns CORS into a working CSRF defence. Reject any request that arrives without it.

The three architectures, ranked

The specification presents the options in decreasing order of security, which is the useful way to read them.

Where the tokens liveWho calls the APIWhat script injection gets
BFFbackend sessionthe backend, on the frontend's behalfrequests through the user's browser only
Token-mediating backendrefresh token in the backend session, access token in the browserthe frontend, directlythe access token, usable from anywhere until it expires, unless it is sender-constrained
Browser-based OAuth clientbrowserthe frontend, directlyaccess and refresh tokens, and the ability to run its own flow

The middle option is worth naming because it looks like a BFF from the outside. A token-mediating backend also runs the flow as a confidential client, which does protect the refresh token, and it is genuinely lighter than a BFF because it does not proxy every call. But it hands the access token to the browser, and from the moment it does, that token is exfiltratable and usable from anywhere unless it is sender-constrained. The size of that exposure is the token's lifetime, which is a configuration choice: minutes rather than hours is what keeps the option defensible. The choice is a real trade-off rather than a mistake in one direction, and the specification does not leave it open: it asks that a full BFF be evaluated first, and that a token-mediating backend be chosen only where the use case or the system requirements rule out proxying every call.

The third option is the plain SPA-as-client design. PKCE was built for native public clients and later extended to every client type, and it does make the code exchange safe here too. What it cannot do is protect a token after the exchange, because the token is then sitting in an environment the attacker shares.

How a login runs

The sequence differs from a plain SPA login in one structural way: navigation, not fetch, drives the parts that involve the authorization server.

  • The frontend asks the backend whether a session exists.
  • With no session, the frontend navigates the browser to the backend's login endpoint. It does not construct the authorization request itself.
  • The backend redirects the browser to the authorization server, where the user authenticates.
  • The authorization server redirects back to the backend's callback endpoint, again by navigation, at a point where the frontend is not even loaded.
  • The backend exchanges the code for tokens, stores them against a new session, sets the session cookie, and redirects the browser to the application.
  • The frontend loads, asks about the session again, and this time gets an authenticated answer.

The redirect at the end matters more than it looks: it puts the application URL in the address bar without the authorization response attached, so the code never enters the browser history.

The endpoints a BFF exposes

Four. A standard OpenID Connect library for confidential clients gives you the callback and the protocol plumbing behind it; the other three are a thin controller you write yourself.

  • Check session. Called by the frontend with the session cookie. Answers whether a session is active, and usually returns the identity information the UI needs, so the frontend does not have to parse an ID token it should never receive.
  • Login. Reached by browser navigation, not by fetch. Responds with a redirect to the authorization server. A fetch here fails on the cross-origin redirect, which is the most common first mistake when building one of these.
  • Callback. Receives the authorization code by navigation, exchanges it, establishes the session, redirects to the application.
  • Logout. There are two sessions to end, and ending only one is the commonest defect in a BFF. Clearing the cookie ends the local session; the provider's own session survives, so the next navigation to Login is answered without a prompt and the user is signed straight back in, which reads as logout being broken. Add RP-initiated logout at the provider, passing the id_token_hint the backend already holds. In the other direction, a backend is the natural receiver for the provider's back-channel logout, because it has a durable handle on the session that a browser-based client does not.

Objections worth answering

The pattern attracts the same handful of counterarguments, and each has a precise answer.

Doesn't refresh token rotation solve token theft?

Rotation defends against an attacker who stole a token and is using it from somewhere else: the stolen copy gets replayed, the server sees the reuse, and the family is revoked. An attacker with script execution inside the origin is not somewhere else, and does not have to win a race. That code steals each refreshed token and then makes sure the application never uses it, by clearing the application's copy or simply waiting until the user closes the tab. The legitimate client never presents the stolen token, so there is no reuse to detect, and the attacker holds a live refresh token at leisure. Rotation still does the job it was built for, catching a token replayed from another machine; what it does not address is a token in a compromised frontend.

Two mechanics decide whether rotation and a backend can co-exist at all. Refresh happens inline while a call is being proxied, so a page that fires several requests at once against an expired access token starts several refreshes; with rotation on, all but one present a superseded token, the reuse detector fires, and the family is revoked. Every user is logged out at once, under load only, and it reads as an outage at the provider rather than a bug in the backend. Serialize refresh per session behind a lock and let the other callers wait for the winner's result.

Then tie the session lifetime to the maximum refresh token lifetime, and end the session as soon as the backend learns its refresh token is dead, so a live cookie never sits in front of a dead grant.

Doesn't DPoP solve it?

DPoP binds a token to a key, and in a browser that key can be made non-extractable through the Web Crypto API, so injected code cannot copy it. It does not need to. The same code can call the same Web Crypto API, sign valid proofs, and use the tokens from inside the browser. DPoP is a real defence against exfiltration and replay from another machine; it does not move the trust boundary, which is what BFF does.

This does keep tokens out of JavaScript's reach, and it misses a second property: scope of authority. An access token in a cookie is still sent with every request, and if it is accepted by several APIs, a compromised frontend can proxy requests to all of them. With a BFF the browser holds a session cookie scoped to one backend, and the backend decides which token goes to which API.

True, and it is the honest limit of the pattern. BFF does not stop XSS. What it removes is token theft and the escalation that follows: injected code cannot extract a token and cannot use one from another machine. Those two are properties of the architecture.

Two more you have to build. The attacker's reach ends where the proxy's allowlist ends, so the backend has to validate outbound destinations against an explicit list of approved resource servers - a proxy that forwards wherever the request points hands over the access token it was built to protect. And surviving the session is only as hard as the session is short, since an attacker driving the page keeps a sliding session alive.

The remaining attack surface is the backend's own API. Rate limiting, anomaly detection and request validation belong there, where injected code cannot tamper with them, and the application builds them; the pattern does not supply them.

Doesn't this violate the OAuth model?

It changes who the client is, deliberately. In the classic model the access token is issued to the application the user is looking at; here the backend is the client and the browser is a session-authenticated consumer of that backend's API. The browser-based apps document recommends exactly this, on the reasoning that a browser is a hostile place to keep credentials. Treating a backend as the confidential client it actually is applies the OAuth model.

Isn't CORS enough to stop exfiltration?

CORS decides whether a script may read a cross-origin response, not whether the browser may send the request. Exfiltration needs only the request: a CORS-safelisted fetch is sent and its response merely hidden, which costs the attacker nothing, and a form submission, navigator.sendBeacon or an image tag carrying the data in a query string never touch CORS at all. CORS is a defence against cross-origin reads, and with a required custom header a defence against CSRF; it was never an exfiltration control. The control that covers those channels is a Content-Security-Policy restricting connect-src, form-action and img-src.

Where PKCE fits

The flow a BFF runs is still an Authorization Code Flow, and it still passes a code through the browser. Proof Key for Code Exchange protects that leg, and the security BCP recommends it for confidential clients too, so a BFF should send code_challenge even though it holds a client secret. The two mechanisms cover different halves of the problem: PKCE makes an intercepted code useless, and BFF makes sure that what the interception could have reached is not a token in the first place.

What it costs

The browser-based apps document is direct about this: a BFF is significantly more complicated than a browser-only application, and proxying every call can put a significant load on the server side. Four costs are worth pricing before choosing it.

  • A component to run. One more deployable on the path of every API call, with its latency and its availability now in front of the whole application.
  • Session infrastructure. Either a shared store and sticky sessions, or cookies large enough to be split and a key shared across instances.
  • A proxy to keep honest. The outbound allowlist, the CSRF defence and per-endpoint method limits are requirements, not refinements.
  • Traffic that all looks alike. Every user reaches the resource server from the backend's address, so rate limiting and anomaly detection keyed on client IP stop working there and have to move into the backend. That is the right place for them, but it is a move, not a gain.

Building one

The pattern is deliberately described here without a framework, because the design questions are the same everywhere: which endpoints exist, what the cookie holds, where the proxy sits.