← Default view
POST-COMPROMISE · ASSUME BREACH · NO REASSURANCE

The Attacker
Is Already
In The Page A red-team capability board for three token-storage architectures. Every column below is drawn after the payload executes. None of them is a defence — they are three different ways to lose.

ScenarioScript executes in origin
Subjectitenium-ui admin console
Columns3 architectures
Evidence17 sources
Depthsurvey

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!
Philippe De Ryck
co-author, IETF browser-apps BCP
[2] pragmaticwebsecurity.com
01

Kill chains

Same starting condition — arbitrary script in your origin. Two entirely different second halves.

Architecture A
In-memory access token
React 19 SPA → .NET APIs directly
  1. Payload executes. It patches the app's HTTP layer, reads the token straight out of the module closure, or fires a hidden silent-renew and harvests the result [2]. "In memory" is not a hiding place from code that is the app.
  2. One 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.
  3. The token is a bearer credential: it works from the attacker's machine, from a script, from anywhere, for its full remaining lifetime [1].
  4. If a refresh token is browser-reachable, the draft's Persistent Token Theft applies and the attacker just keeps renewing [1]. The victim closing the tab changes nothing.
Loss profile SILENT · OFF-ORIGIN · OUTLIVES THE TAB · PORTABLE
Architecture B
httpOnly cookie + BFF
.NET 10 proxy in front of the APIs
  1. Payload executes. It cannot read the cookie [1], cannot see an access token (there isn't one in the browser) [11], cannot start a new OAuth flow to mint one — "the BFF is a confidential client" (§6.1.3.1) [1].
  2. So it rides the session instead. Same-origin 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].
  3. Anti-CSRF adds nothing. Same-origin script reads response bodies and lifts the token — "you can create a new variable — 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.
  4. But everything must transit the victim's browser, through your BFF, while the tab is open. "The attacker can make requests to your BFF while the user's browser is open. But they can't exfiltrate tokens for offline use" [3].
Loss profile LOUD · SAME-ORIGIN · DIES WITH THE TAB · NON-PORTABLE
02

The capability board

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
⚠ Do not read the tally as a scoreboard. Column C denies nine capabilities and grants one — but the one it grants is the one that was ever the point. Row 03 is GRANTED in every column. For an admin console, "everything the admin can do" is the console. If the payload can create a user, grant a role or plant a webhook in the first 400 ms, the tab-lifetime bound buys you exactly nothing [6].

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].

03

What httpOnly actually converts

Not a wall. A transformer. Here is the exact exchange rate — and the one line it leaves untouched.

In-memory / storage tokenSilentOne beacon to attacker infrastructure, then nothing you will ever detect.
httpOnly + BFFLoudEvery attacker action is an authenticated request through your own proxy. Observable, rate-limitable, killable.
In-memory / storage tokenOff-origin, portableWorks from the attacker's laptop, a cron job, anywhere, for the token's full TTL.
httpOnly + BFFBound to the browserNo lateral use against other APIs, no use from attacker infrastructure.
In-memory / storage tokenOutlives the tabOr outlives everything, if a refresh token came with it.
httpOnly + BFFDies with the tabAnd dies immediately on a server-side session kill.
In-memory / storage tokenAttacker acts as the userFull authority of the compromised session.
=
httpOnly + BFFAttacker acts as the userUnchanged. The BFF bounds duration and location. It does not bound authority.
04

The actual mitigation

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.

94.68%
of script-limiting CSP policies were ineffective in Google's internet-scale study; 99.34% of CSP-deploying hosts got "little to no benefit against XSS." Allowlist CSP failed empirically. If you ship an allowlist policy and call it done, you have shipped nothing. [8]
Baseline
Trusted Types is now available everywhere. Safari 26 landed it in Sept 2025; Firefox completed the picture in Feb 2026 — "it now works across all major browsers." This is the 2026 change that moves the argument. [9]
  • Strict CSP — nonce/hash + strict-dynamic Blocks "untrusted inline scripts like inline event handlers or javascript: URIs" [7]. This is script-loading control.
  • ⚠ Strict CSP's own holes Injection into the body or 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.
  • Trusted Types closes the 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.
  • ⚠ Trusted Types is not magic either 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].
⊠ The hole CSP cannot cover — and the BFF's strongest argument

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].

05

Testimony, including the dissent

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.

Hacker News · horsawlarwayAGAINST
All it does is add a marginal level of complexity… still completely hacked.
Dismisses the security case entirely; argues the real BFF benefits are API consolidation and token-scope reduction, not XSS defence. [13] news.ycombinator.com
Hacker News · unscaledFOR
HttpOnly cookies ARE better in terms of security. Storing Refresh Tokens in local storage is only recommended for low-security use cases…
The counter-view from the same discussion space. Both commenters are right about different things — the disagreement is really about whether bounded compromise is worth an architecture. [16] news.ycombinator.com
Duende — the marketing pageOVERCLAIM
If the browser based application is compromised, for example with XSS attacks, there is no risk of the attacker stealing the access tokens.
True, and narrow. Now read the same vendor's docs, which concede session hijacking is "very difficult to completely protect against." [15] duendesoftware.com · [11] docs.duendesoftware.com
Auth0 — the honest lineCALIBRATED
BFF shifts the security boundary. It doesn't eliminate it.
"You're trading one attack vector (XSS-based token theft) for a different, more constrained one." [12] auth0.com
06

Calibration — itenium-ui

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.

⚠ Weakens
Single-origin, first-party, no third-party ad / analytics / tag-manager scriptsThe dominant real-world XSS vector — untrusted third-party script — is simply absent. And strict CSP is achievable here in a way it never is on a marketing site [7].
⚠ Weakens
React 19, no dangerouslySetInnerHTML, Trusted Types now BaselineThe DOM-sink class of XSS is closable at low cost [9] [10].
Neutral
Admin privileges = maximum per-action impactSession riding is enough to do all the damage there is. The BFF bounds duration, not authority [6].
⚠ Weakens
APIs reachable only from the corporate network / VPNThis kills the exfiltration delta specifically. A stolen bearer token used from attacker infrastructure fails at the network boundary — the attacker is pushed back into session riding anyway, which is precisely what the BFF was selling you.
⚠ Strengthens
npm supply chainThe one argument that survives every other mitigation, including a perfect CSP [3].
Strengthens
Small user population, internal auditing, ability to force logoutServer-side revocation is actually operable here, which it rarely is at consumer scale [11].
07

Assessment

The BFF does not stop XSS. It bounds the loss — and the bound is narrower than it is sold.

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]:

1
Eliminate the XSS
Strict CSP (nonce + strict-dynamic) and require-trusted-types-for 'script'. This is the mitigation. Everything else is triage.
2
Then choose storage
As damage-limitation, knowing exactly which capabilities on the board it removes — and which one it never touches.
3
Price the supply chain
The trojaned dependency is the threat CSP cannot see and the BFF genuinely blunts. That, not "httpOnly," is the argument to make.
09

Related dossiers

This board answers one question. The decision needs five more.

Threat Matrix — an alternate view of the canonical survey · 17 sources · Atlas
Assume breach. Row 03 is granted in every column.