CORS Testing & Regression Prevention
A CORS policy is a piece of security configuration that almost nothing in a normal test suite touches. It is spread across an allowlist in application code, a header block in a reverse proxy, a rule in a CDN and an environment variable that differs per deployment — and the only thing that exercises the whole stack end to end is a browser belonging to a real user. That is why CORS failures have a distinctive shape: they are never caught by the change that caused them, and they surface hours later as a support ticket rather than a red build. This page belongs to Cross-Origin Debugging & Error Diagnosis, which covers isolating a cross-origin failure to the browser, network, server or proxy layer; here the goal is the inverse — making sure the failure never reaches a user in the first place.
Regression prevention for CORS is not one technique. It is a short ladder of checks, each running against a different substrate, each catching a class of fault that the layer below is structurally blind to. Get all four rungs in place and a CORS outage becomes a failed job on a pull request instead of an incident.
The Contract You Are Actually Testing
The WHATWG Fetch Standard defines a CORS-preflight fetch as an OPTIONS request carrying Origin and Access-Control-Request-Method, whose response is subjected to a CORS check before the real request is allowed to proceed. That check is a small set of byte comparisons: the response’s Access-Control-Allow-Origin must be either * or a string identical to the request’s Origin; if the request’s credentials mode is include, the wildcard is disallowed and Access-Control-Allow-Credentials must be the literal ASCII lowercase true; the requested method must appear in Access-Control-Allow-Methods; and every non-safelisted request header must appear in Access-Control-Allow-Headers.
Because the browser’s decision is a byte comparison, a test of that decision must also be a byte comparison. Asserting “the request succeeded” tells you almost nothing — curl and most HTTP clients succeed happily against a response that a browser will reject, because they never run the CORS check at all. The unit of assertion is the header value, and the unit of coverage is the pairing of a request shape with an origin fixture.
The diagram below is the contract in its literal form: the response a preflight must produce, and the assertion each line earns.
Header Reference for Assertions
Each response header carries a different failure mode, and each therefore deserves a different assertion style. Asserting containment where you need equality is how a wildcard slips into production unnoticed.
| Header | Correct assertion style | Regression it catches | Assertion that is too weak |
|---|---|---|---|
Access-Control-Allow-Origin |
Strict equality against the fixture origin; strict absence for a rejected origin | Reflection of any origin, a wildcard creeping in, a normalisation bug that drops the port | toContain('example'), or asserting only that the header exists |
Access-Control-Allow-Credentials |
Equality with the string true, or strict absence |
A credentialed route losing cookies, or a public route gaining them | Truthiness checks — the header value false is truthy as a string |
Access-Control-Allow-Methods |
Parse into a set, then assert membership of the method under test | A verb silently removed when someone tidies the middleware config | Substring match — PATCH is a substring of nothing useful, but POST matches POSTAL |
Access-Control-Allow-Headers |
Case-insensitive set membership for every header the client sends | A new client header (x-request-id, traceparent) never added to the list |
Exact string equality on the whole list — it reorders harmlessly |
Access-Control-Max-Age |
Integer parse, then a range assertion | A value that a proxy rewrote to 0, forcing a preflight per request |
Ignoring it entirely |
Vary |
Case-insensitive set membership of Origin |
Shared-cache poisoning after someone adds a caching layer | Exact equality — Accept-Encoding is legitimately present too |
Access-Control-Expose-Headers |
Set membership for each header the client reads | A pagination or rate-limit header becoming unreadable in JavaScript | Skipping it because the request itself still succeeds |
The distinction between an absent header and an empty header matters more here than anywhere else in HTTP. Nginx emits Access-Control-Allow-Origin: with an empty value when a map block falls through to an empty default, and a browser treats that as a failed match, but a test written as expect(res.headers['access-control-allow-origin']).not.toBe('https://evil.example') passes happily. Assert toBeUndefined(). The same trap is described from the configuration side in Dynamic Origin Validation Patterns.
Four Layers, Four Blind Spots
There is no single place to test CORS, because the policy is assembled from contributions made at four different times: when the code is written, when the app boots, when it is deployed, and whenever someone edits a rule in a console afterwards. Each layer of testing sees only its own slice.
The layers are cheap in proportion to how early they run. A unit test of the policy function costs single-digit milliseconds and can afford dozens of origin fixtures. A synthetic monitor costs a real HTTPS round trip every interval and is worth pointing at two or three endpoints, not two hundred. Build downward from the cheap end and only promote a check upward when a lower layer genuinely cannot see the fault.
Building the Ladder
1. Extract the policy into a pure function
Most CORS code is untestable because the decision and the response are welded together inside a middleware closure. Split them. The decision is a pure function from a request-shaped input to a header map, and everything about it can be asserted without a socket.
// cors-policy.js
const ALLOWED = new Set([
'https://console.acme.example',
'https://admin.acme.example',
]);
const ALLOWED_METHODS = ['GET', 'POST', 'PATCH', 'DELETE'];
const ALLOWED_HEADERS = ['authorization', 'content-type', 'x-request-id'];
/**
* @param {object} input - { origin, credentialed }
* @returns {Record<string,string>} headers to write on the response
*/
export function corsPolicy({ origin, credentialed = false }) {
const headers = { Vary: 'Origin' };
if (!origin || !ALLOWED.has(origin)) return headers;
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Methods'] = ALLOWED_METHODS.join(', ');
headers['Access-Control-Allow-Headers'] = ALLOWED_HEADERS.join(', ');
headers['Access-Control-Max-Age'] = '600';
if (credentialed) headers['Access-Control-Allow-Credentials'] = 'true';
return headers;
}
The suite for it is a table-driven test over origin fixtures. Note that the rejection cases assert undefined, not “not equal to the attacker’s origin”:
// cors-policy.test.js — runs under Vitest or Jest with no changes
import { describe, it, expect } from 'vitest';
import { corsPolicy } from './cors-policy.js';
const REJECTED = [
'https://evil.example',
'https://console.acme.example.evil.io', // suffix lookalike
'https://notconsole.acme.example', // prefix lookalike
'http://console.acme.example', // scheme downgrade
'null', // sandboxed iframe / data: URL
];
describe('corsPolicy', () => {
it('echoes an allowlisted origin exactly', () => {
const h = corsPolicy({ origin: 'https://console.acme.example' });
expect(h['Access-Control-Allow-Origin']).toBe('https://console.acme.example');
expect(h.Vary).toBe('Origin');
});
it.each(REJECTED)('omits the grant for %s', (origin) => {
const h = corsPolicy({ origin });
expect(h['Access-Control-Allow-Origin']).toBeUndefined();
expect(h.Vary).toBe('Origin'); // still varies, so caches stay partitioned
});
it('never pairs a wildcard with credentials', () => {
const h = corsPolicy({ origin: 'https://console.acme.example', credentialed: true });
expect(h['Access-Control-Allow-Origin']).not.toBe('*');
expect(h['Access-Control-Allow-Credentials']).toBe('true');
});
});
Two details in that suite do real work. Vary: Origin is asserted on the rejection path as well as the success path, because a cached “no grant” response served to a legitimate origin is its own outage — the mechanics are covered in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation. And the literal string null is in the rejected list rather than absent from it, because null is what a sandboxed iframe sends and it is trivially forgeable.
2. Assert the booted app, not just the function
A correct policy function still produces a broken API if the middleware runs after the router, if an authentication layer answers OPTIONS with 401, or if one route was mounted on a sub-app that never received the middleware. Boot the real Express app in-process and drive it with Supertest.
// app.cors.test.js
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from './app.js';
const ORIGIN = 'https://console.acme.example';
describe('preflight on /v1/invoices', () => {
it('answers OPTIONS with 204 and the full grant', async () => {
const res = await request(app)
.options('/v1/invoices')
.set('Origin', ORIGIN)
.set('Access-Control-Request-Method', 'PATCH')
.set('Access-Control-Request-Headers', 'authorization, x-request-id');
expect(res.status).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
const methods = res.headers['access-control-allow-methods'].split(/,\s*/);
expect(methods).toContain('PATCH');
const allowed = res.headers['access-control-allow-headers'].toLowerCase().split(/,\s*/);
expect(allowed).toEqual(expect.arrayContaining(['authorization', 'x-request-id']));
});
it('carries the grant on the real response too', async () => {
const res = await request(app)
.patch('/v1/invoices/42')
.set('Origin', ORIGIN)
.send({ status: 'paid' });
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
});
it('does not answer the preflight for a foreign origin', async () => {
const res = await request(app)
.options('/v1/invoices')
.set('Origin', 'https://evil.example')
.set('Access-Control-Request-Method', 'PATCH');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
});
The second test is the one teams skip, and it is the one that catches the most common production failure of all: a preflight that passes while the actual PATCH response carries no grant, so the browser blocks a request the server already executed. Writing this pair for every non-simple route is the subject of Writing Automated Tests for CORS Headers.
3. Run the proxy configuration you actually ship
When the policy lives in Nginx, the application suite proves nothing at all. Run the real config file against a stub upstream and assert with curl.
# nginx/cors.conf — the file that ships, mounted verbatim into the test container
map $http_origin $acme_cors_origin {
default "";
"https://console.acme.example" $http_origin;
"https://admin.acme.example" $http_origin;
}
server {
listen 8080;
location /v1/ {
add_header Vary Origin always;
if ($acme_cors_origin) {
add_header Access-Control-Allow-Origin $acme_cors_origin always;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "authorization, content-type, x-request-id" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://127.0.0.1:9000;
}
}
#!/usr/bin/env bash
# nginx-cors.test.sh — boot the shipped config, then assert against it
set -euo pipefail
docker run --rm -d --name cors-under-test -p 8080:8080 \
-v "$PWD/nginx/cors.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.27-alpine
trap 'docker rm -f cors-under-test >/dev/null' EXIT
until curl -sf -o /dev/null http://127.0.0.1:8080/v1/ping; do sleep 0.2; done
fail=0
assert_acao() { # assert_acao <origin> <expected value, or empty for absent>
local got
got=$(curl -sS -o /dev/null -D - -X OPTIONS http://127.0.0.1:8080/v1/invoices \
-H "Origin: $1" -H 'Access-Control-Request-Method: PATCH' \
| tr -d '\r' | awk 'tolower($1)=="access-control-allow-origin:"{print $2}')
if [ "$got" != "${2:-}" ]; then
printf 'FAIL origin=%-42s expected=%-32s got=%s\n' "$1" "${2:-<absent>}" "${got:-<absent>}"
fail=1
fi
}
assert_acao 'https://console.acme.example' 'https://console.acme.example'
assert_acao 'https://evil.example' ''
assert_acao 'https://console.acme.example.evil.io' ''
assert_acao 'null' ''
exit "$fail"
The map block with an empty default plus the if ($acme_cors_origin) guard is deliberate: without the guard Nginx emits the header with an empty value, and the shell assertion above is written to catch exactly that, since an empty got and an absent header both compare equal to '' only when the header is genuinely missing from the awk output.
4. Probe the deployed environment
The last rung sends real requests to a real hostname. It needs no test framework and no repository checkout, which is precisely what makes it usable as a deploy gate, a scheduled monitor and a manual debugging tool at once.
// probe.mjs — node probe.mjs https://api.acme.example/v1/invoices https://console.acme.example
const [, , url, allowedOrigin] = process.argv;
const CASES = [
{ origin: allowedOrigin, expect: allowedOrigin },
{ origin: 'https://evil.example', expect: null },
{ origin: `${allowedOrigin}.evil.io`, expect: null },
{ origin: 'null', expect: null },
];
let failed = 0;
for (const { origin, expect: want } of CASES) {
const res = await fetch(url, {
method: 'OPTIONS',
headers: {
Origin: origin,
'Access-Control-Request-Method': 'PATCH',
'Access-Control-Request-Headers': 'authorization',
},
});
const got = res.headers.get('access-control-allow-origin');
const vary = (res.headers.get('vary') ?? '').toLowerCase();
const ok = got === want && vary.split(/,\s*/).includes('origin');
if (!ok) failed++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${origin} -> ${got ?? '<absent>'} (vary: ${vary || '<absent>'})`);
}
process.exit(failed ? 1 : 0);
Because it is a plain script with no dependencies, the same file runs in a pipeline job, in a cron-driven monitor and on a laptop while somebody is debugging. Keeping the three uses on one implementation is what stops them from drifting apart.
Where the Gate Belongs
The value of a CORS check is almost entirely a function of when it runs. The same four assertions catch the same bug whether they run on a pull request or on a schedule in production — but the cost of the bug between those two moments is separated by orders of magnitude, because a bad preflight response gets cached in every browser that saw it and keeps failing for up to Access-Control-Max-Age seconds after the fix ships.
Two properties of the upper timeline are worth naming, because both are specific to CORS rather than general to bugs. First, the failure is invisible to the server: from the origin’s point of view it returned a perfectly good 204, and nothing in its logs distinguishes the broken policy from the working one. Second, the recovery is not instantaneous even after a correct deploy, because each browser holds its own preflight cache entry — a topic covered in depth under Cache Duration Tuning & Max-Age. A ten-minute Access-Control-Max-Age is a ten-minute tail on every rollback; a one-day value is a one-day tail. Wiring the probe into a pipeline stage is walked through in Catching CORS Regressions in CI Pipelines.
The Fixture Set
Coverage in CORS testing is measured in origin fixtures, not in lines. A suite that sends one good origin and one bad one has two data points against a policy whose failure modes number about a dozen. This is the minimum set worth carrying, and it is small enough to paste into every layer of the ladder.
| Fixture | Example value | What a correct policy does | Regression it exposes |
|---|---|---|---|
| Allowlisted | https://console.acme.example |
Echoes it verbatim | Normalisation that lowercases, strips a port or adds a slash |
| Second allowlisted | https://admin.acme.example |
Echoes it verbatim | A hardcoded single origin masquerading as an allowlist |
| Foreign | https://evil.example |
Omits the header | Unconditional reflection of the Origin header |
| Suffix lookalike | https://console.acme.example.evil.io |
Omits the header | An unanchored regex or a startsWith check |
| Prefix lookalike | https://notconsole.acme.example |
Omits the header | An endsWith check on the apex domain |
| Scheme downgrade | http://console.acme.example |
Omits the header | A comparison performed on the host only |
Literal null |
null |
Omits the header | A sandboxed iframe or data: URL gaining access |
No Origin at all |
header absent | Omits the header, or returns a deliberate public grant | A static Access-Control-Allow-Origin leaking onto every response |
Run the whole set against every distinct policy in the system, not once per service. If /v1/invoices is credentialed and /v1/status is public, they are two policies and each needs the eight rows. The same fixtures also form the backbone of a manual review — they map closely onto the probes in the CORS Security Audit Checklist.
Edge Cases and Security Boundaries
A passing test can hide a permissive policy. The most dangerous CORS suite is one that only asserts success. expect(res.headers['access-control-allow-origin']).toBe(ORIGIN) passes just as happily against a server that reflects every origin as it does against a server with a strict allowlist. It is the rejection fixtures that carry the security signal, and they are the ones a rushed refactor deletes first because “they were not testing anything”.
Credentials change the meaning of every other assertion. With Access-Control-Allow-Credentials: true, the wildcard becomes illegal, Access-Control-Allow-Headers: * stops matching arbitrary headers and matches only a header literally named *, and the exactness requirement on the origin tightens from “matches or wildcard” to “matches”. Any route whose test sets a Cookie or Authorization header must assert the credentialed variant of the contract, as described in Credential Sharing & Security Boundaries in CORS.
Error responses are part of the contract. A 500 from an API that omits CORS headers on error paths shows up in the browser as a CORS error, not as a server error, and the real cause disappears from the developer’s console. Add at least one test that forces a 4xx or 5xx and asserts the grant is still present — this is the single most valuable assertion for future debuggability, because it is what keeps the real error message readable.
Redirects erase the grant. A preflight that receives a 301 fails outright; an actual request that follows a redirect to another origin arrives with Origin: null at the destination. Any endpoint your tests reach through a redirect is not the endpoint the browser will talk to. Always assert against the final URL, with redirect following disabled in the probe.
Testing must not weaken the policy. Adding http://localhost:5173 to the production allowlist so that a test can pass is a policy change dressed as a test fix. Keep development origins in a separate, environment-gated list — the safe pattern is set out in Safely Allowing localhost Origins in Development.
Keeping the Loop Closed
A CORS policy is not a static artefact; it is a state that circulates. Every arrow in the cycle below is a place where a check either exists or does not, and the cycle keeps turning either way — the only difference is whether anyone finds out.
The final transition is the one most teams have no answer for. A CORS policy edited in a cloud console, a WAF rule, or a load balancer listener rule is a change that never passed through a pull request and therefore never met a single assertion. The only mechanism that closes that gap is a check that reads the live policy and compares it with the declared one — which is exactly what a parity check does, described in Staging vs Production CORS Parity Checks.
Proxy and CDN Interaction
Everything above assumes the response your test sees is the response the browser sees. Between the two sit a CDN, possibly a WAF, an ingress controller and a service mesh sidecar, each able to add, rewrite or remove a header.
Test through the edge, not around it. A probe pointed at the origin’s internal hostname skips every layer that could break the policy. Point it at the public hostname, and if a private path exists, probe both and diff them — a difference between the two is the layer that is modifying headers, which is the whole method described in Troubleshooting CORS at the Proxy Layer.
Assert on duplicates, not just presence. The classic edge failure is both the application and the proxy adding Access-Control-Allow-Origin, producing two header lines. Browsers reject the pair outright. Most HTTP clients join duplicate headers with a comma, so the assertion that catches it is a check for a comma inside a value that must never contain one:
const acao = res.headers.get('access-control-allow-origin');
if (acao && acao.includes(',')) {
throw new Error(`duplicate Access-Control-Allow-Origin at the edge: ${acao}`);
}
Prove the cache is partitioned. Send the same URL twice from two different allowlisted origins, in that order, and assert each response echoes its own origin. If the second response carries the first origin’s value, a cache in the path is keyed without Origin and the Vary header is either missing or being stripped. This assertion is worth running against production specifically, because it only fails when a real shared cache is present.
Watch for a Max-Age rewritten downstream. Some proxies normalise or drop Access-Control-Max-Age. The symptom is a preflight on every single request, which is a performance regression rather than a correctness one and so never triggers an error. Assert the value you set is the value that arrives.
DevTools and curl Verification Checklist
Run this after any change to a CORS policy, a proxy config, or a test suite that touches either.
curl -sS -o /dev/null -D - -X OPTIONS https://api.acme.example/v1/invoices \ -H 'Origin: https://console.acme.example' \ -H 'Access-Control-Request-Method: PATCH' \ -H 'Access-Control-Request-Headers: authorization' | grep -i '^access-control\|^vary'
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Only asserting the happy path | A server that reflects every origin passes the suite unchanged; the allowlist can be deleted without a red build | Add rejection fixtures asserting the header is absent, and treat them as security tests, not redundancy |
| Testing the actual request but never the preflight | The browser fails at OPTIONS, which the suite never sends, so the build stays green while the feature is broken |
Send an explicit OPTIONS with Access-Control-Request-Method for every non-simple route |
Asserting not.toBe(attackerOrigin) instead of absence |
An empty Access-Control-Allow-Origin: header passes the assertion and fails in the browser |
Assert toBeUndefined() / toBeNull(); distinguish absent from empty |
| Running the suite only against a locally booted app | Every fault introduced by the CDN, WAF or ingress is invisible until a user hits it | Add a deployed probe stage that targets the public hostname |
| Using a stale mock of the CORS middleware in tests | The mock keeps returning last quarter’s policy while production changed | Boot the real app or the real proxy config; never mock the layer under test |
| Widening the production allowlist to make a test pass | A test-only origin becomes a permanent grant nobody remembers approving | Keep test origins in an environment-gated list and assert they are absent in production |
| No check on policy changes made outside the repository | A console edit bypasses every gate and is discovered by users | Schedule a monitor that compares the live policy against the declared one |
FAQ
What should a CORS test assert beyond the status code?
A status code proves the route answered, not that the browser will accept the answer. Assert the exact value of Access-Control-Allow-Origin, the presence or absence of Access-Control-Allow-Credentials, the method and header lists, and Vary: Origin. Then assert the negative case: for an origin that is not on the allowlist, Access-Control-Allow-Origin must be absent entirely rather than empty or wildcarded. The negative assertions are where the security value lives, because the positive ones pass against a completely open server too.
Should CORS tests run against a live deployment or a locally booted app?
Both, because they fail differently. A locally booted app catches allowlist logic, middleware ordering and routes that skip the CORS layer, and it runs in milliseconds on every commit. A probe against a deployed hostname catches everything the edge adds or removes: stripped headers, duplicated headers, a CDN rule, an unset environment variable. Neither substitutes for the other, and the deployed probe is worth running on a schedule as well as at deploy time, since the edge can change without any deploy happening.
How do I test a CORS policy that lives in Nginx rather than in application code?
Run the real configuration. Start Nginx in a container with the production config file mounted and a stub upstream behind it, then drive it with curl assertions from a shell script or a test runner. Config-file parsing alone is not enough, because the bugs are in map fallbacks, add_header inheritance into nested locations and the always flag, all of which only appear when a request actually flows through the server. The same approach works for Envoy, HAProxy and Caddy — mount the shipped file, boot it, assert against it.
How many origin fixtures does a CORS suite actually need?
Five is a good working minimum: one allowlisted origin, one plainly foreign origin, one suffix lookalike, one prefix lookalike, and the literal string null. Add a scheme-downgraded copy of an allowlisted origin if the API is reachable over HTTP at all, and a second allowlisted origin if the policy claims to support more than one. Every fixture asserts the exact Access-Control-Allow-Origin value, not just pass or fail, since a wildcard would satisfy a pass-or-fail check on the good origin.
Why do my CORS tests pass locally but the browser still blocks the request?
The most common causes are that the test never sent an Origin header at all, that it asserted on the actual request while the browser fails at the preflight, or that a proxy in front of the deployed app rewrites headers your local server never sees. Reproduce the exact request the browser sent, preflight included, against the same hostname the browser used — the technique is set out in Reproducing CORS Failures with curl. If the reproduction passes and the browser still fails, check for a cached preflight result and for duplicate response headers.