Writing Automated Tests for CORS Headers
The failure that a header test would have caught:
Access to XMLHttpRequest at 'https://api.ledgerly.example/v1/entries' from
origin 'https://app.ledgerly.example' has been blocked by CORS policy: The
value of the 'Access-Control-Allow-Origin' header in the response must not be
the wildcard '*' when the request's credentials mode is 'include'.
Root Cause
Somebody replaced a two-entry allowlist with Access-Control-Allow-Origin: * while chasing an unrelated OPTIONS failure, and the existing test suite kept passing — because it asserted that the header was present, not what it said. Presence is the one property that every broken CORS configuration also satisfies. A wildcard is present. A reflected attacker origin is present. An empty value emitted by a fallthrough in a proxy map block is present. The browser, meanwhile, performs a byte comparison and adds a second rule on top of it: under credentials: 'include' the wildcard is not merely weak, it is illegal, so the response is rejected outright.
Writing tests that would have failed on this change means asserting values instead of existence, and asserting the absence of a grant for origins that must not receive one. This page is part of CORS Testing & Regression Prevention, which lays out where each kind of check belongs; here the focus is the mechanics of the assertions themselves.
Prerequisite State
- An API you can boot in-process from the test runner, or reach over HTTP at a stable hostname.
- A test runner with access to raw response headers — Jest or Vitest with Supertest, pytest with
requests, or anything equivalent. - The real allowlist available to the test as data, ideally from the same environment variable the server reads.
- One route that requires a preflight, meaning a non-simple method or a non-safelisted request header such as
Authorization.
Step 1 — Derive the fixture table from the real allowlist
Hardcoding fixtures is how a suite ends up testing last quarter’s policy. Read the allowlisted origins from the same place the server does, then generate the rejection fixtures from them, so a new tenant origin automatically brings its own lookalikes with it.
// test/cors-fixtures.js
const ALLOWED = (process.env.CORS_ALLOWED_ORIGINS || '')
.split(',').map(s => s.trim()).filter(Boolean);
// Every rejected fixture is derived from a real allowlisted value, so it cannot go stale.
const REJECTED = [
'https://unrelated.example',
'null',
...ALLOWED.map(o => `${o}.evil.io`), // suffix lookalike
...ALLOWED.map(o => o.replace('https://', 'https://x')), // prefix lookalike
...ALLOWED.map(o => o.replace('https://', 'http://')), // scheme downgrade
];
export { ALLOWED, REJECTED };
The .replace('https://', 'https://x') trick turns https://app.ledgerly.example into https://xapp.ledgerly.example, which is exactly the string an endsWith check on the apex domain will wrongly accept. Deriving it costs one line and removes a whole class of stale-fixture problem.
Step 2 — Assert the preflight and the actual request as two separate tests
The browser makes two round trips for a non-simple request, and each one is independently able to fail. A suite that only exercises the second one is blind to the failure users actually hit, because the browser never reaches the second request when the first is rejected.
In Supertest that is two tests against the same route, not one:
// test/entries.cors.test.js
import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from '../src/app.js';
import { ALLOWED } from './cors-fixtures.js';
const ORIGIN = ALLOWED[0]; // https://app.ledgerly.example
describe(`CORS contract for ${ORIGIN}`, () => {
it('preflight: 204 with the exact grant', async () => {
const res = await request(app)
.options('/v1/entries')
.set('Origin', ORIGIN)
.set('Access-Control-Request-Method', 'PATCH')
.set('Access-Control-Request-Headers', 'authorization, content-type');
expect(res.status).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
const methods = res.headers['access-control-allow-methods'].split(/\s*,\s*/);
expect(methods).toContain('PATCH');
const headers = res.headers['access-control-allow-headers'].toLowerCase().split(/\s*,\s*/);
expect(headers).toEqual(expect.arrayContaining(['authorization', 'content-type']));
expect(res.headers.vary.toLowerCase().split(/\s*,\s*/)).toContain('origin');
});
it('actual request: the grant is repeated on the real response', async () => {
const res = await request(app)
.patch('/v1/entries/9')
.set('Origin', ORIGIN)
.set('Authorization', 'Bearer test-token')
.send({ amount: 1200 });
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
expect(res.headers['access-control-allow-credentials']).toBe('true');
});
});
Two idioms in there are load-bearing. The method and header lists are split into arrays before assertion, because a raw string comparison breaks the moment somebody reorders the list harmlessly, while a substring match happily reports success for POST when the list contains POSTAL — a header name that does not exist yet but will. And Vary is checked by membership rather than equality, because Accept-Encoding legitimately shares that header.
Step 3 — Assert rejection as absence, not as inequality
This is the assertion that would have caught the wildcard. It has to be written as “the header is not there at all”, because an empty header value, a wildcard, and a reflected foreign origin are three different bugs that all survive an inequality check.
import { it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../src/app.js';
import { REJECTED } from './cors-fixtures.js';
it.each(REJECTED)('grants nothing to %s', async (origin) => {
const res = await request(app)
.options('/v1/entries')
.set('Origin', origin)
.set('Access-Control-Request-Method', 'PATCH');
// absence — not "different from the attacker", not "not a wildcard"
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
// the response must still vary, or a cache will hand this refusal to a valid origin
expect(res.headers.vary.toLowerCase()).toContain('origin');
});
The difference between assertion styles is not stylistic. Each style is a filter, and only one of them lets every real bug through to a red build.
Step 4 — Pick the assertion shape from the header’s value type
CORS response headers are not all the same kind of value, and using one assertion idiom for all of them is what produces brittle tests on the list headers and useless tests on the single-value ones.
The flag case deserves its own note. Access-Control-Allow-Credentials has exactly one legal value, the lowercase string true; any other value means the header is simply not in effect. A test written as expect(res.headers['access-control-allow-credentials']).toBeTruthy() passes against the literal string false, which is truthy in JavaScript and inert in the browser. Compare against 'true' and nothing else. The wider consequences of getting that header wrong are covered in Fix Access-Control-Allow-Credentials Errors: Wildcard Conflicts & Preflight Failures.
Step 5 — Port the same table to every service
A polyglot estate needs the same fixture table in each language, or the Python service quietly develops a different policy from the Node one. The pytest translation is mechanical:
# tests/test_cors_contract.py
import os
import pytest
import requests
BASE = os.environ["API_BASE_URL"] # https://api.ledgerly.example
ALLOWED = [o.strip() for o in os.environ["CORS_ALLOWED_ORIGINS"].split(",") if o.strip()]
REJECTED = (
["https://unrelated.example", "null"]
+ [o + ".evil.io" for o in ALLOWED]
+ [o.replace("https://", "https://x") for o in ALLOWED]
+ [o.replace("https://", "http://") for o in ALLOWED]
)
def preflight(origin, method="PATCH"):
return requests.options(
f"{BASE}/v1/entries",
headers={
"Origin": origin,
"Access-Control-Request-Method": method,
"Access-Control-Request-Headers": "authorization",
},
allow_redirects=False,
timeout=10,
)
@pytest.mark.parametrize("origin", ALLOWED)
def test_allowlisted_origin_is_echoed_exactly(origin):
res = preflight(origin)
assert res.status_code in (200, 204)
assert res.headers.get("Access-Control-Allow-Origin") == origin
assert "origin" in [v.strip().lower() for v in res.headers.get("Vary", "").split(",")]
@pytest.mark.parametrize("origin", REJECTED)
def test_rejected_origin_receives_no_grant(origin):
res = preflight(origin)
assert "Access-Control-Allow-Origin" not in res.headers
assert "Access-Control-Allow-Credentials" not in res.headers
allow_redirects=False is not decoration. A preflight that receives a 301 fails in the browser outright, and letting requests follow the redirect would hide that by asserting against a completely different response. The same reasoning applies to curl: never add -L to a CORS reproduction.
Finally, keep exactly one real-browser test, because it is the only check that exercises the browser’s own preflight machinery and its cache:
// e2e/cors.spec.js — Playwright
import { test, expect } from '@playwright/test';
test('the app can PATCH the API cross-origin', async ({ page }) => {
await page.goto('https://app.ledgerly.example/entries');
const status = await page.evaluate(async () => {
const res = await fetch('https://api.ledgerly.example/v1/entries/9', {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1200 }),
});
return res.status;
});
expect(status).toBe(200);
});
If the CORS check fails, fetch rejects and the test fails with a TypeError rather than a status mismatch — which is exactly the signal you want, because it is the same failure the user would have seen.
Verification
Run the header assertions by hand once before trusting the suite, so you know a green run means something:
curl -sS -o /dev/null -D - -X OPTIONS https://api.ledgerly.example/v1/entries \
-H 'Origin: https://app.ledgerly.example' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization' | grep -i '^access-control\|^vary'
Security Boundary Note
Rejection tests are security tests, and they should be treated as such in review. When a suite goes red because a rejection fixture suddenly received a grant, the correct response is never to delete the fixture or relax it to expect(...).not.toBe(attacker). It is to find out why the server started reflecting.
The second boundary is the fixture data itself. Do not put a real internal hostname in the rejected list and then, when someone eventually needs that host allowed, move it into the allowed list inside the test file while the server config stays unchanged — the suite and the server would then disagree about the policy while both appear green. The allowlisted fixtures must come from the server’s own configuration source, which is what makes the test an assertion about production rather than an assertion about the test file. Related reasoning about which origins deserve a grant at all is set out in Wildcard CORS Risks and Safe Origin Allowlisting.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| Asserting the header exists rather than its value | A wildcard, a reflected attacker origin and an empty value all pass; the suite proves nothing | Assert strict equality against the fixture origin, and absence for every rejected origin |
Only testing the actual request, never OPTIONS |
The browser fails at the preflight, which the suite never sends, so the build stays green while the feature is dead | Write the preflight and the actual request as two separate tests per route |
| Following redirects in the test client | The assertions run against a different response than the browser would ever see | Set allow_redirects=False in requests, and never pass -L to curl |
Truthiness checks on Access-Control-Allow-Credentials |
The string false is truthy, so a disabled credentials policy passes |
Compare with the literal string true, or assert absence |
FAQ
Do I need a real browser to test CORS headers?
Not for the header contract. An HTTP client that lets you set Origin and read raw response headers can assert everything the browser’s CORS check evaluates, and it is far faster and more precise than driving a browser. One real-browser test is still worth keeping, because it is the only thing that proves the whole chain — the browser’s own preflight, its cache, and the fetch call your application actually makes with the credentials mode it actually uses.
Should CORS assertions live in the API’s test suite or the frontend’s?
In the API’s, because that is where the headers are produced and where a change can break them. The frontend repository owns at most one end-to-end check that the app can reach the API. Putting the contract in the API suite also means the API can be changed and released independently while still proving it has not revoked anyone’s access, which matters most when the frontend and the API ship on different schedules.
How do I stop the origin fixture list from drifting away from the real allowlist?
Load the allowlisted fixtures from the same source the server reads — the environment variable or config file — and keep only the rejected fixtures hardcoded in the test. The rejected list is derived from the allowlisted values at runtime, so adding a new origin automatically produces new lookalike fixtures and the suite cannot fall behind. If the allowlist lives in a proxy rather than the app, export it into the environment at build time so both sides read one definition.