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:

  1. start OAuth by calling the auth server with fetch()
  2. 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:

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: Origin prevents 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
  • SameSite
  • Secure
  • 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 Authorization header
  • 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.

  1. SPA redirects browser to https://auth.example.com/oauth/authorize
  2. user authenticates there
  3. auth server redirects back to https://app.example.com/callback?code=...&state=...
  4. SPA completes Authorization Code + PKCE flow
  5. SPA calls https://api.example.com with bearer token
  6. 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: Origin when 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.