Use case — CORS for SPA
What is a "SPA" and why does it need CORS?
A Single-Page Application (SPA) is a JavaScript front-end (React, Vue, Svelte, ...) that runs entirely in the browser and talks to APIs via fetch(). Two things make it different from a server-rendered RP:
- It cannot keep a
client_secret— anyone can read the JS bundle. So a SPA is a public client and uses PKCE (RFC 7636) to protect the authorization code in lieu of a secret. - It is typically served from a different origin to the OP (
app.example.comvsop.example.com). Browser CORS (Fetch spec, "Cross-Origin Resource Sharing") then requires the OP to explicitly allow the SPA's origin on every endpoint the SPA calls from JavaScript.
This page covers the CORS layer. PKCE itself is covered in Authorization Code + PKCE.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 Authorization Framework
- RFC 7636 — PKCE (Proof Key for Code Exchange)
- RFC 8252 — OAuth 2.0 for Native Apps, §8.1 (browser-side public clients)
- Fetch Standard (WHATWG) — defines CORS / preflight semantics
- RFC 6265bis — Cookies,
SameSite,__Host-prefix
Vocabulary refresher
- Origin — The triple
(scheme, host, port)—https://app.example.com:443andhttps://app.example.com:8443are different origins. The browser's same-origin policy isolates pages from each other; CORS is the controlled escape hatch. - CORS preflight — For non-trivial XHR (
Authorizationheader, custom headers, methods other than GET/HEAD/POST), the browser first sends anOPTIONSrequest asking the server "may this origin make this request?". If the server's headers allow it, the actual request follows. The OP responds to preflight on every CORS-enabled endpoint without bothering your app code. - Public vs confidential client — A public client (a SPA, a mobile app) cannot keep a secret — anyone can read the bundle. A confidential client (a backend) can. Public clients must use PKCE on the authorization-code flow because the secret-equivalent (the code) would otherwise be unprotected on the wire.
Source:
examples/14-cors-spa
What the OP allows
Two layers compose:
- Static allowlist —
op.WithCORSOrigins("https://app.example.com", ...) - Auto-derived allowlist — every registered client's
redirect_uriscontributes its origin automatically. SoWithStaticClientsalready gets you most of the way.
Code
op.New(
/* required options */
op.WithCORSOrigins(
"https://app.example.com", // production SPA
"https://staging.example.com",
"http://localhost:5173", // local dev — Vite default
),
op.WithStaticClients(op.PublicClient{
ID: "spa-client",
RedirectURIs: []string{"https://app.example.com/callback"},
Scopes: []string{"openid", "profile"},
/* ... */
}),
)Both static and auto-derived origins land on the same internal list; duplicates are de-duped.
What endpoints get CORS headers
| Endpoint | CORS-enabled? | Why |
|---|---|---|
/.well-known/openid-configuration | ✅ | RPs and SPAs fetch it. |
/jwks | ✅ | RPs and SPAs verify ID Tokens. |
/userinfo | ✅ | SPAs call it with a Bearer token. |
/interaction/* (when SPA driver is in use) | ✅ | The SPA polls these. |
/session/* | ✅ | SPA reads session state. |
/authorize | ❌ | Browser navigation, not XHR. |
/token | ✅ | Browser public clients can redeem code+PKCE with fetch; backend RPs ignore the CORS headers. |
/par, /introspect, /revoke, /register | ✅ when mounted | Strict CORS is available for admin consoles and browser-based harnesses, while the protocol auth checks still decide admission. |
/bc-authorize, /device_authorization, /end_session | ✅ when mounted | Usually server-side or navigation flows, but wrapped for tooling and explicit browser integrations. |
credentialed XHR
The CORS layer sets Access-Control-Allow-Credentials: true so the SPA's fetch(url, { credentials: 'include' }) can carry the OP's session cookie. The browser also requires Access-Control-Allow-Origin to be a specific origin (not *); the library honors this — every match is the requesting origin verbatim, never a wildcard.
Public client + PKCE
A SPA is a public client (token_endpoint_auth_method=none). It cannot keep a client_secret. It uses PKCE to compensate:
op.WithStaticClients(op.PublicClient{
ID: "spa-client",
RedirectURIs: []string{"https://app.example.com/callback"},
Scopes: []string{"openid", "profile"},
GrantTypes: []string{"authorization_code", "refresh_token"},
})op.PublicClient is the typed seed for SPAs / native apps; it sets token_endpoint_auth_method=none and public_client=true automatically so the embedder cannot accidentally ship a SPA with confidential auth. The OP's strict CORS wrapper covers /token, so an allowlisted SPA can perform the PKCE code exchange from JavaScript without a separate proxy.
The OP refuses code_challenge_method=plain for any client — S256 only — so the SPA's PKCE is real PKCE, not the legacy variant.
OP-issued cookies and cross-site
Even with CORS allowing the origin, the OP's session cookie is set on op.example.com — that's where it lives. The SPA on app.example.com cannot read or write it directly. The session cookie's job is to keep the user signed in at the OP across /authorize redirects; the SPA's session lives in the SPA's own cookie or storage.
Per-endpoint CORS guidance
The previous table records which endpoints the library wraps with the strict CORS handler. That is an upper bound — every wrapped endpoint can answer a cross-origin request when the origin is allowlisted. The lower bound — which endpoints actually need CORS for a typical deployment — is narrower and depends on who calls the endpoint from a browser.
The allowlist is the union of two sources: explicit entries from WithCORSOrigins and the origin of every redirect_uri registered through the client store. So registering a SPA client with RedirectURIs: []string{"https://app.example.com/callback"} already contributes https://app.example.com to the CORS allowlist; WithCORSOrigins is for the cases where a browser-side origin does not appear as a redirect_uri (a separate admin SPA, a status page, a localhost dev origin).
| Endpoint | Does a browser typically call it? | CORS needed in practice? |
|---|---|---|
/authorize | No — full-page redirect, never XHR. | No. |
/token | Yes for SPAs (PKCE code exchange via fetch). | Yes for SPAs; no for backend RPs. |
/userinfo | Yes for SPAs (Authorization: Bearer from JS). | Yes for SPAs. |
/jwks | Yes — RP SDKs running in the browser fetch it. | Yes (commonly). |
/.well-known/openid-configuration | Yes — discovery via fetch is widespread. | Yes. |
/introspect | No — RFC 7662 is RS → OP, server-to-server. | Usually no. |
/revoke | Maybe — only if the SPA itself initiates revocation. | Depends on the SPA design. |
/par | No — RP backend pushes the auth request. | No. |
/bc-authorize | No — CIBA initiation is server-side. | No. |
/device_authorization | No — device-flow start is server-side. | No. |
/register (Dynamic Client Registration) | No by default. | Only if you intentionally expose DCR to a SPA. |
/end_session | No — full-page redirect or back-channel. | No. |
The library wraps several "no" endpoints with strictCORS anyway because the cost of wrapping is low and it future-proofs against tooling that does call them from JS (admin consoles, browser-based test harnesses). You don't have to add the origin to your allowlist if you don't intend to use them from the browser; without an allowlist match the strict layer responds with no Access-Control-Allow-* headers and the browser refuses the response — which is the correct outcome.
Audit signal for cross-origin traffic
The strict CORS layer emits op.AuditCORSPreflightAllowed (cors.preflight.allowed) every time it admits an OPTIONS preflight from an allowlisted origin. SOC dashboards that observe only the actual (non-preflight) request can otherwise miss cross-origin activity entirely, because the preflight is short-circuited at 204 before any inner handler runs. Treat this event as a baseline for "legitimate CORS traffic": a sudden absence on a normally-busy endpoint usually points at a misconfigured WithCORSOrigins change, while a sudden burst from an unfamiliar origin is worth checking against your redirect_uris registry.
Pitfalls when wiring the SPA
credentials: 'include'requires a specific origin, not*. Browsers refuse to send cookies on a CORS request whose response carriesAccess-Control-Allow-Origin: *. The library never returns*for an allowlisted origin — the strict layer echoes the requestingOriginverbatim and pairs it withAccess-Control-Allow-Credentials: true. The SPA can therefore callfetch(url, { credentials: 'include' })against/userinfoand the browser will accept the response. If you see "credentials flag is true, but Access-Control-Allow-Credentials is not 'true'" in DevTools, the request hit the public-CORS profile (/jwks,/.well-known/openid-configuration) — those endpoints intentionally serve*and do not allow credentials, because no browser-side caller needs cookies for them.- Wildcard origins are a configuration mistake, not a knob.
WithCORSOriginsvalidates each entry as a real origin and rejects*at boot — there is no "allow everything" setting. Make the list explicit: every SPA host, every staging host, every localhost dev port. Embedders frequently forget the:5173port for Vite or the:3000port for Next.js dev — the browser treats a different port as a different origin, so each must appear separately. - A redirect URI registered as
http://localhost:5173/callbackcontributeshttp://localhost:5173to the allowlist automatically. You don't need to repeat it inWithCORSOrigins. Repeating it is harmless (duplicates are de-duped) but adds noise. - The OP's session cookie lives on the OP's origin, not the SPA's. CORS allows the SPA to receive a response, but it does not let the SPA read or write
op.example.comcookies. The SPA's own session state belongs in the SPA's storage — see "OP-issued cookies and cross-site" above.