<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Neil Mason</title>
    <description>The latest articles on DEV Community by Neil Mason (@neilpmas).</description>
    <link>https://dev.to/neilpmas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F386997%2F8427738e-8e80-4744-b8ed-a12fd1f9e09b.png</url>
      <title>DEV Community: Neil Mason</title>
      <link>https://dev.to/neilpmas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/neilpmas"/>
    <language>en</language>
    <item>
      <title>Your React app is one XSS away from a full account takeover</title>
      <dc:creator>Neil Mason</dc:creator>
      <pubDate>Tue, 28 Apr 2026 00:17:54 +0000</pubDate>
      <link>https://dev.to/neilpmas/your-react-app-is-one-xss-away-from-a-full-account-takeover-ipm</link>
      <guid>https://dev.to/neilpmas/your-react-app-is-one-xss-away-from-a-full-account-takeover-ipm</guid>
      <description>&lt;p&gt;&lt;em&gt;There's a 60-page IETF spec that explains exactly why. And a pattern that makes token theft structurally impossible.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It's not like you didn't try.&lt;/p&gt;

&lt;p&gt;You did &lt;a href="https://oauth.net/2/pkce/" rel="noopener noreferrer"&gt;PKCE&lt;/a&gt;. You read the blog posts. You moved the access token out of &lt;code&gt;localStorage&lt;/code&gt; and into memory. Maybe you even rotate refresh tokens.&lt;/p&gt;

&lt;p&gt;I'm sorry. It's not enough.&lt;/p&gt;

&lt;p&gt;The access token is still in JavaScript memory. Any XSS on your page, any compromised transitive npm dependency (&lt;a href="https://www.itnews.com.au/news/supply-chain-attack-hits-100-million-download-axios-npm-package-624699" rel="noopener noreferrer"&gt;this happens&lt;/a&gt;), 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.&lt;/p&gt;

&lt;p&gt;This isn't a hot take. From &lt;a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps" rel="noopener noreferrer"&gt;BCP212&lt;/a&gt;, the IETF's working draft on OAuth for browser-based apps:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Malicious JavaScript code has the same privileges as the legitimate application code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the spec authors saying it. Not me.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your access token lives in JS, one XSS = full account takeover, and "in-memory" doesn't save you.&lt;/li&gt;
&lt;li&gt;BCP212 (OAuth 2.0 for Browser-Based Apps) describes three patterns. Only the BFF pattern actually closes the gap.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.npmjs.com/package/bezzie" rel="noopener noreferrer"&gt;&lt;code&gt;bezzie&lt;/code&gt;&lt;/a&gt; is an open-source BFF library for Cloudflare Workers. JWTs never reach the browser.&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://bezzie-demo.neilmason.dev" rel="noopener noreferrer"&gt;bezzie-demo.neilmason.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What BCP212 actually says is on fire
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persistent token theft.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent re-authorisation.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request proxying.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The BFF pattern, concretely
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt;, &lt;code&gt;__Host-&lt;/code&gt;-prefixed session cookie. No JWT, no token, no claims. Nothing readable.&lt;/p&gt;

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

&lt;p&gt;Open DevTools on a BFF app. You'll see one cookie. There is nothing to steal because there is nothing there.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;useAuth&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fair objection:&lt;/strong&gt; XSS can still make requests &lt;em&gt;through&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxuzwukk0uo9l2qy1ord3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxuzwukk0uo9l2qy1ord3.png" alt="Auth flow sequence diagram" width="800" height="660"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  bezzie
&lt;/h2&gt;

&lt;p&gt;Meet bezzie. BFF = Backend for Frontend = Best Friend Forever. Your BFF's BFF. I'm not sorry — I like it.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createBezzie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cloudflareKVAdapter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bezzie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createBezzie&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTH0_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTH0_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTH0_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTH0_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cloudflareKVAdapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KV&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;MIT, v1.0.0 on &lt;a href="https://www.npmjs.com/package/bezzie" rel="noopener noreferrer"&gt;npm&lt;/a&gt;, source on &lt;a href="https://github.com/neilpmas/bezzie" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, demo at &lt;a href="https://bezzie-demo.neilmason.dev" rel="noopener noreferrer"&gt;bezzie-demo.neilmason.dev&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits
&lt;/h2&gt;

&lt;p&gt;Good BFF implementations exist. &lt;a href="https://duendesoftware.com/products/bff" rel="noopener noreferrer"&gt;Duende BFF&lt;/a&gt; is excellent — if you're on .NET. &lt;a href="https://github.com/auth0/nextjs-auth0" rel="noopener noreferrer"&gt;&lt;code&gt;@auth0/nextjs-auth0&lt;/code&gt;&lt;/a&gt; is solid — if you're on Next.js. Nothing portable existed for Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth.js&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clerk&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hand-rolling it&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned shipping it
&lt;/h2&gt;

&lt;p&gt;Three things bit us hard enough to be worth writing down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safari and &lt;code&gt;__Host-&lt;/code&gt; cookies on HTTP.&lt;/strong&gt; Safari silently refuses to set &lt;code&gt;__Host-&lt;/code&gt;-prefixed cookies over plain HTTP. Local dev against &lt;code&gt;http://localhost:8787&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;KV eventual consistency.&lt;/strong&gt; The first thing every Cloudflare-savvy reader asks. In practice it's a non-issue: the session is written to KV &lt;em&gt;before&lt;/em&gt; &lt;code&gt;Set-Cookie&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://bezzie-demo.neilmason.dev" rel="noopener noreferrer"&gt;bezzie-demo.neilmason.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm install bezzie&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Star the repo: &lt;a href="https://github.com/neilpmas/bezzie" rel="noopener noreferrer"&gt;github.com/neilpmas/bezzie&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;a href="https://pragmaticwebsecurity.com" rel="noopener noreferrer"&gt;pragmaticwebsecurity.com&lt;/a&gt; is where to go next.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
