A lot of CORS bugs are really OAuth2 architecture bugs wearing a fake mustache.
I’ve seen teams spend days tweaking Access-Control-Allow-Origin headers when the real problem was simpler: they were trying to run the wrong OAuth2 flow in the browser, or they expected the browser to carry cookies and tokens across origins in ways it never will.
Here’s a case study based on a very normal setup:
- frontend:
https://app.example.com - API:
https://api.example.com - auth server:
https://auth.example.com
The team had a React SPA talking directly to the API. They wanted users to click “Login with OAuth”, get redirected to the auth server, come back with a session, and then call the API with fetch().
Sounds routine. It broke in three different ways.
The original setup
The frontend tried to do two things from JavaScript:
- start OAuth by calling the auth server with
fetch() - call the API with cookies after login
Their client code looked like this:
async function login() {
const res = await fetch("https://auth.example.com/oauth/authorize?client_id=spa&response_type=code&redirect_uri=https://app.example.com/callback", {
credentials: "include"
});
const data = await res.json();
console.log(data);
}
And API calls looked like this:
async function loadProfile() {
const res = await fetch("https://api.example.com/me", {
credentials: "include"
});
return res.json();
}
On the backend, they “fixed CORS” like this:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, OPTIONS
This is one of those configurations that shows up in incident reviews and Stack Overflow answers for all the wrong reasons.
Pitfall #1: Trying to start OAuth with fetch()
The first bug was conceptual. OAuth authorization is a browser navigation flow, not an AJAX flow.
When you hit /oauth/authorize, the server usually responds with redirects, login pages, consent pages, MFA prompts, and cookies. That flow is meant to happen in the top-level browser window. Trying to drive it with fetch() turns a user navigation into a cross-origin XHR, which means CORS kicks in immediately.
That leads to errors like:
- blocked by CORS policy
- redirect not allowed for preflight request
- credentialed request failed due to wildcard origin
The fix was to stop using fetch() for authorization initiation.
Before
await fetch("https://auth.example.com/oauth/authorize?client_id=spa&response_type=code&redirect_uri=https://app.example.com/callback", {
credentials: "include"
});
After
const params = new URLSearchParams({
client_id: "spa",
response_type: "code",
redirect_uri: "https://app.example.com/callback",
scope: "openid profile email",
state: crypto.randomUUID(),
code_challenge: "...",
code_challenge_method: "S256"
});
window.location.href = `https://auth.example.com/oauth/authorize?${params}`;
That single change removed most of the CORS noise because browser navigations are not governed by CORS the same way XHR and fetch() are.
If you’re building a SPA, the browser should navigate to the authorization endpoint. Don’t AJAX your way into an OAuth flow.
Official docs worth reading:
- MDN CORS
- OAuth 2.0
- OAuth 2.0 for Browser-Based Apps
For newer browser guidance, also check the OAuth working group docs in the IETF set.
Pitfall #2: Wildcard origin with credentials
The second bug was pure CORS misconfiguration.
They had:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers reject this. If credentials: "include" is used, the server must return a specific origin, not *.
This is where people get confused because public APIs often do use wildcard CORS. GitHub is a good example. Real headers from api.github.com include:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That works because GitHub’s public API is generally designed for cross-origin reads without browser cookies. Wildcard CORS is fine for public, non-credentialed resources.
Your authenticated API is different.
Before
fetch("https://api.example.com/me", {
credentials: "include"
});
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
After
Reflect only trusted origins:
const allowlist = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowlist.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
Two details matter here:
Vary: Originprevents cache poisoning and wrong-origin cache reuse- only trusted origins get echoed back
I’m opinionated about this: never “reflect whatever Origin came in” unless you like turning your API into a cross-origin data faucet.
Pitfall #3: Confusing cookies, tokens, and CORS
After fixing login initiation and CORS headers, the team still had intermittent auth failures. Why? Their auth server set a cookie, but the API lived on another subdomain and expected that cookie to be present cross-site.
That gets messy fast because now you’re depending on:
- cookie
Domain SameSiteSecure- browser privacy behavior
- credentialed CORS on every API response
They were effectively trying to use a browser session cookie as a distributed auth mechanism across origins. It sort of worked in one browser, failed in another, and broke during local testing.
The cleaner fix was to separate concerns:
- use OAuth2 Authorization Code Flow with PKCE in the browser
- exchange the code on a backend-for-frontend or token endpoint designed for the client
- send bearer tokens to the API in the
Authorizationheader - use CORS for API access, not as part of login state propagation
Before
fetch("https://api.example.com/me", {
credentials: "include"
});
After
const token = sessionStorage.getItem("access_token");
const res = await fetch("https://api.example.com/me", {
headers: {
Authorization: `Bearer ${token}`
}
});
And server-side:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Methods: GET, POST, OPTIONS
Vary: Origin
Notice what disappeared:
- no
Access-Control-Allow-Credentials - no cross-site session cookie dependency for the API
- no wildcard/credentials conflict
This is usually the point where systems get dramatically simpler.
Pitfall #4: Missing exposed headers
The team also needed rate-limit info and pagination metadata from the API. They could see headers in the network tab, but JavaScript couldn’t read them.
Classic CORS gotcha.
Browsers only expose a limited set of response headers to JavaScript unless you explicitly allow more with Access-Control-Expose-Headers.
GitHub does this well. Their real access-control-expose-headers includes operational headers developers actually need:
ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s a good real-world model.
Before
const res = await fetch("https://api.example.com/repos");
console.log(res.headers.get("X-RateLimit-Remaining")); // null
console.log(res.headers.get("Link")); // null
After
Server:
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
Client:
const res = await fetch("https://api.example.com/repos", {
headers: {
Authorization: `Bearer ${token}`
}
});
console.log(res.headers.get("X-RateLimit-Remaining"));
console.log(res.headers.get("Link"));
If your frontend needs pagination, deprecation, or quota metadata, expose it deliberately.
The final architecture
What finally worked was boring, which is usually a good sign.
- SPA redirects browser to
https://auth.example.com/oauth/authorize - user authenticates there
- auth server redirects back to
https://app.example.com/callback?code=...&state=... - SPA completes Authorization Code + PKCE flow
- SPA calls
https://api.example.comwith bearer token - API returns narrow CORS policy for trusted frontend origins
That setup removed the weirdest browser behaviors because each piece was doing the job it was designed for:
- browser navigation for login
- tokens for API auth
- CORS for controlled cross-origin reads
- exposed headers for metadata
The checklist I use now
When CORS and OAuth2 collide, I check these first:
- Are we using
fetch()for/authorize? That’s usually wrong. - Are we mixing
Access-Control-Allow-Origin: *with credentials? Browsers will block it. - Are we depending on cross-site cookies for API auth? Expect pain.
- Do we actually need cookies, or should we use bearer tokens?
- Are preflight requests handled cleanly for
Authorization? - Did we set
Vary: Originwhen returning per-origin CORS headers? - Do we expose headers the frontend needs?
And one more thing: CORS is not CSRF protection, and it’s not an auth mechanism. It’s a browser enforcement layer for cross-origin reads. If you start treating it like identity, session management, or trust by itself, the design usually goes sideways.
If you’re tightening the rest of your response headers too, the broader hardening story matters as well. For that side of things, CSP Guide is a useful reference.