CORS: The Three Headers That Cause 90% of Debugging Pain and How to Test Them Without Guessing
Your SPA works fine in development and then every cross-origin request fails in staging with a cryptic CORS error. Here is what the preflight, the three response headers, and the credential flag actually do under the hood, plus a test harness that lets you verify CORS behavior before you deploy.
You ship a new API endpoint. Your SPA calls it from app.example.com to api.example.com. Works fine in local dev because both live on localhost:3000 and localhost:4000, and the browser does not enforce CORS for localhost the same way. Then you deploy to staging, and every single request fails with:
Access to fetch at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy
Half the team dives into the backend. The other half fires up Postman, where the endpoint works perfectly (no browser, no CORS enforcement). The argument runs for hours. The fix is three HTTP headers.
CORS is not complicated. It is just poorly understood because the browser hides most of the negotiation, the error messages are misleading, and most frameworks ship defaults that work for the simple case and break for every real-world scenario. Here is the mental model that makes CORS boring, the three headers that cover 90% of cases, and a test harness you can run before your frontend team wastes another afternoon.
The Mental Model: CORS Is a Bouncer at the Browser
CORS (Cross-Origin Resource Sharing) is a browser-enforced security policy. It is not a server security mechanism. Your API is perfectly reachable from curl, Postman, or a server-side script. The browser looks at the origin of the page making the request, compares it to the origin of the target API, and if they differ, it demands permission from the API before handing the response to JavaScript.
The flow is straightforward:
- Your browser tab loads
https://app.example.com. The origin ishttps://app.example.com. - JavaScript on that page calls
fetch('https://api.example.com/orders'). The origin of the target ishttps://api.example.com. - The browser sees the mismatch and checks: does
api.example.comallow requests fromapp.example.com? - If the API returns the proper
Access-Control-Allow-Originheader, the browser hands the response to JavaScript. If not, it throws the error above and blocks the response.
That is it. The server still processes the request. The response still arrives at the browser. The browser just refuses to give it to the page’s JavaScript.
Simple Requests vs. Preflighted Requests
The browser has two request paths. The distinction matters because one path does not trigger a preflight at all, and the other sends an extra OPTIONS request that most backend teams never see coming.
Simple requests are GET, HEAD, or POST with a limited set of content types (text/plain, application/x-www-form-urlencoded, multipart/form-data) and no custom headers. The browser makes the request directly and checks the response headers. If they do not allow the origin, it blocks the response. No preflight.
Preflighted requests are everything else: PUT, DELETE, PATCH, POST with application/json, any request with custom headers like Authorization or X-Request-Id, or requests that include credentials (cookies, HTTP auth). The browser sends an OPTIONS request first, asking the server for permission. Only if the preflight succeeds does it send the real request.
Every JSON API is preflighted. Every API that uses Authorization headers is preflighted. If you are building a modern web app, you are in the preflight path.
# Preflight request (browser sends this automatically)
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# Preflight response
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
If the preflight response is missing or the headers do not match, the browser cancels the real request and you get a CORS error in the console. No JavaScript error is thrown on the real request endpoint because it never fires.
The Three Headers That Cover 90% of Cases
You need exactly three response headers to handle nearly every CORS scenario. A fourth (credentials) is optional but commonly needed.
1. Access-Control-Allow-Origin
This is the header that says “yes, this origin is allowed to read the response.” It can be a specific origin (https://app.example.com), or the wildcard *.
Access-Control-Allow-Origin: https://app.example.com
The wildcard looks convenient, but it has one killer restriction: you cannot use it with credentials. If your request includes credentials: 'include' (cookies, auth headers), the browser rejects any response with Access-Control-Allow-Origin: *. You must echo back the requesting origin.
// Express middleware for dynamic origin
app.use((req, res, next) => {
const allowedOrigins = [
'https://app.example.com',
'https://staging.app.example.com',
];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
next();
});
Never use req.headers.origin without validation. An attacker can set any origin header. If you echo it back unconditionally, you have effectively set Allow-Origin: * while also supporting credentials, which defeats the purpose entirely.
2. Access-Control-Allow-Methods
For preflight responses only, this tells the browser which HTTP methods the real request is allowed to use.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
If your preflight is for a DELETE request and the response omits DELETE from the methods list, the browser blocks the real request. Match this header to the methods your endpoint actually exposes.
3. Access-Control-Allow-Headers
For preflight responses only, this tells the browser which custom headers the real request is allowed to include.
Access-Control-Allow-Headers: content-type, authorization, x-request-id
This catches most teams off guard. Your frontend sends Authorization: Bearer <token> and Content-Type: application/json. The preflight response must list both of those in Access-Control-Allow-Headers, or the browser never sends the real request.
// Server-side check: log what the preflight is asking for
if (req.method === 'OPTIONS') {
console.log('Preflight request headers:', req.headers['access-control-request-headers']);
// Now you know exactly what to list in Allow-Headers
}
4. Access-Control-Allow-Credentials (bonus, but essential)
When your API uses cookies for authentication (SameSite, session cookies, or HTTP-only cookies) or you pass credentials: 'include' in your fetch calls, you need this header set to true.
Access-Control-Allow-Credentials: true
Setting this to true has three consequences:
Access-Control-Allow-Origincannot be*(must be an explicit origin).Access-Control-Allow-Originmust match the requesting origin exactly (no pattern matching, no subdomain wildcards - the spec says exact string match).- The server must set the
Vary: Originheader so caches know the response depends on the origin.
// Complete Express CORS middleware for credentialed requests
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
];
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-Id');
res.setHeader('Access-Control-Max-Age', '86400');
return res.status(204).end();
}
next();
});
The Vary Header: The CORS Mistake That Haunts Your Cache
Most teams stop after the Access-Control headers and wonder why their CDN occasionally serves CORS errors to some users.
The problem is caching. If api.example.com sits behind a CDN (CloudFront, Cloudflare, Fastly) and your Access-Control-Allow-Origin header varies per requesting origin, the CDN might cache a response meant for https://app.example.com and serve it to https://admin.example.com, which was never in the allow list.
The fix is Vary: Origin:
Vary: Origin
This tells caches to store separate copies of the response for each distinct Origin request header. Without it, your CDN serves stale CORS headers to the wrong clients.
Set Vary: Origin on every response whose Access-Control-Allow-Origin is dynamic. It costs nothing and prevents a category of bug that is almost impossible to debug because it reproduces intermittently and only for some users.
A Test Harness You Can Run Before You Deploy
Stop guessing whether your CORS configuration works. Here is a script that sends the exact same requests your browser will send: a preflight OPTIONS, a credentialed GET, and a POST with JSON. It validates the response headers and fails with a clear message if something is wrong.
// test-cors.mjs - run with: node test-cors.mjs
const API_BASE = process.env.API_URL || 'https://api.example.com';
const TEST_ORIGIN = process.env.TEST_ORIGIN || 'https://app.example.com';
async function testCors() {
const results = [];
// Test 1: Preflight (OPTIONS)
{
const res = await fetch(`${API_BASE}/v1/orders`, {
method: 'OPTIONS',
headers: {
Origin: TEST_ORIGIN,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type, authorization',
},
});
const origin = res.headers.get('access-control-allow-origin');
const methods = res.headers.get('access-control-allow-methods');
const headers = res.headers.get('access-control-allow-headers');
const maxAge = res.headers.get('access-control-max-age');
results.push({
name: 'Preflight OPTIONS returns 204',
pass: res.status === 204,
});
results.push({
name: 'Access-Control-Allow-Origin matches test origin',
pass: origin === TEST_ORIGIN,
});
results.push({
name: 'Access-Control-Allow-Methods is present',
pass: !!methods,
actual: methods,
});
results.push({
name: 'Access-Control-Allow-Headers includes content-type and authorization',
pass: headers?.toLowerCase().includes('content-type') &&
headers?.toLowerCase().includes('authorization'),
actual: headers,
});
results.push({
name: 'Access-Control-Max-Age is set',
pass: !!maxAge,
actual: maxAge,
});
}
// Test 2: Credentialed GET request
{
const res = await fetch(`${API_BASE}/v1/orders`, {
method: 'GET',
headers: { Origin: TEST_ORIGIN },
credentials: 'include',
});
const origin = res.headers.get('access-control-allow-origin');
const credentials = res.headers.get('access-control-allow-credentials');
const vary = res.headers.get('vary');
results.push({
name: 'GET returns credentials header when origin is allowed',
pass: credentials === 'true',
actual: credentials,
});
results.push({
name: 'GET Access-Control-Allow-Origin is not wildcard (required for credentials)',
pass: origin !== '*',
actual: origin,
});
results.push({
name: 'Vary: Origin is set for cache correctness',
pass: vary?.toLowerCase().includes('origin'),
actual: vary,
});
}
// Test 3: POST with JSON body (preflighted)
{
const res = await fetch(`${API_BASE}/v1/orders`, {
method: 'POST',
headers: {
Origin: TEST_ORIGIN,
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify({ item: 'test' }),
});
const origin = res.headers.get('access-control-allow-origin');
results.push({
name: 'POST JSON request allowed by CORS',
pass: origin === TEST_ORIGIN,
actual: origin,
});
}
// Report
let passed = 0;
for (const r of results) {
const icon = r.pass ? 'PASS' : 'FAIL';
if (r.pass) passed++;
console.log(`[${icon}] ${r.name}`);
if (!r.pass && r.actual !== undefined) {
console.log(` Expected something, got: ${r.actual}`);
}
}
console.log(`\n${passed}/${results.length} tests passed`);
process.exit(passed === results.length ? 0 : 1);
}
testCors().catch(console.error);
Run this script against your staging environment before you merge. If it fails, you know exactly which header is wrong. No more “let me try a different fetch option” back-and-forth with the frontend team.
The Three CORS Lies Developers Tell Themselves
“CORS is a server problem.” No. CORS is a browser problem that the server helps solve. The browser enforces the policy, but the server controls the policy by sending the right headers. If you treat CORS as the backend team’s problem only, you miss the client-side implications: your fetch code must set the right credentials mode, and your frontend must handle the opaque error that CORS produces (a TypeError with no status code or body).
“Just set the wildcard and move on.” The wildcard works until you need cookies, auth headers, or any kind of credentialed request. At that point you have to rewrite the middleware anyway. Start with the explicit origin list from day one.
“CORS is a development-only concern.” CORS is enforced identically in development, staging, and production. The only difference is your origin list. If your dev config uses * and prod uses explicit origins, you will ship CORS bugs that only appear after deployment. Use the same code path everywhere. Just change the list of allowed origins per environment.
Production Checklist
Before you ship your CORS configuration, verify:
-
Access-Control-Allow-Originechoes back the validated origin (not*) when credentials are needed - Preflight OPTIONS requests return 204 with proper
Allow-MethodsandAllow-Headers -
Access-Control-Allow-Headerslists every custom header the frontend sends (includingcontent-type) -
Access-Control-Allow-Credentials: trueis present when using cookies orcredentials: 'include' -
Vary: Originis set on every response with a dynamic CORS origin - The CORS middleware runs before any route handler or authentication middleware (preflight must succeed without auth)
- Your CDN or reverse proxy is configured to pass through OPTIONS requests (some CDNs strip them)
- The test script above passes against your staging environment
The last point is the most important. A passing test script eliminates the guesswork. When the frontend team says “CORS is broken,” run the script. If the script passes, the headers are right and the problem is in the fetch call. If the script fails, you know exactly which header to fix.
A note from Yojji
Getting CORS right means understanding how the browser, the server, and the CDN interact across every environment your application runs in. It is the kind of cross-layer debugging that separates a team that ships confidently from one that burns hours on “it works on my machine.” Yojji’s engineers build API stacks with environment-aware middleware, automated CORS validation, and deployment pipelines that catch these header mismatches before they reach production.