Sessions and Tokens — From the Browser to the Mesh
How not to keep tokens in the browser: the BFF
In one line
If an SPA holds the access token itself, a single compromised script running on that page lets the token leak out. A BFF (Backend for Frontend) keeps the token only on the server and gives the browser only an HttpOnly session cookie. It is a design that leaves no token in the browser to steal.
Why this was needed
For a while the standard setup for an SPA was like this. The browser gets an access token from the issuer, puts it in localStorage, and attaches it as Authorization: Bearer every time it calls the API. It is convenient because the server is stateless. The problem is that the party that can read that token is "every script running on this page". An ad tag, an analytics script, a compromised npm package, a single line of XSS — anything can read the token and send it to the attacker's server, and that token stays valid in the attacker's hands until it expires. Closing the tab does not help.
The IETF refined this problem for a long time and published it as RFC 10017 OAuth 2.0 for Browser-Based Applications (draft name draft-ietf-oauth-browser-based-apps). After comparing several architectures, it puts the BFF in section 6.1. The core is one sentence — the token exists only in the BFF, so there is no token to take out of the browser.
How it works
A BFF is a small server that stands at the same origin as the frontend. This server takes over the OAuth client role entirely.
브라우저 ── GET /login ─────────────▶ BFF (state 를 담은 임시 세션 발급)
브라우저 ── 302 ▶ 발급자 /authorize (로그인) ── 302 ▶ BFF /callback?code=..
BFF ─────── code + client_secret ──▶ 발급자 /token → access token
BFF: 토큰을 서버 쪽 세션에 저장, 세션 ID 재발급, HttpOnly 쿠키만 내려줌
브라우저 ── GET /bff/api/me + 쿠키 ─▶ BFF ── Authorization: Bearer ▶ 하류 API
The rules to follow are gathered in section 6.1.3.
- Confidential client. The BFF holds a credential (the client secret) with the issuer and uses the authorization code grant. Even if the code leaks through the address bar or logs, it cannot be exchanged for a token without the secret.
- Cookie.
SecureandHttpOnlyare MUST, andSameSite=Strict,Path=/, and a prefix such as__Host-Http-, which means it was set only over HTTP, are SHOULD. Only the session ID goes into the cookie, and that value means nothing to the downstream API. - Forwarding. The BFF strips the cookie from the request, attaches the user's access token, and passes it downstream. The downstream API does not know the cookie and trusts only the Bearer token. If the token is missing or dead, it gives a 401 and
WWW-Authenticate: Beareras in RFC 6750 section 3. - CSRF. Because the browser carries the cookie automatically, CSRF defense is a MUST for a BFF. The RFC cites SameSite=Strict and a way of requiring a custom request header so that a cross-origin request must go through a CORS preflight. This lab sends the session-bound CSRF token built in the earlier module as a custom header to get both properties together.
- Logout. If you delete only the BFF session, the token on the issuer side stays alive until it expires. Tell the RFC 7009 revocation endpoint first.
What it looks like in the field
After you add a BFF, the most common accident is the token leaking again "for convenience". The session check response also carries access_token, or the frontend wants to call the downstream API directly and you hand down the token once more as a cookie that JS can read. At that moment the BFF is a BFF in name only. There is the opposite direction too. If the downstream API accepts a request with no token "because it is the internal network", a call that skips the BFF goes straight through.
You also have to use it knowing its limits. Section 6.1.4.1 notes that client hijacking, where a malicious script sends requests to the BFF through the user's browser, cannot be blocked even by a BFF. What a BFF removes is token theft, not XSS itself. That the attack also ends when the user closes the tab is the difference, and that difference is the value of a BFF.
What you will do in the next lab
You bring up the issuer (8312), the downstream API (8311), and the BFF (8310) in one Pod, play the browser role with curl, and walk through the login flow. You check the attributes of the cookie the BFF handed down, pull the real token out of the BFF session in Redis, and confirm that that value is nowhere in the responses the browser saw. Then you prove with requests that a call through the BFF is 200 and a direct downstream call is 401, that a write without a CSRF token is blocked, and that after logout the old token dies downstream.