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.

The preflight response contract, line by line A single panel split into two columns. The left column lists the six lines of a correct preflight response, from the 204 status to Vary: Origin. The right column states the assertion a test suite makes about each of those lines. What the server must return What the suite asserts about it HTTP/1.1 204 No Content 204 or 200 — never 3xx, 401 or 405 Access-Control-Allow-Origin: https://console.acme.example equals the request Origin byte for byte, and is absent for a foreign origin Access-Control-Allow-Credentials: true present only on credentialed routes Access-Control-Allow-Methods: GET, POST, PATCH holds the method under test, and no more Access-Control-Allow-Headers: authorization holds every header the client sends Vary: Origin on the preflight and the real response Six lines, six assertions — a suite that checks only the status code covers none of them

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.

Four layers of CORS testing and the blind spot of each A matrix with four rows for unit tests, integration tests, deployed probes and synthetic monitors. For each layer the table gives the substrate it runs against, the regressions it detects, and the class of fault it cannot see at all. Layer Runs against What it catches What it cannot catch 1 Unit the policy function a pure function, with no server, no socket allowlist logic errors, lookalike origins accepted, a forgotten Vary: Origin anything the framework does before or after the function 2 Integration the booted app an in-process server on an ephemeral port middleware ordering, routes that skip the layer, an OPTIONS route answering 404 everything added or removed by the proxy and the edge 3 Deployed probe the real hostname the environment as users reach it headers stripped by a proxy, duplicated header values, an env var that never applied any change made after the deploy job finished 4 Monitor production, on a timer the live edge, every few minutes drift from a console edit, a CDN rule someone added, an expired certificate chain nothing — but it only ever reports after the fact Each layer's blind spot is the next layer's whole reason to exist

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.

The same CORS mistake with and without a gate on the merge The upper timeline follows an allowlist edit through merge, deploy, browser caching, a support ticket two days later and a rollback. The lower timeline shows the same edit stopped by a preflight probe forty seconds after the pull request opens. No CORS check anywhere in the pipeline Allowlist edited in a config PR t = 0 Merged, built and deployed t + 20 min Bad preflight cached per browser t + 25 min Ticket: invoices will not save t + 2 days Rollback, then wait for caches t + 2.5 days A preflight probe gating the merge The identical allowlist edit opens a pull request t = 0 Four origin fixtures run, one assertion fails t + 40 s Nothing shipped, so no browser ever cached the broken preflight result The assertions are identical on both rows — only the moment they run is different

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 lifecycle of a CORS policy change Six states arranged as a loop. A change moves from proposed to asserted in the suite, to merged, to deployed, to verified in production, and finally to drift detected, which reopens the cycle. Each transition is labelled with the check that gates it. unit fixtures pipeline probe Change proposed an origin added or removed Asserted in the suite eight fixtures, both directions Merged to main the build is green deploy gate Every arrow is a check that something has to run. Delete one and the cycle still turns, only silently. Deployed to an env staging, then production parity check Verified in production the probe agrees with the plan scheduled monitor Drift detected someone edited a console reopens as a change Drift is not a special case — it is the cycle running without the checks attached

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.

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.