After RFC 10017, the First Reason for a BFF Is Keeping Tokens Out of the Browser
After years of work, the OAuth working group’s guidance for browser-based apps was published in August 2026 as RFC 10017. It sits under BCP 212 alongside RFC 8252, the guidance for native apps. Section 6 ranks three architectures by security, with the BFF first. The BFF manages the tokens without handing them to browser code, so even an attacker with full control of the frontend finds no tokens to steal.
When I design a BFF now, I start with where the tokens live. Earlier posts in this series mostly covered aggregation and response shaping. Those reasons still hold, but they can wait: the browser should hold only a random session identifier, with no user data or permissions in it. It is still a login credential; it just grants nothing by itself. I faced this trade-off when reworking an admin back office. The Go excerpts below show how to keep the tokens inside the BFF.
1. Starting from a forgeable role cookie
I once built an admin back-office MVP whose first version stored the user’s role as plain text in a cookie, and the SSR admin layout read that value directly to decide whether to let someone into the admin pages. Anyone could open the developer tools, edit the cookie and get in. That was an authorisation hole, with the UI merely the visible part. Prototypes often do this because it’s the quickest option, but permission data ends up in the user’s hands when it needs to be checked by the server.
I replaced this with server-side sessions, which can be built in several ways. For example, login can verify the password with Argon2id and then issue an opaque token, with its plaintext in the browser’s cookie, base64url-encoded, and only its SHA-256 hash in the database. Admin sessions can get a shorter TTL than ordinary members’, say 8 hours. In SvelteKit, hooks.server can call a /me endpoint with the cookie to populate locals.user and locals.admin, with every frontend fetch using credentials: include.
The old cookie carried the permission itself. The new one holds a random string that needs a server-side lookup to identify the user, while permission data stays on the server. With hashed storage, if the database leaks, the attacker gets hashes they can’t use to log in. Server-side sessions also make suspension simple: when that back office suspends a member, it revokes all of the user’s active sessions, and their next request is refused.
Section 7.1 of RFC 10017 covers this case: the frontend and API share a domain, and there are no third-party APIs to call. Replacing session management with OAuth often just makes a simple application complicated. A server-side cookie session will do, using the BFF cookie rules in section 6.1.3.2.
The back-office rework and a BFF use the same idea; only the token’s source differs. A session you issue yourself doesn’t need OAuth. When tokens come from an external authorisation server, the BFF can hold them on the browser’s behalf so frontend code never touches them.
2. The three architectures in RFC 10017
The three architectures in section 6 differ in where the tokens end up:
| Architecture | Role of the OAuth client | What the browser holds |
|---|---|---|
| Backend for Frontend | The BFF is a confidential client | Only a session cookie |
| Token-mediating backend | The backend is a confidential client | The access token |
| Browser-based OAuth client | The frontend is a public client | The access token, possibly a refresh token too |
There are two ways to handle the BFF’s session cookie, both covered in section 6.1.2.3. With a server-side session, the browser gets an identifier and the data stays on the server. A client-side session puts the data in a signed cookie; section 6.1.3.2 says the BFF SHOULD encrypt it when it contains access tokens. Both use an HttpOnly cookie that frontend code can’t read. This post uses server-side sessions.
A token-mediating backend obtains the tokens and passes the access token to the frontend, which calls resource servers directly. Without having to proxy every request, the backend is lighter than a BFF. The RFC considers it less secure, though, and recommends checking whether a full BFF is feasible before choosing it. The third architecture runs the whole OAuth flow in the browser with no client credentials. As the RFC points out, an attacker doesn’t even have to steal an existing token: they can request a fresh set from the authorisation server.
The ranking uses the threat model in section 5: assume the attacker can already run JavaScript in the application, through XSS or a compromised third-party package, then consider what they can do. If the frontend can obtain tokens, even complete isolation of stored tokens won’t help. The attacker can run a silent authorisation flow in a hidden iframe and obtain new ones, provided the user’s session at the authorisation server is still active and the server supports silent flows without user interaction.
A stolen refresh token lets the attacker keep obtaining access tokens on their own machine. They only need the application to stop using that refresh token, perhaps by waiting for the user to close the tab, to avoid triggering reuse detection. This can continue until the refresh token expires, is rotated or is revoked. The RFC notes that browser apps’ refresh tokens often stay valid for hours or even days. On another multi-tenant prototype I took a different route. The access token lived for 15 minutes in frontend memory, and the refresh token lived for 7 days in a cookie with the __Host- prefix, HttpOnly, Secure and SameSite=Lax, with rotation and reuse detection that invalidated the whole token family on reuse. Because the access token reaches the browser, that design sits closest to the token-mediating backend. I accepted the trade-off at the time because frontend code couldn’t read the refresh token, and a stolen access token was only good for 15 minutes. The RFC’s analysis shows the gap that remains: section 6.2.4.3.1 notes that malicious code can mimic the legitimate application and ask the backend for the latest access token, with the browser attaching the cookie for it, so the 15-minute limit just means the attacker asks more often. That is why section 6.2.4.4 strongly recommends evaluating a full BFF first, and that evaluation is where I would start if I built such a system again.
Of the three architectures, only the BFF keeps OAuth tokens away from frontend code, and section 6.1.4.3 strongly recommends it for business applications, sensitive applications and applications handling personal data.
The Framework Server Layer Is Not Your BFF drew the boundary by asking who faces the browser, who faces the backend and who carries the risk. RFC 10017 gives a concrete answer to the last of those: whichever layer holds the tokens.
3. Keeping tokens in the BFF comes first
Why BFF Still Earns Its Keep in 2025 and Two Ways to Wire a BFF gave the BFF three jobs: aggregate, trim and hide. Back then, hiding meant keys and downstream addresses. RFC 10017 puts the user’s access and refresh tokens in that category too, and at the top of the list.
In section 6.1, the BFF acts as a confidential client to obtain tokens from the authorisation server and binds them to a cookie-based session. When forwarding the frontend’s API calls to the resource server, it attaches the access token. None of those responsibilities requires aggregation. Holding tokens and forwarding requests is enough reason for a BFF to exist.
My priorities are Security, Simplicity, Correctness, Performance, in that order. Token storage is a security decision, so I settle it before aggregation and response shaping. Deciding who holds the tokens also helps limit the BFF’s responsibilities to the translation layer described in A Thin BFF with Fastify.
Holding tokens adds two jobs: session storage and token refresh. Put the sessions in a shared store such as Valkey and the BFF can still run as several stateless replicas.
4. A minimal BFF with the Go standard library
The example uses the Go standard library: net/http handles HTTP, while httptest fakes the authorisation server (IdP) and downstream API. No real OAuth server is needed. The BFF does four things. Its login callback exchanges the code for a token at the IdP using a client secret and issues a session cookie with the __Host- prefix. Every /api/ request must carry a fixed custom header or it gets a 403. To forward a request to the downstream API, the BFF looks up the access token through the session. It also keys sessions by a SHA-256 hash, as in the earlier example. The client secret and session IDs come from crypto/rand.
Only excerpts are shown here. The rest of the callback, the fake IdP and API, the type definitions and main are left out; the output comes from running the full program.
Setting the cookie:
const (
cookieName = "__Host-sid"
csrfHeader = "X-BFF-Request"
sessionTTL = 8 * time.Hour
)
sid := randToken(32) // 32 bytes from crypto/rand, base64url-encoded
b.mu.Lock()
b.sessions[sha256.Sum256([]byte(sid))] = session{accessToken: tok, expires: time.Now().Add(sessionTTL)}
b.mu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: sid,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
// no Domain: the __Host- prefix forbids it
})
Next come the custom header check and request forwarding. The b.http client sets CheckRedirect to return http.ErrUseLastResponse, disabling the redirect handling that Go’s http.Client enables by default. The BFF returns 502 for any downstream 3xx response:
func (b *BFF) requireHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(csrfHeader) != "1" {
http.Error(w, "missing "+csrfHeader, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (b *BFF) proxy(w http.ResponseWriter, r *http.Request) {
s, ok := b.lookup(r) // look up the session by the cookie's SHA-256; drop it if expired
if !ok {
http.Error(w, "no session", http.StatusUnauthorized)
return
}
u, err := url.Parse(b.apiURL) // fixed host; the frontend only controls path and query
if err != nil {
http.Error(w, "bad upstream", http.StatusInternalServerError)
return
}
u.Path = strings.TrimPrefix(r.URL.Path, "/api")
u.RawQuery = r.URL.RawQuery
req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), http.MaxBytesReader(w, r.Body, maxBodySize))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
req.Header.Set("Authorization", "Bearer "+s.accessToken)
if ct := r.Header.Get("Content-Type"); ct != "" {
req.Header.Set("Content-Type", ct)
}
resp, err := b.http.Do(req) // b.http does not follow redirects; see main
if err != nil {
log.Printf("call api: %v", fmt.Errorf("proxy %s: %w", u.Path, err))
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
http.Error(w, "upstream redirect not allowed", http.StatusBadGateway)
return
}
// read one extra byte to detect an oversized body; return 502 rather than a truncated one
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
if err != nil {
log.Printf("read upstream body: %v", fmt.Errorf("proxy %s: %w", u.Path, err))
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
if len(body) > maxBodySize {
http.Error(w, "upstream response too large", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
if _, err := w.Write(body); err != nil {
log.Printf("write response: %v", err)
}
}
main simulates seven requests in turn: the login callback, a cookie with no header, a cookie plus the header (with a query string), the header with no cookie, a forged cookie, a downstream redirect, and a downstream response over the 1 MiB limit. The fake API accepts only the bearer token the IdP issued and rejects any request that carries a cookie, which confirms the BFF isn’t passing the browser’s cookie downstream. Run with go run -race . (the session ID differs on every run, so only its first six characters are shown):
1. callback: 303 See Other
Set-Cookie: __Host-sid=fiY4pP…(43 chars); Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Strict
token in Set-Cookie: false
2. cookie, no header: 403 Forbidden missing X-BFF-Request
3. cookie + header : 200 OK {"user":"alice","role":"admin","query":"lang=zh"}
token in response: false
4. header, no cookie: 401 Unauthorized no session
5. forged cookie : 401 Unauthorized no session
6. upstream redirect: 502 Bad Gateway upstream redirect not allowed
7. oversized body : 502 Bad Gateway upstream response too large
The access token never appears in Set-Cookie or a response body. All the browser gets is a 43-character session ID. A request carrying a cookie without the header gets a 403 before the code even looks up the session.
io.LimitReader returns EOF at the limit, so using io.Copy directly would send a truncated body with a 200 status. Reading one extra byte lets the example detect an oversized response and return 502. The whole response is buffered in memory; a streaming API needs a different approach.
Two things are deliberately left out: CSRF protection for the authorisation code flow, and refreshing expired tokens. Section 6.1.1 describes the BFF sending a PKCE code verifier when it exchanges the code. Under section 2.1 of RFC 9700, a client can rely on PKCE for CSRF protection once it has confirmed that the authorisation server supports it; otherwise it needs a one-time state value. For refresh, section 6.1.2.2 says the BFF also holds the refresh token and binds it to the session.
Forwarding is fixed to the host in b.apiURL; the frontend controls only the path and query. With redirects disabled as well, requests can’t be steered to another host. Section 6.1.3.6 makes destination validation a MUST: the BFF has to check the host before forwarding and allow only explicitly permitted hosts and paths. In practice, that means keeping an allowlist of resource servers so it doesn’t become an open proxy. The example doesn’t restrict paths; add path and HTTP method restrictions before deployment.
5. Cookie attributes and CSRF details
Section 6.1.3.2 distinguishes two levels of cookie requirements: Secure and HttpOnly are MUSTs; SameSite=Strict, Path=/ and no Domain attribute are SHOULDs. It recommends the __Host-Http- name prefix, defined in the HTTP working group’s draft-ietf-httpbis-layered-cookies, still an Internet-Draft.
The example uses the older __Host- prefix. Browsers reject a __Host- cookie that lacks Secure, has a Path other than / or sets a Domain. __Host-Http- also requires HttpOnly, guaranteeing that the cookie was set through a Set-Cookie header, so frontend JavaScript can’t write one itself. MDN warns that browsers without prefix support accept these cookies anyway. On those browsers, the prefixes offer no guarantees.
With SameSite=Strict, the browser omits the cookie when the user follows a link from another site. That barely affects a single-page app: it calls the BFF with fetch after loading, when the request is already same-site. If the server uses the session to render the first page, as in the earlier example that resolves locals in hooks.server, someone arriving from an email or chat message appears logged out on that page. Whether to fall back to Lax is your call. For the MVP stage, that back office judged SameSite=Lax sufficient and put CSRF protection on the list to evaluate later; given the RFC requirements below, it needs doing before a production launch.
Section 6.1.3.3.1 also cautions that SameSite is only enough when the BFF doesn’t share a site with other applications. Other services under the same registrable domain can send requests that count as same-site.
Section 6.1.3.3.2 recommends adding a custom header because CORS-safelisted requests, such as a POST with no body or an ordinary form submission, can cross origins without a preflight. By the time CORS stops JavaScript reading the response, the request has already reached the server.
A custom header on a cross-origin request always triggers a preflight. Unless the BFF allows it, the browser won’t send the request. The RFC requires every incoming request to carry the header when this mechanism is used, and the defence depends on the BFF’s CORS configuration. The example sends no CORS headers at all, so only a same-origin frontend can make requests with that header.
When the frontend and BFF are cross-origin but same-site (say app.example.com and bff.example.com), the SameSite=Strict cookie is still sent. The frontend needs credentials: include, and the BFF has to return Access-Control-Allow-Credentials with an explicit origin, never *. Every origin on that list must be trustworthy: the list is part of the CSRF defence.
Across sites, a Strict cookie won’t be sent. Browsers’ third-party cookie policies still apply, too. Neither credentials nor CORS settings get around these restrictions, so the BFF is best placed on the same site as the frontend. Section 6.1.3.3.3 also covers the anti-forgery (double-submit) cookies built into some frameworks. The Security Middleware Suite describes gortex’s double-submit implementation; the two approaches can be used together.
6. What a BFF can’t stop
Section 6.1.4.2 says the BFF neutralises attacks that steal tokens. The attack in section 5.1.4, proxying requests through the user’s browser, still works. Malicious JavaScript calls the BFF from inside the user’s page without needing a token. The browser attaches the cookie automatically, and the BFF can’t tell a legitimate action from an attack.
This attack depends on malicious code in the user’s browser and a valid session; the attacker can’t move it to their own machine. Closing the tab doesn’t fix the vulnerability. Reloading the compromised page exposes the user again. A stolen refresh token remains usable on the attacker’s machine until it expires or is revoked.
You still have to deal with XSS, including CSP and dependency management. Section 6.1.4.1 also notes that every request passes through the BFF, making it a useful place for anomaly detection and rate limiting. On high-privilege pages such as an admin back office, shorter session TTLs reduce the attack window. That’s the reasoning behind the 8-hour admin sessions in the earlier example.
Two other documents cover the related cases. Native apps can’t use this cookie mechanism; RFC 8252, also part of BCP 212, covers them. RFC 9700 (BCP 240, published January 2025) collects OAuth’s general security requirements. RFC 10017 builds on those for browser apps.
Notes
- RFC 10017 (BCP 212, August 2026) ranks the OAuth architectures for browser apps by security: BFF, token-mediating backend, browser-based OAuth client. Of the three, only the BFF keeps OAuth tokens away from frontend code.
- The BFF holds the access and refresh tokens as a confidential client and the browser gets only an
HttpOnlysession cookie. This post uses a server-side session with only an identifier in the cookie. When designing a BFF, decide where the tokens live first and leave aggregation for later. - For a single-domain app with no third-party APIs, section 7.1 says a server-side cookie session is enough; replacing the plain-text role cookie with a server-side session follows the same thinking.
- Cookies need
SecureandHttpOnly, and should useSameSite=Strict,Path=/and noDomain. For CSRF, add a custom header that every request must carry, which works as long as CORS doesn’t admit other origins. - The example forwards to a fixed host, keeps the query string, doesn’t follow redirects and returns 502 for oversized responses. Section 6.1.3.6 requires forwarding only to explicitly permitted hosts and paths (MUST), and the example still lacks path restrictions.
- A BFF can’t stop malicious JavaScript sending requests through the user’s browser, so XSS defences, rate limiting and shorter session TTLs are still needed.
Further reading
- RFC 10017, OAuth 2.0 for Browser-Based Applications: the source for the three architectures, the cookie rules, the CSRF defences and the threat analysis cited here.
- IETF Datatracker, draft-ietf-oauth-browser-based-apps: the draft history behind the RFC, ending at -27.
- RFC Editor, BCP 212: BCP 212 consists of RFC 8252 (native apps) and RFC 10017 (browser-based apps).
- RFC 9700, Best Current Practice for OAuth 2.0 Security: the overall security recommendations for OAuth 2.0, published as BCP 240.
- RFC 8252, OAuth 2.0 for Native Apps: how native apps should use OAuth.
- IETF Datatracker, draft-ietf-httpbis-layered-cookies: the draft that defines the
__Http-and__Host-Http-prefixes. - MDN, Set-Cookie: how cookie prefixes and each
SameSitevalue behave. - MDN, Cross-Origin Resource Sharing (CORS): why credentialed CORS requests can’t use
*, and why third-party cookie policies still apply.
Ideas and technical judgement by Sheng; drafted with Claude · examples run on Go 1.24.7.