DEV Community

Cover image for Your React app is one XSS away from a full account takeover
Neil Mason
Neil Mason

Posted on • Originally published at open.substack.com

Your React app is one XSS away from a full account takeover

There's a 60-page IETF spec that explains exactly why. And a pattern that makes token theft structurally impossible.


Ugh, application security. I know. But like your mum said about eating your greens, you know it's important, and one day you'll thank her for it.

It's not like you didn't try.

You did PKCE. You read the blog posts. You moved the access token out of localStorage and into memory. Maybe you even rotate refresh tokens.

I'm sorry. It's not enough.

The access token is still in JavaScript memory. Any XSS on your page, any compromised transitive npm dependency (this happens), any injected analytics script, any browser extension your user installed last Tuesday — all of it runs with full access to that token. Exfiltrate it once, and the attacker has a credential they can use from anywhere until it expires. If a refresh token is reachable from JS, they have persistence.

This isn't a hot take. From BCP212, the IETF's working draft on OAuth for browser-based apps:

Malicious JavaScript code has the same privileges as the legitimate application code.

That's the spec authors saying it. Not me.

You're here because you're better than that. So let's fix it. And I'm going to make it fast. Thirty minutes of work fast. Claude-assisted setup fast.

TL;DR

  • If your access token lives in JS, one XSS = full account takeover, and "in-memory" doesn't save you.
  • BCP212 (OAuth 2.0 for Browser-Based Apps) describes three patterns. Only the BFF pattern actually closes the gap.
  • bezzie is an open-source BFF library for Cloudflare Workers. JWTs never reach the browser.
  • Live demo: bezzie-demo.neilmason.dev

What BCP212 actually says is on fire

The threat model in BCP212 is worth reading in full (I know you won't), but the relevant parts boil down to three concrete attacks against any single-page app that holds tokens in JS:

  1. Persistent token theft. Steal the token once, use it from anywhere. If you grabbed a refresh token, you have ongoing access, even after the user closes the tab.
  2. Silent re-authorisation. Even without stealing a token, malicious JS can open a hidden iframe to the authorisation server and ride the user's existing session at the identity provider to mint a fresh one. The user sees nothing.
  3. Request proxying. The attacker doesn't need to exfiltrate anything. They sit inside the page and proxy authenticated requests through the user's browser, using the user's tokens, from the user's IP.

The spec sketches three architectural responses: tokens in JS (acknowledged as weakest), the service worker pattern, and the BFF pattern. Only the BFF pattern removes tokens from the browser entirely. The other two reduce the attack surface; the BFF eliminates the asset.

The BFF pattern, concretely

A Cloudflare Worker owns the OAuth flow end to end. It does the redirect, the code exchange, the refresh. Access and refresh tokens live in Cloudflare KV, keyed by an opaque session ID. The browser gets one thing: an HttpOnly, Secure, SameSite=Lax, __Host--prefixed session cookie. No JWT, no token, no claims. Nothing readable.

When the app calls /api/whatever, the Worker looks up the session, fetches the access token from KV, and injects Authorization: Bearer … server-side before proxying the request. Token refresh happens transparently. The client never knows it happened.

Open DevTools on a BFF app. You'll see one cookie. There is nothing to steal because there is nothing there.

There's a secondary benefit that doesn't get talked about enough: your frontend gets simpler. No OAuth library. No token storage logic. No refresh scheduling. No useAuth hook threading tokens through your component tree. Your React app makes API calls like it's 2015 — fetch, get a response, done. All the auth complexity lives in the Worker where it belongs.

A fair objection: XSS can still make requests through the session cookie. The attacker can proxy API calls from inside the victim's browser without ever seeing the token itself. BCP212 acknowledges this. The difference is that the credential stays tethered to the victim's browser. The attacker can't take it away and use it from their own machine. The moment the session expires or the user closes the tab, it's dead. A stolen JWT, by contrast, is fully portable. Valid from anywhere until expiry, with no way to invalidate it mid-flight. The BFF doesn't eliminate XSS as a risk; it eliminates credential portability. That's a meaningful reduction in blast radius, not a silver bullet.

Auth flow sequence diagram

bezzie

Meet bezzie. BFF = Backend for Frontend = Best Friend Forever. Your BFF's BFF. I'm not sorry — I like it.

bezzie is a BFF OAuth library for Cloudflare Workers. You give it your identity provider config, a KV namespace, and your app's base URL. It handles the rest — login, callback, logout, token refresh, session cookie. The setup looks like this:

import { createBezzie, providers, cloudflareKVAdapter } from 'bezzie'

const auth = createBezzie({
  ...providers.auth0(env.AUTH0_DOMAIN),
  clientId: env.AUTH0_CLIENT_ID,
  clientSecret: env.AUTH0_CLIENT_SECRET,
  audience: env.AUTH0_AUDIENCE,
  adapter: cloudflareKVAdapter(env.SESSION_KV),
  baseUrl: env.APP_BASE_URL,
})

app.route('/auth', auth.routes())
app.use('/api/*', auth.middleware())
Enter fullscreen mode Exit fullscreen mode

That's the whole integration. auth.routes() mounts /auth/login, /auth/callback, /auth/logout, /auth/me. auth.middleware() handles session lookup, token injection, and refresh on every /api/* call. Providers ship for Auth0, Okta, Keycloak, and Google. Any OIDC provider works.

MIT, v1.0.0 on npm, source on GitHub, demo at bezzie-demo.neilmason.dev.

Where it fits

Good BFF implementations exist. Duende BFF is excellent — if you're on .NET. @auth0/nextjs-auth0 is solid — if you're on Next.js. Nothing portable existed for Cloudflare Workers.

Auth.js is the right call if you're on Next.js or a Node-shaped runtime. It doesn't implement the BFF pattern (tokens still reach the client), but for most apps on most platforms it's the pragmatic choice. If you're on Vercel, use Auth.js.

Clerk is a managed identity platform. It handles the BFF pattern for you, which is great. But you're locked into Clerk's infrastructure and pricing. If you want control over your auth stack and your data, that's the tradeoff.

Hand-rolling it is absolutely doable. The BFF pattern isn't complicated in principle. In practice it's 200+ lines of careful code: PKCE, state validation, token exchange, refresh logic, cookie handling, race condition guards. bezzie is what you'd end up with after a few iterations anyway.

What we learned shipping it

Three things bit us hard enough to be worth writing down:

Safari and __Host- cookies on HTTP. Safari silently refuses to set __Host--prefixed cookies over plain HTTP. Local dev against http://localhost:8787 just… doesn't work. No error, no cookie, just an unauthenticated session loop. Use Chrome or Firefox locally, or run a local TLS proxy. We document this prominently because it cost a real afternoon.

Auth0's "Allow Offline Access" setting. If it's not enabled on the API (not the application, the API), Auth0 silently strips the offline_access scope from the request. You don't get a refresh token, and the only signal is a generic access_denied later. Took embarrassing amounts of staring at network tabs to find. There's now a check in the demo's docs.

KV eventual consistency. The first thing every Cloudflare-savvy reader asks. In practice it's a non-issue: the session is written to KV before Set-Cookie goes out, and every subsequent request reads it on the same path. We've never observed a stale-read race because the cookie can't exist before the write.

Try it

The architectural argument here isn't mine. Philippe de Ryck's YOW talk on browser-based OAuth was the thing that made me stop hand-waving about "in-memory tokens are fine, right?" and actually read BCP212. If you found this useful, his work at pragmaticwebsecurity.com is where to go next.

Top comments (0)