For years we treated the backend as a fortress and the browser as a friendly assistant. That worked when most attacks came from outside. It does not work anymore.
The browser runs on someone else’s machine. It runs your code next to extensions, ad scripts, and whatever else the user installed. Once you accept that, the security question changes. Instead of “how do we keep attackers out”, it becomes “what happens when an attacker is already inside the page”.
That mindset has a name: Zero Trust. The short version is, do not assume any environment is safe. Verify every request, every script, every token, every time.
Why “trust the browser less” became the default
Two things changed.
The first is supply chain attacks. A library you depend on gets compromised. Now your app ships malicious code, signed by you, running with full access to the page. The user did nothing wrong. Your CI did nothing wrong. The code is just bad.
The second is XSS that survives every framework. Modern frameworks escape output by default, which helps. But one careless dangerouslySetInnerHTML, one third-party widget, one stale dependency, and a script can read whatever the page can read. Including your auth tokens.
So the question is not “can we prevent every XSS”. The question is “when an XSS happens, how much damage can it do”.
Bearer tokens, the movie ticket problem
Most apps today use bearer tokens. The name is honest. Whoever bears the token can use it. The server does not check who you are, it checks what you have.
Think of it like a movie ticket. The cinema does not care if you are the person who bought it. They care that you have a valid ticket. If you drop it in the parking lot and someone picks it up, that person gets in.
A stolen bearer token is a stolen session. If a malicious script reads your localStorage and finds an access token, it can call your API as you, from anywhere, until the token expires. There is no second factor. The token is the factor.
This is the part that breaks people’s intuition. The token is not “tied” to anything. Not the device, not the browser, not the IP. It is just a string the server trusts.
DPoP in plain words
DPoP stands for Demonstrating Proof of Possession. The standard is short. The idea is even shorter.
Each device generates its own key pair. The private key never leaves the device. When the browser makes a request, it signs that specific request with the private key, and sends the signature alongside the token. The server checks two things: the token is valid, and the signature was made by the key tied to this token.
Now stealing the token alone is not enough. To use it, an attacker also needs the private key. And the private key lives in non-extractable storage (CryptoKey with extractable: false, or hardware-backed keys on some platforms). A script reading the page cannot copy it.
DPoP does not fix XSS. It changes what an XSS can do. An attacker who runs code in your page can still call your API while the user is on the page, but they cannot steal a session and use it later from their own machine. That is a real downgrade in attacker capability.
BFF in plain words
DPoP raises the bar. The Backend For Frontend pattern, usually shortened to BFF, removes the problem entirely.
The setup looks like this. You run a small backend that sits between the browser and your real API or your OAuth provider. The browser talks only to that small backend. The browser never sees an OAuth access token. Not in localStorage, not in memory, not anywhere.
What the browser holds is a session cookie. The cookie is HttpOnly, so JavaScript cannot read it. It is Secure, so it only travels over HTTPS. It is SameSite=Strict or Lax, so it does not leak to other sites. The cookie value is opaque, usually an encrypted reference.
When the user does something, the browser sends the cookie to the BFF. The BFF looks up the real OAuth token on the server side, calls the upstream API, and returns the result. The token never crosses into the browser.
The trade is simple. You add one extra hop and a tiny stateful service. You delete an entire category of attacks (token theft via JS) in exchange.
What we usually recommend
For most apps, default to BFF. If your team controls a backend or an edge function, there is no good reason to put OAuth access tokens in the browser. The “pure SPA with tokens in localStorage” pattern made sense when serverless was hard. It is not hard anymore.
Use DPoP when you genuinely cannot have a backend, or when you need bearer tokens for a public API. It is the right answer for those cases. It is not the right default.
Layer Content Security Policy on top. CSP is the difference between “an XSS happened” and “an XSS happened and the script could not actually do anything useful”. Our CSP guide walks through a strict policy you can ship.
A short checklist for the common case:
- OAuth tokens live on the server, behind a BFF.
- Browser only holds a session cookie,
HttpOnlyplusSecureplusSameSite. - Strict CSP with nonces, no
unsafe-inline, no wildcard sources. - If you must hold tokens in the browser, use DPoP, not raw bearer.
- Audit your real headers in production, not just locally. We use our SEO Expert tool to spot regressions.
The goal is not to make XSS impossible. The goal is to make a successful XSS boring.