Browser-Based Apps (SPAs)

This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against.

OAuth 2.1 is still an Internet-Draft (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A single-page application (SPA) is code running inside a browser tab, which is a fundamentally different trust environment from the native-app client shape covered on Native and Mobile Apps. RFC 10017, also published as BCP 212, is the OAuth working group’s Best Current Practice for exactly this environment, and its starting point is a plain statement of the browser threat model that everything else on this page follows from: cross-site scripting (XSS) is game over for any token reachable from JavaScript.

The browser threat model

A browser tab runs whatever JavaScript the page loads — the application’s own code, every third-party library it imports, every analytics or ad-tech snippet on the page, and, if the application has an XSS vulnerability anywhere on its origin, an attacker’s injected code too. All of that code shares one execution context and one origin’s worth of storage. There is no per-script sandboxing inside a single origin: a vulnerability in a single dependency, or a single unescaped user-controlled string rendered into the DOM, gives an attacker the same JavaScript execution rights as the application’s own code.

This matters specifically because of what that means for a token:

  • Anything stored in localStorage, sessionStorage, or an in-memory JavaScript variable is readable by any script running on that origin — including an attacker’s injected script. There is no access-control layer between "the application’s code" and "an XSS payload" once both execute in the same page.

  • A token an attacker’s script can read, it can also exfiltrate — typically with a single fetch() call to an attacker-controlled endpoint, taking milliseconds and leaving no trace in the application’s own logs.

  • This is true regardless of how the token was obtained — authorization code + PKCE gets a token to the browser just as securely as any other flow can, but once that token sits in browser-reachable storage, its fate depends entirely on the page remaining free of XSS for as long as the token remains valid.

RFC 10017’s core design principle follows directly: the smaller the surface of browser-reachable code that can touch a token, the smaller the blast radius of an XSS bug. Every recommendation below is a way of shrinking that surface, in decreasing order of how far it goes.

No storage location and no amount of hardening makes a token safe to keep reachable from JavaScript on a page with an active XSS vulnerability. The mitigations on this page reduce the blast radius and the window of exposure; the only complete fix for a token exposed to page JavaScript is not exposing it there in the first place, which is what the backend-for-frontend pattern below achieves.

RFC 10017’s headline recommendation is to give the SPA a same-site backend of its own — a backend-for-frontend — and to run the entire OAuth exchange there, so that no OAuth token is ever sent to, or reachable from, the browser’s JavaScript at all. The browser holds only an ordinary session cookie.

How it works

  1. The browser navigates to the BFF’s own login endpoint (a plain link or form submission, not a fetch() call).

  2. The BFF — a confidential client, because it runs server-side and can hold a secret — performs the full authorization code + PKCE exchange with the authorization server, exactly as described on Authorization Code and PKCE.

  3. The BFF stores the resulting access token and refresh token server-side — in its own session store, never sent to the browser.

  4. The BFF establishes a browser session using an ordinary Set-Cookie response: HttpOnly, Secure, SameSite=Lax (or Strict), referencing only an opaque session identifier.

  5. Every subsequent API call from the SPA goes to the BFF, authenticated by that session cookie; the BFF looks up the corresponding access token server-side and forwards (or proxies) the call to the actual resource server, attaching the token itself.

GET /bff/api/orders HTTP/1.1
Host: app.example.com
Cookie: bff_session=8f14e45fceea167a5a36dedd4bea2543
HTTP/1.1 200 OK
Content-Type: application/json

{ "orders": [ ... ] }

The browser never sees access_token, refresh_token, or an Authorization header at any point in this exchange — the cookie is the only credential JavaScript on the page could even attempt to read, and marking it HttpOnly removes even that.

Because the BFF is a confidential client running server-side, it can use any of the client-authentication methods on Client Credentials and Client Authentication — private_key_jwt or mTLS client authentication are natural fits alongside the authorization code exchange, since the BFF is exactly the kind of server-side component those methods assume.

The token-mediating backend variant

A lighter-weight variant of the BFF keeps the same session-cookie-only contract with the browser, but instead of proxying every API call itself, the backend’s job is narrowed to token mediation: it performs the OAuth exchange and stores tokens server-side exactly as above, but exposes a same-origin endpoint the SPA calls to retrieve a short-lived, narrowly-scoped token for the SPA to attach itself to a specific, trusted downstream call — rather than proxying every API response through the backend. This trades some of the full BFF’s isolation (the token now briefly exists in the browser, for the lifetime of that one call) for reduced backend proxying work, and is a reasonable middle ground when the full proxy is architecturally inconvenient, provided the token handed out is scoped and short-lived enough that its brief browser exposure is an acceptable residual risk.

The browser-only variant, and its residual risks

Not every SPA can stand up a same-site backend — a purely static site hosted on a CDN with no server component is a common, legitimate case. RFC 10017 documents a browser-only variant for exactly this situation, built from authorization code + PKCE with several additional hardenings layered on:

  • Refresh-token rotation. Every use of a refresh token issues a new one and invalidates the old, so a stolen refresh token has a single, narrow window of usability — see Access and Refresh Tokens for the full rotation and reuse-detection mechanics.

  • Reuse detection. If a refresh token that has already been rotated away is presented again, the authorization server treats this as a signal of theft and revokes the entire token family, not just the one request.

  • Sender-constraining (DPoP). Binding the access token (and ideally the refresh token) to a private key that never leaves the browser, so a token captured by XSS is still useless without the corresponding proof — see Sender-Constrained Tokens: DPoP and mTLS and the DPoP-in-the-browser mechanics below.

Even with all of that, RFC 10017 is explicit that the browser-only variant carries residual risk that the BFF pattern does not: for the lifetime of an access token, that token (or, with DPoP, the proof of possession for it) is reachable from any script executing on the page. Rotation and reuse detection shrink the window an attacker has and limit the damage from a stolen refresh token; sender-constraining makes a bare stolen access token useless on its own. None of the three removes the browser’s fundamental property that JavaScript on the page can, in principle, do anything the page’s own code can do. Choose the browser-only variant deliberately, with those residual risks accepted and documented, not as a default.

Backend-for-frontend Browser-only

Token ever reachable from page JavaScript

No

Yes, for the access token’s lifetime (mitigated by DPoP)

Requires a same-site backend component

Yes

No

XSS impact if it occurs

Session cookie theft only (still serious, but no OAuth token exposure)

Potential access-token theft; refresh-token theft mitigated by rotation + reuse detection

Best fit

Any SPA that can stand up a lightweight backend

Static sites with no server component, where the residual risk is accepted

localStorage vs. sessionStorage vs. cookies

For whichever variant is in play, and for the token-mediating backend’s brief in-browser token in particular, where a value is stored matters:

Storage Reachable from page JavaScript Notes

localStorage

Yes, by any script on the origin

Persists across tabs and browser restarts. The worst choice for a token: maximal JavaScript reachability and the longest persistence, so a single XSS bug can harvest it long after the vulnerable page has closed.

sessionStorage

Yes, by any script on the origin

Scoped to one tab and cleared when it closes, which narrows the window somewhat, but is exactly as reachable by an XSS payload running in that tab as localStorage is.

HttpOnly + Secure + SameSite cookie

No — the HttpOnly flag removes document.cookie access entirely

The only one of the three JavaScript cannot read at all. Secure restricts it to HTTPS; SameSite=Lax or Strict blocks it from being sent on most cross-site requests, which is the BFF pattern’s actual defence against both XSS-driven token theft and CSRF against the session.

This table is the concrete reason the BFF pattern’s browser-side artefact is a cookie and not a token in storage: it is the one option on this list JavaScript genuinely cannot read.

CORS on the token endpoint

A browser-only SPA calling /token directly from JavaScript needs the authorization server’s token endpoint to answer cross-origin requests correctly. The authorization server must respond to the SPA’s origin with Access-Control-Allow-Origin naming that exact origin (never a wildcard * for an endpoint that accepts credentials or issues tokens) and, since the token request is a POST with a Content-Type that typically triggers a CORS preflight, must handle the OPTIONS preflight correctly:

OPTIONS /token HTTP/1.1
Host: as.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type

A BFF-fronted SPA avoids this entirely for the OAuth exchange itself, since the BFF talks to the token endpoint server-side with no browser-imposed CORS rules in play — one more piece of complexity the BFF pattern removes rather than merely mitigates.

DPoP in the browser: the non-extractable CryptoKey

Sender-constraining a browser-held token with DPoP (RFC 9449, detailed on Sender-Constrained Tokens: DPoP and mTLS) needs a private key that the DPoP proof can be signed with but that page JavaScript can never extract and exfiltrate — otherwise sender-constraining buys nothing against exactly the XSS threat it exists to blunt.

The Web Cryptography API’s SubtleCrypto.generateKey() supports exactly this: a key pair generated with extractable: false can be used by the page’s own code to sign a DPoP proof, but the private key’s raw material can never be exported, serialized, or read out of the CryptoKey object by any script — including an attacker’s injected one:

const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  /* extractable */ false,
  ["sign", "verify"]
);
// keyPair.privateKey can sign DPoP proofs; its key material
// can never be exported, even by the page's own code.

This is what makes DPoP meaningfully stronger than a bearer token in a browser-only SPA: even a script with full read access to the page’s JavaScript state cannot walk away with the private key itself, only with whatever it can trick the legitimate signing code into signing while it is still running — a materially smaller and more detectable attack than "copy this string and use it from anywhere, indefinitely."

CSP as defence in depth

A carefully authored Content-Security-Policy header does not fix the underlying architecture, but it meaningfully narrows the paths an XSS payload has to reach a token or exfiltrate it in the first place:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'none';

script-src 'self' (with no unsafe-inline and no unsafe-eval) blocks the single most common XSS delivery mechanism — an attacker-injected inline <script> tag simply will not execute. connect-src restricts which origins the page’s own fetch()/XMLHttpRequest calls can reach, which limits where a successfully injected script could even attempt to exfiltrate a stolen value to. CSP is defence in depth, not a substitute for avoiding token exposure in the first place — treat it as one more layer alongside the BFF pattern or the browser-only hardenings above, never as a reason to skip them.

Diagram: BFF topology vs. browser-only topology

flowchart TB subgraph BFF["Backend-for-frontend topology"] direction LR browser1["Browser\n(session cookie only,\nHttpOnly + Secure + SameSite)"] bff["BFF\n(confidential client,\nholds access + refresh tokens)"] as1["Authorization server"] rs1["Resource server"] browser1 -->|"cookie"| bff bff -->|"authorization code + PKCE"| as1 bff -->|"token attached server-side"| rs1 end subgraph Direct["Browser-only topology"] direction LR browser2["Browser\n(holds access token,\nDPoP-bound; rotated refresh token)"] as2["Authorization server"] rs2["Resource server"] browser2 -->|"authorization code + PKCE"| as2 browser2 -->|"DPoP-bound access token"| rs2 end

In the BFF topology, no arrow reaching the browser carries an OAuth token at all. In the browser-only topology, the browser itself holds and presents the token directly — which is exactly the difference this whole page has been about.