Security
Part 5 of 7 · OAuth & OIDCBFF Cookie Sessions vs SPA Bearer Tokens
A BFF keeps access and refresh tokens on the server and gives the browser only an HttpOnly Secure SameSite session cookie. SPA bearer puts tokens in the browser, so XSS means token theft. BFF needs CSRF defenses; bearer needs extreme XSS hygiene. Prefer BFF for first-party SPAs.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Prefer BFF for first-party SPAs
Prefer
BFF cookie session
The browser holds an HttpOnly Secure SameSite session cookie. Access and refresh tokens stay on the server. Refresh is single-flight, and the session id rotates at login.
- XSS cannot exfiltrate the refresh token. It can still ride the live session.
- The PKCE verifier and state stay in the server pre-login session.
- Mutating routes compare a session-bound CSRF token in constant time and check Origin.
Alternative
SPA bearer in the browser
Simpler to operate when there is no BFF. XSS that can read the token can replay it from anywhere.
- Keep the access token in memory only.
- Short TTL and rotation still leave the token usable for its lifetime.
- Native apps use Auth Code + PKCE themselves. They do not share the browser cookie.
Overview
Two dominant browser patterns:
- SPA bearer. The browser holds access and refresh tokens and calls APIs with an
Authorizationheader. - BFF (Backend-for-Frontend). The browser holds only an
HttpOnlySecureSameSitesession cookie. The BFF is a confidential OAuth client that holds the tokens and proxies API calls.
BFF shrinks the XSS blast radius and keeps refresh on the server. SPA bearer is simpler to operate and makes XSS a token-theft event. The question is where the credential lives, who can read it, and how fast you can revoke it. Rotation and reuse detection are covered in refresh token rotation.
Where the credential lives
| Credential | Storage | Revoke | XSS | CSRF |
|---|---|---|---|---|
| Server session id in an HttpOnly Secure SameSite cookie | Cookie JavaScript cannot read | Instant, delete the session | Cannot exfiltrate the cookie; can ride the session while running | Must mitigate |
| BFF session cookie, tokens on the BFF | Cookie to the BFF only | Delete the session and revoke the refresh token | Session riding only | Mitigate on the BFF |
| Access and refresh tokens in localStorage | JavaScript-readable | Hard until expiry | Severe; tokens replayable from anywhere | Low |
| Long-lived stateless JWT | Anywhere | Weak | High if stolen | Depends on how it is sent |
Comparative
| Concern | SPA bearer | BFF |
|---|---|---|
| Token location | Browser memory or storage | Server-side session store |
| XSS impact | Steal access and refresh tokens for durable abuse | The attacker can use the session only while the script runs |
| CSRF | Mostly not applicable; browsers do not auto-attach Authorization | Must mitigate cookie auto-send |
| CORS | APIs must allow the SPA origin and headers | The browser talks same-origin to the BFF |
| Token refresh | In the browser, racing across tabs | Server-side, single-flight per session |
| Complexity | IdP SDK in the SPA | Extra hop and a session store that must be highly available |
| Mobile | Natural | Native apps use Auth Code + PKCE themselves |
What fails if you choose wrong
- Bearer access token in
localStorageplus XSS: exfiltrate and replay from anywhere. - BFF cookies without CSRF defenses: cross-site state-changing posts succeed.
SameSite=NonewithoutSecure: browsers reject the cookie.- Assuming
HttpOnlystops all XSS harm: the attacker can still drive the victim's browser (session riding).
BFF flow (recommended for first-party SPAs)
- Steps 1–2. The SPA hits BFF
/login. The BFF creates a pre-login session holding the PKCE verifier, state, and nonce, and redirects to the authorization server with the S256 challenge. - Steps 3–4. The callback hits the BFF. The BFF checks state, exchanges the code plus verifier, validates the ID token nonce, and stores tokens server-side.
- Step 5. The BFF issues a new session id (session fixation defense) in a
__Host-cookie. - Steps 6–7. The SPA calls BFF
/apiwith the cookie and a CSRF header on mutating requests. The BFF attaches the bearer upstream. - Refresh happens only inside the BFF, once per session at a time, with rotation and reuse detection at the authorization server. See refresh rotation.
Sequence
- 1
SPA in browser → BFF
Step 1 GET /login
- 2
BFF → Authorization server
Step 2 Redirect with PKCE challenge, state, nonce - verifier stays in server session
- 3
Authorization server → BFF
Step 3 Callback with code and state
- 4
BFF → Authorization server
Step 4 Check state, exchange code plus verifier for AT and RT
- 5
BFF → SPA in browser
Step 5 Set-Cookie __Host-sid HttpOnly Secure SameSite=Lax, new session id
- 6
SPA in browser → BFF
Step 6 POST /api with cookie and X-CSRF-Token header
- 7
BFF → Resource API
Step 7 CSRF ok, attach Bearer AT held server-side
- 8
Resource API → SPA in browser
Step 8 Response via BFF, browser never sees tokens
- 9
SPA in browser
Failure path - cross-site POST rides the cookie without a CSRF token, BFF returns 403
Lesson map
BFF Cookie Sessions vs SPA Bearer Tokens
A BFF keeps access and refresh tokens on the server and gives the browser only an HttpOnly Secure SameSite session cookie. SPA bearer puts tokens in the browser, so XSS means token theft. BFF needs CSRF defenses; bearer needs extreme XSS hygiene. Prefer BFF for first-party SPAs.
Architecture. SPA in browser Ready. BFF Ready. Authorization server Ready. Resource API Ready. Failure path - cross-site POST rides the cookie without a CSRF token, BFF returns 403 Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB S["SPA in browser Ready"] B["BFF Ready"] A["Authorization server Ready"] API["Resource API Ready"] fail["Failure path - cross-site POST rides the cookie without a CSRF token, BFF returns 403 Ready"] S -->|Step 1 GET /login| B B -->|Step 2 Redirect with PKCE challenge, state, nonce - verifier stays in server session| A A -->|Step 3 Callback with code and state| B B -->|Step 4 Check state, exchange code plus verifier for AT and RT| A B -->|Step 5 Set-Cookie __Host-sid HttpOnly Secure SameSite=Lax, new session id| S S -->|Step 6 POST /api with cookie and X-CSRF-Token header| B B -->|Step 7 CSRF ok, attach Bearer AT held server-side| API API -->|Step 8 Response via BFF, browser never sees tokens| S S -->|Failure path - cross-site POST rides the cookie without a CSRF token, BFF returns 403| fail
Cookie flags checklist
HttpOnly: nodocument.cookieaccess.Secure: HTTPS only.SameSite=LaxorStrictfor same-site apps.SameSite=NonerequiresSecureand is only for real cross-site use.SameSite=NonewithoutSecureis rejected.__Host-prefix: requiresSecureandPath=/and forbidsDomain, which pins the cookie to the exact host.- Rotate the session id at login. Set absolute and idle timeouts.
CSRF for cookie BFFs
SameSiteis a baseline, not the only control (same-site subdomains, top-level navigations, older browsers).- Synchronizer token tied to the session, or a signed double-submit token, sent as a custom header on
POST,PUT,PATCH, andDELETE. Compare in constant time. - Check
Origin(orSec-Fetch-Site) on mutating requests as an extra layer. - Credentialed cross-origin calls need an exact
Access-Control-Allow-Origin(never*) plusAccess-Control-Allow-Credentials: true. A same-origin BFF avoids this. - JSON APIs that require an
Authorizationheader are a weak CSRF target because browsers do not attach that header automatically. XSS dominates that design.
Logout is complete only when you clear the cookie, delete the server session, revoke the refresh token at the authorization server, and call the end-session endpoint when the IdP provides SSO. Silent iframe refresh against the IdP breaks under third-party cookie blocking.
When SPA bearer still appears
- Static hosting with no BFF budget: keep the access token in memory only, short TTL, refresh rotation, and a strict CSP.
- Public APIs consumed by third-party SPAs: a different threat model.
- Even in memory, the access token is usable by XSS for its lifetime.
Sandbox: BFF login, CSRF, and single-flight refresh (Python)
Runnable with python3, no dependencies. The authorization server is faked.
Press Run. Snippets must be self-contained — no network, files, or native modules.
BFF edge checks: cookie header, Origin, CSRF (TypeScript)
The Study file imports node:crypto and runs with npx tsx on Node 18+. This sandbox uses the same checks with Web Crypto so Run stays in the browser.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
Why BFF over SPA bearer?
Answer
Tokens stay off the browser, refresh is centralized on the server, and XSS cannot exfiltrate them. First-party SPAs should start here.
Does HttpOnly stop XSS?
Answer
It stops JavaScript from reading the cookie. It does not stop the script from making requests as the user while it runs. You still need CSP and XSS hygiene.
How do you CSRF-protect a BFF?
Answer
SameSite plus a session-bound CSRF token in a custom header on mutating requests, an Origin check, and constant-time comparison.
CORS vs cookies?
Answer
Credentialed cross-origin calls need an exact Access-Control-Allow-Origin and Allow-Credentials true. A same-origin BFF avoids this.
Mobile app on the same API?
Answer
Native apps use Auth Code + PKCE and bearer tokens. The BFF cookie is browser-specific. Share resource servers and audiences, not cookie sessions.
Where is the PKCE verifier stored in a BFF?
Answer
In the server-side pre-login session between /login and the callback, never in the SPA.
Why rotate the session id at login?
Answer
To prevent session fixation: an attacker who planted a pre-login id must not end up holding the logged-in session.
Can the SPA call the resource server directly with the cookie?
Answer
Only if you accept CSRF and CORS on every API. The point of the BFF is one origin for the browser and bearer tokens the browser never sees.
Does SameSite=Lax stop all CSRF?
Answer
No. It is the baseline. Mutating routes still want a CSRF token or custom header.
Why is localStorage the wrong home for a JWT?
Answer
Any XSS reads it and replays it elsewhere. Prefer a BFF, or at least an in-memory access token. See JWT vs opaque tokens.
Pitfalls
An attacker injects a script on app.example. In the SPA bearer design, what do they steal? In the BFF design, what can they still do until the tab closes? Then draw a cross-site POST against the BFF without a CSRF token and show where it is rejected.