This is not protected vs unprotected. It is two loss profiles.
The slogan under test is "httpOnly cookies protect you from XSS." The defensible version is far narrower: httpOnly prevents JavaScript from reading the cookie value. It does nothing about the browser attaching that cookie to requests the attacker's own script makes — cookies marked HttpOnly "are still automatically included in same-origin requests made by JavaScript (such as fetch or XMLHttpRequest calls)" [14].
The IETF browser-apps BCP says the same in threat language: under a BFF, "Proxying Requests via the User's Browser" remains possible "because the malicious browser-based code still runs within the application's origin" (§6.1.4.1.1) [1].
A successful XSS attack gives the attacker full control over your application code… In a nutshell, XSS means game over!
Same starting condition — arbitrary script in your origin. Two entirely different second halves.
POST to attacker infrastructure. The credential is now off-origin. The attack is over in a single request and everything after it happens somewhere you cannot see.fetch() to the BFF; the browser attaches the cookie; the BFF swaps it for the real access token server-side and forwards the call. "Using XHR, an attacker can make the victim perform any action within the application" [6].ctoken — and set the value to the HTTP response we just made" [6]. Duende's CSRF defence is a required custom header [11] — trivially set by same-origin JS.Rows are what an attacker with script execution can actually do. GRANTED = the attacker has this capability. DENIED = the architecture takes it away. Read row 03 before you read the tally.
| Attacker capability, payload executing | Column A In-memory token SPA → API, bearer in a JS closure | Column B local / sessionStorage the baseline everyone agrees is bad | Column C httpOnly cookie + BFF no bearer credential in the browser at all |
|---|---|---|---|
| 01Read the credential out of the page | GRANTEDPatch the app's own code, or trigger silent-renew and read the response [2] | GRANTEDOne line. "A single Cross Site Scripting can be used to steal all the data in these objects" [4] | DENIEDhttpOnly hides the value; there is no token in JS reach [1] |
| 02Exfiltrate the credential off-origin | GRANTEDOne beacon and the bearer token is on attacker infrastructure [1] | GRANTEDSame, minus the effort [4] | DENIED"They can't exfiltrate tokens for offline use" [3] |
| 03Act as the user. Do anything the admin can do. | GRANTEDIt is the user's page and the user's token. | GRANTEDIt is the user's page and the user's token. | GRANTEDSession riding. "An attacker can make the victim perform any action within the application" [6] |
| 04Keep working after the tab closes | GRANTEDFor the token's TTL — or indefinitely with a stolen refresh token [1] | GRANTEDSame | DENIEDThe script dies with the page [3] |
| 05Survive a page reload | DENIEDUnless the injection is stored/persistent | GRANTEDA fresh payload re-reads the persisted token | DENIEDUnless the injection is stored/persistent |
| 06Mint new tokens | GRANTEDSilent-renew in a hidden iframe [2] | GRANTEDSame | DENIEDThe BFF is a confidential client; the browser cannot complete a flow [1] |
| 07Reach the refresh token | CONDITIONALDepends on your implementation — and in practice, ⚠ often yes | CONDITIONAL⚠ Often yes. "Only recommended for low-security use cases" [16] | DENIEDIt never leaves the server [11] |
| 08Survive your attempt to revoke | GRANTEDA signed JWT is valid until it expires. "Log everyone out" does nothing. | GRANTEDSame | DENIED"Server can forcibly end sessions" [11] — instantly, not in 15 minutes |
| 09Operate invisibly to your logs | GRANTEDOff-origin token use happens somewhere you are not looking | GRANTEDSame | DENIEDEvery action transits the BFF as an authenticated request [1] — you can see it, rate-limit it, kill it |
| 10Reuse the credential cross-tab / cross-device | GRANTEDA bearer token is a bearer token | GRANTEDSame | DENIEDBound to the compromised browser |
| —Attack signature | SILENTOne beacon, then quiet. | SILENTOne beacon, then quiet. | LOUDN authenticated calls, through your own logs. |
| Tally — capabilities granted | 8 / 10 + 1 conditional | 9 / 10 + 1 conditional | 1 / 10 — and it is row 03 |
On Column B: sessionStorage vs localStorage is a rounding error here — both are "always accessible by JavaScript" [4]. OWASP's preference for sessionStorage is about persistence, not about XSS. In-memory (Column A) is one further notch: it strips persistence and the trivial one-liner read, forcing the attacker to hook the app's own code [2]. That is real friction against dumb commodity payloads and zero friction against anyone targeting your app. Auth0 puts it flatly: "Tokens in localStorage, sessionStorage, or even JavaScript memory are all reachable by malicious scripts" [12].
Not a wall. A transformer. Here is the exact exchange rate — and the one line it leaves untouched.
If XSS is game over regardless of storage [2], the money goes into not having XSS. Token storage is damage-limitation. Strict CSP + Trusted Types is the mitigation.
javascript: URIs" [7]. This is script-loading control.src of a nonced script; injection into the locations of dynamically-created script nodes; AngularJS template injection; unsafe-eval sinks [7]. It does not close DOM sinks.require-trusted-types-for 'script' "instructs user agents to control the data passed to DOM XSS sink functions" [10], blocking "risky injection points (like .innerHTML) from using unvalidated string values" [17]. React apps are near-compliant already; dangerouslySetInnerHTML is the sink you must route through a policy.createHTML: (input) => input is a technically valid and entirely useless policy. "The browser enforces the mechanism, but what happens inside the funnel is entirely your responsibility" [9].CSP is a policy on scripts the browser loads. It cannot distinguish your trojaned npm dependency from your own code. The nonced bundle executes with full privilege. Against 2025-style supply-chain compromise, CSP is not the mitigation [3].
The Sept 2025 npm compromise (debug, chalk, ~2B weekly downloads) proved malicious dependency code "can steal anything accessible to JavaScript." FusionAuth's argument: had those attackers gone for OAuth tokens in localStorage instead of crypto wallets, millions of user sessions would have been compromised [3].
This is where the BFF's "nothing to steal" property does genuine work that no CSP can do. Microsoft's framing is the right one: "Because attacks are composable, defenses must be layered. Treat XSS as a gateway risk, not a standalone bug" [5].
The evidence does not converge. It is worth seeing exactly where the practitioners split — and where a vendor's marketing page and its own documentation split from each other.
horsawlarwayAGAINSTAll it does is add a marginal level of complexity… still completely hacked.
unscaledFORHttpOnly cookies ARE better in terms of security. Storing Refresh Tokens in local storage is only recommended for low-security use cases…
If the browser based application is compromised, for example with XSS attacks, there is no risk of the attacker stealing the access tokens.
BFF shifts the security boundary. It doesn't eliminate it.
The published guidance is written for public consumer SPAs. These are the factors that move the BFF's XSS argument for a first-party, network-restricted, internal admin console.
For an internet-exposed console the BFF's XSS story is strong. For a network-restricted internal console with strict CSP and Trusted Types, the incremental XSS benefit over an in-memory token is narrow: it reduces to supply-chain resilience and instant revocation. Those are real. They are also a much smaller claim than "httpOnly protects you from XSS," and they should be weighed against the BFF's cost rather than played as a security trump card.
Do not buy a BFF for XSS and then skip the CSP. The ordering the sources support is unambiguous [2] [12]:
require-trusted-types-for 'script'. This is the mitigation. Everything else is triage.17 sources. The dissenting ones are in here too — this board is not a case for the prosecution.
This board answers one question. The decision needs five more.