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:
v1allowsAuthorizationv2forgot it inAccess-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:
DeprecationSunsetLink
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:
v1allowedPUT,PATCH,DELETEv2should 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
/v1and/v2 - explicit origin allowlists for credentialed APIs
Access-Control-Expose-Headersfor version metadata- correct
Varyheaders 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.