CORS and API versioning tend to collide in ugly ways once an API leaves the whiteboard and hits browsers, CDNs, mobile clients, and a few years of “temporary” backwards compatibility.

I’ve seen teams treat them as separate concerns: versioning is for API design, CORS is for frontend access. That split works right up until you ship v2, your browser app starts sending different headers, preflights spike, and suddenly half your cross-origin traffic is failing for reasons no one can reproduce with curl.

Here are the mistakes I see most often, and the fixes that actually work.

Mistake 1: Versioning the API but forgetting CORS behavior changed

A common setup is:

  • https://api.example.com/v1/...
  • https://api.example.com/v2/...

The backend team rolls out v2, copies most handlers, and forgets to copy the CORS policy. v1 works in the browser. v2 fails only in frontend environments.

Usually the bug looks like this:

  • v1 allows Authorization
  • v2 forgot it in Access-Control-Allow-Headers
  • browser sends a preflight for Authorization
  • preflight gets rejected
  • app reports a vague network error

Bad

OPTIONS /v2/users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST

That response is missing Access-Control-Allow-Headers: authorization.

Fix

Treat CORS policy as versioned API behavior. If your new version changes required request headers, methods, or response headers, update CORS config in the same pull request.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

If you run a gateway, centralize this instead of duplicating it in every versioned service.

Mistake 2: Using custom version headers without understanding preflight cost

A lot of APIs use header-based versioning:

Accept: application/vnd.example.v2+json

or:

API-Version: 2

That can be clean. It can also trigger more CORS preflights than teams expect.

Browsers preflight requests when they stop being “simple.” A custom header like API-Version guarantees that. If your app is chatty, this adds latency fast.

Bad frontend

fetch("https://api.example.com/users", {
  headers: {
    "API-Version": "2",
    "Authorization": `Bearer ${token}`
  }
});

Now you’re preflighting because of Authorization anyway, but I’ve also seen public endpoints use custom version headers and accidentally turn cacheable GETs into preflight-heavy traffic.

Fix

Be deliberate about the versioning strategy.

If your browser clients are the main consumers, path versioning is often the least surprising option:

fetch("https://api.example.com/v2/users");

If you need media-type versioning, make sure your CORS config explicitly allows the headers you require:

Access-Control-Allow-Headers: Authorization, Accept, Content-Type, API-Version

And if you use Accept for versioning, test it in actual browsers, not just API tools.

Mistake 3: Forgetting exposed headers when versioning adds metadata

This one bites frontend developers hard.

The API starts returning deprecation or migration metadata in headers for old versions:

  • Deprecation
  • Sunset
  • Link

Great idea. Browsers won’t let your frontend read most of them unless you expose them.

GitHub does this well. Real response 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 access-control-expose-headers line is doing real work. Without it, browser JavaScript can’t reliably inspect Link, Deprecation, or Sunset.

Bad

Access-Control-Allow-Origin: https://app.example.com
Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"

Frontend code:

const res = await fetch("https://api.example.com/v1/users");
console.log(res.headers.get("Sunset")); // null

Fix

Expose the headers your clients are expected to consume.

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: Deprecation, Sunset, Link, ETag
Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"

Then browser code works:

const res = await fetch("https://api.example.com/v1/users");
console.log(res.headers.get("Deprecation"));
console.log(res.headers.get("Sunset"));
console.log(res.headers.get("Link"));

If version migration guidance lives in headers, Access-Control-Expose-Headers is not optional.

Mistake 4: Wildcard origins with credentials during version transitions

During a migration, teams often support both old and new frontends:

  • old SPA at https://old-app.example.com
  • new SPA at https://app.example.com

Someone gets lazy and sets:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject this combination. * cannot be used when credentials are involved.

Fix

Reflect or explicitly allow trusted origins, and send Vary: Origin.

Example in Express:

const allowedOrigins = new Set([
  "https://old-app.example.com",
  "https://app.example.com"
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (allowedOrigins.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-Methods", "GET, POST, PUT, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");

  if (req.method === "OPTIONS") {
    return res.status(204).end();
  }

  next();
});

If your API is truly public and doesn’t use cookies or HTTP auth, Access-Control-Allow-Origin: * is fine. GitHub’s api.github.com does exactly that for broad public access. But don’t mix that with credentialed browser sessions.

Mistake 5: Caching preflight or versioned responses without Vary

This one is subtle and nasty behind CDNs and reverse proxies.

Suppose your API behavior differs by:

  • Origin
  • version header
  • Access-Control-Request-Headers

If caches don’t vary correctly, one client can get a cached CORS response intended for another.

Bad

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Headers: Authorization

No Vary header. A CDN may reuse that response for another origin or another preflight shape.

Fix

Add the right Vary values.

For preflight responses:

Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers

For header-based versioning:

Vary: Origin, Accept, API-Version

For path versioning, you get some isolation from the URL itself, which is another reason it tends to behave better operationally.

Mistake 6: Breaking older browser clients by tightening CORS in new versions only

I’ve seen versioning used as an excuse to “clean up” CORS. The team thinks:

  • v1 allowed PUT, PATCH, DELETE
  • v2 should be stricter
  • let’s only allow GET, POST

That’s fine if the API semantics changed. It’s not fine if your frontend still uses PATCH on v2 and no one updated the policy.

The same applies to allowed request headers. A new tracing header, tenant header, or idempotency header can quietly break browser calls.

Fix

Write browser-level contract tests per version.

Not just unit tests. Real tests that assert:

  • preflight succeeds
  • expected methods are allowed
  • expected request headers are allowed
  • migration headers are exposed

A lightweight example with Node fetch won’t fully emulate browser CORS enforcement, but it can still validate header shape:

const res = await fetch("https://api.example.com/v2/users", {
  method: "OPTIONS",
  headers: {
    Origin: "https://app.example.com",
    "Access-Control-Request-Method": "PATCH",
    "Access-Control-Request-Headers": "authorization,content-type"
  }
});

console.log(res.headers.get("access-control-allow-methods"));
console.log(res.headers.get("access-control-allow-headers"));
console.log(res.headers.get("vary"));

Then verify in a real browser too. Browser devtools will tell you the truth faster than most test harnesses.

Mistake 7: Hiding version lifecycle info in docs only

If v1 is deprecated, don’t make the frontend scrape release notes or rely on a wiki page someone forgot to update.

Put lifecycle signals in the HTTP response for old versions:

Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: </v2/users>; rel="successor-version"

And because this is a browser-facing API topic, expose them:

Access-Control-Expose-Headers: Deprecation, Sunset, Link

That lets your app show useful warnings:

const res = await fetch("https://api.example.com/v1/users");
const sunset = res.headers.get("Sunset");

if (sunset) {
  console.warn(`This API version sunsets at ${sunset}`);
}

That’s a much better migration path than surprising people with a shutdown date.

My default recommendation

For browser-facing APIs, I usually prefer:

  • path-based versioning like /v1 and /v2
  • explicit origin allowlists for credentialed APIs
  • Access-Control-Expose-Headers for version metadata
  • correct Vary headers everywhere
  • preflight handling at the edge or gateway, not sprinkled through app code

And I avoid inventing custom version headers unless there’s a strong reason.

If you do header-based versioning, own the CORS complexity that comes with it. Don’t act surprised when custom headers, preflights, caches, and migration metadata all become part of the same problem.

CORS and versioning are both HTTP contract design. Treat them that way, test them together, and you’ll avoid the weirdest production bugs.