Catching CORS Regressions in CI Pipelines
What shipped, and what the browser said about it:
Access to fetch at 'https://api.tilehub.example/v1/tiles/482' from origin
'https://web.tilehub.example' has been blocked by CORS policy: Method PATCH is
not allowed by Access-Control-Allow-Methods in preflight response.
Root Cause
A refactor consolidated three copies of the CORS middleware into one shared helper and, in the process, transcribed GET, POST, PATCH, DELETE as GET, POST, DELETE. Every test in the repository passed, the review was approved by two people, and the change reached production twenty minutes later, where it broke every edit operation in the map editor.
Nothing in that pipeline was capable of catching it. The unit tests never sent an OPTIONS request. The end-to-end suite ran against a mock API. The deploy job checked that a health endpoint returned 200, which it did, because the CORS policy has no effect on a request that does not carry an Origin header. A CORS regression is invisible to every conventional check precisely because the server behaves correctly from its own point of view — it is the browser that refuses, and no browser is present in a build. This page belongs to CORS Testing & Regression Prevention, and it covers the pipeline half: making the build itself send the preflight that nothing else does.
Prerequisite State
- A pipeline you can add a job to, with the ability to fail the build (GitHub Actions, GitLab CI, Buildkite, Jenkins — the shape below ports directly).
- Either an app you can boot inside the runner, or a deployed hostname the runner can reach.
- The allowlist available to the job as an environment variable or a checked-in config file.
- One route that requires a preflight — a
PATCH,PUTorDELETE, or any route the client calls withAuthorization.
Step 1 — Write a probe that exits non-zero
The gate is a script, not a framework. Keeping it dependency-free means the same file runs in the pre-merge job, the post-deploy job, the scheduled monitor and on a laptop, so all four can never disagree about what the policy is supposed to be.
#!/usr/bin/env bash
# ci/cors-probe.sh BASE_URL PATH METHOD
# Exits 0 only when every fixture receives exactly the grant it should.
set -uo pipefail
base="$1"; path="$2"; method="${3:-PATCH}"
IFS=',' read -ra allowed <<< "${CORS_ALLOWED_ORIGINS:?set CORS_ALLOWED_ORIGINS}"
grant_for() { # grant_for <origin> -> the ACAO value, or empty
curl -sS --max-time 10 -o /dev/null -D - -X OPTIONS "${base}${path}" \
-H "Origin: $1" \
-H "Access-Control-Request-Method: ${method}" \
-H 'Access-Control-Request-Headers: authorization' \
| tr -d '\r' \
| awk 'tolower($1) == "access-control-allow-origin:" { print $2 }'
}
status=0
check() { # check <origin> <expected grant, empty for none>
local got; got="$(grant_for "$1")"
if [ "$got" = "${2}" ]; then
printf 'ok %-46s -> %s\n' "$1" "${got:-<absent>}"
else
printf 'FAIL %-46s -> %s (wanted %s)\n' "$1" "${got:-<absent>}" "${2:-<absent>}"
status=1
fi
}
for origin in "${allowed[@]}"; do
check "$origin" "$origin"
check "${origin}.evil.io" '' # suffix lookalike
check "${origin/https:\/\//http://}" '' # scheme downgrade
done
check 'https://unrelated.example' ''
check 'null' ''
exit "$status"
Two choices in that script exist purely so the job cannot lie. --max-time 10 turns a hung edge into a failure rather than a job that runs until the runner times out with an ambiguous result. And awk prints nothing when the header is absent, so an absent header and an empty header both compare against the empty expectation — which is what makes the Nginx empty-value trap visible rather than silently acceptable.
Step 2 — Boot the app and probe it before the merge
The pre-merge job is the cheap one and it should run on every pull request. It boots the real application inside the runner, waits for the port, and runs the probe against localhost.
# .github/workflows/ci.yml
name: ci
on: [pull_request]
jobs:
cors:
runs-on: ubuntu-latest
env:
CORS_ALLOWED_ORIGINS: https://web.tilehub.example,https://admin.tilehub.example
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- name: Boot the API
run: |
npm run start &
for i in $(seq 1 40); do
curl -sf -o /dev/null http://127.0.0.1:3000/healthz && break
sleep 0.5
done
- name: Preflight contract
run: bash ci/cors-probe.sh http://127.0.0.1:3000 /v1/tiles PATCH
The wait loop matters more than it looks. Without it the probe races the server’s startup, the curl calls fail at the connection level, and the job goes red for a reason that has nothing to do with CORS — which is how a genuinely useful gate acquires a reputation for flakiness and gets disabled.
Step 3 — Probe the deployed environment before shifting traffic
The pre-merge job runs against a bare application process. It cannot see the CDN, the WAF, the ingress controller or the environment variables that only exist in the deployed environment. The second gate runs after the deploy completes but before traffic moves, so a rollback is still a no-op.
deploy:
needs: cors
runs-on: ubuntu-latest
environment: production
env:
CORS_ALLOWED_ORIGINS: https://web.tilehub.example,https://admin.tilehub.example
steps:
- uses: actions/checkout@v4
- name: Deploy to the idle slot
run: ./ci/deploy.sh --slot idle
- name: Preflight contract at the edge
run: bash ci/cors-probe.sh https://idle.api.tilehub.example /v1/tiles PATCH
- name: Shift traffic
run: ./ci/deploy.sh --promote
Placing the probe between the deploy and the promotion is the whole point. Once traffic has shifted, a bad preflight is cached inside every browser that saw it, and rolling the deploy back does not roll those caches back — the exposure lasts for whatever Access-Control-Max-Age you published, as explained under Cache Duration Tuning & Max-Age.
The same two gates in GitLab CI are a stage boundary rather than a needs edge:
stages: [test, deploy, verify]
cors:preflight:
stage: test
image: node:22
variables:
CORS_ALLOWED_ORIGINS: "https://web.tilehub.example,https://admin.tilehub.example"
script:
- npm ci
- npm run start &
- until curl -sf -o /dev/null http://127.0.0.1:3000/healthz; do sleep 0.5; done
- bash ci/cors-probe.sh http://127.0.0.1:3000 /v1/tiles PATCH
cors:edge:
stage: verify
image: curlimages/curl:8.11.0
variables:
CORS_ALLOWED_ORIGINS: "https://web.tilehub.example,https://admin.tilehub.example"
script:
- sh ci/cors-probe.sh https://idle.api.tilehub.example /v1/tiles PATCH
The pipeline now looks like this, with the two probe stages the only ones that can see the class of fault at issue:
Step 4 — Commit a snapshot so the diff shows up in review
A probe answers “is the policy correct”. A snapshot answers “did the policy change”, which is the question a reviewer can actually act on. Have the probe write its findings to a file, commit that file, and fail the job when the generated file differs from the committed one.
#!/usr/bin/env bash
# ci/cors-snapshot.sh — regenerate and diff the committed policy snapshot
set -euo pipefail
out=ci/cors-policy.snapshot
{
for origin in ${CORS_ALLOWED_ORIGINS//,/ }; do
printf 'origin=%s method=PATCH\n' "$origin"
curl -sS --max-time 10 -o /dev/null -D - -X OPTIONS "$1/v1/tiles" \
-H "Origin: $origin" \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: authorization' \
| tr -d '\r' | grep -iE '^(access-control|vary):' | tr 'A-Z' 'a-z' | sort | sed 's/^/ /'
done
} > "$out.new"
if ! diff -u "$out" "$out.new"; then
echo "CORS policy changed. Review the diff above, then commit $out.new as $out."
exit 1
fi
rm -f "$out.new"
The refactor that started this page would have produced exactly this diff, in the pull request, before anyone approved it:
Sorting the header lines before writing them is deliberate: proxies and frameworks are free to reorder response headers, and an unsorted snapshot would produce noisy diffs that teach the team to ignore the job. Lowercasing them is deliberate for the same reason, since HTTP header names are case-insensitive and different layers capitalise them differently.
Step 5 — Make sure a failing probe actually fails the job
More CORS gates are defeated by shell plumbing than by bad assertions. Each of the lines below runs the probe and reports success no matter what the probe found.
The remedy for all four is the same discipline: write the step so the probe is the last command in it, set set -euo pipefail at the top of every script, and — most importantly — verify the gate by breaking the policy deliberately in a throwaway branch. A gate that has never been observed failing is not known to be a gate.
Verification
CORS_ALLOWED_ORIGINS=https://web.tilehub.example \ bash ci/cors-probe.sh https://api.tilehub.example /v1/tiles PATCH
Security Boundary Note
A pipeline that can probe production is a pipeline that holds a production hostname and, often, a token. Keep the probe read-only: it sends OPTIONS and nothing else, so it cannot mutate state even if a fixture is wrong. Resist the temptation to give it credentials — a preflight is unauthenticated by definition, since the browser sends it without cookies, and a probe that authenticates is testing a code path no browser ever takes.
The second boundary is the failure message. Printing the full response headers of a failing probe into a public build log can disclose internal hostnames, upstream identifiers or an internal allowlist that was never meant to be published. Print the header under test and the fixture origin, not the whole response. The broader question of which values are safe to expose at all is covered in the CORS Security Audit Checklist.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
| The probe runs after traffic has already shifted | The broken preflight is already cached in every active browser; rolling back does not clear those caches | Place the edge probe between the deploy step and the promotion step |
| Retrying a failed assertion | A genuine regression is reported as an intermittent failure and eventually ignored | Retry only connection-level errors; never retry a header mismatch |
| Probing the internal origin hostname | Every fault introduced by the CDN, WAF or ingress is skipped | Point the edge probe at the same public hostname the browser uses |
continue-on-error or a trailing ` |
true` on the gate step |
FAQ
Where in the pipeline should the CORS gate run?
Twice. Once before the merge, against the app booted inside the job, so a policy change cannot land at all. Once after the deploy but before traffic is shifted, against the environment’s real hostname, so a change introduced by the edge or by configuration is caught while a rollback is still cheap. The first is fast and free and belongs on every pull request; the second is the only one that sees the proxy, and it is the one that catches the faults described in Troubleshooting CORS at the Proxy Layer.
Will probing a deployed environment make the pipeline flaky?
Only if the probe is written like a browser test. Keep it to OPTIONS requests with a short timeout, retry on connection-level errors but never on an assertion failure, and disable redirect following. A probe that retries a failed header assertion is worse than no probe, because it converts a real regression into an intermittent one, and intermittent jobs get muted. Eight fixtures against one route take well under a second.
How do I gate a CORS policy that does not live in the repository?
Snapshot it. Have a scheduled job read the live policy with the same probe, write the result to a file in the repository, and open a pull request when it differs from the committed snapshot. The console edit then becomes a reviewable diff, which is the only way to bring a change made outside version control back under review. Comparing two environments’ snapshots against each other is the related technique in Staging vs Production CORS Parity Checks.