Migrating from a Wildcard CORS Policy to an Allowlist Safely
What a rushed migration produces, minutes after the deploy that removed the wildcard:
Access to fetch at 'https://api.brightwave.co/v2/catalog' from origin
'https://partner-portal.mercora.example' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The origin in that message belongs to an integration nobody on the current team remembers approving. It worked for three years because Access-Control-Allow-Origin: * never asked who was calling. This page is about removing the wildcard without generating that message — and it is a companion to Wildcard CORS Risks and Safe Origin Allowlisting, which sets out why the wildcard needs removing in the first place.
Root Cause of a Broken Migration
A wildcard policy answers a question the server never had to record. Because * matches every caller, the set of origins that actually depend on the API exists nowhere — not in configuration, not in a ticket, not in anyone’s head. Switching to an allowlist replaces an implicit grant with an explicit one, so the migration fails whenever the explicit list is shorter than reality. Every failure mode traces to the same omission: the list was written from memory instead of measurement.
The second trap is timing. Access-Control-Max-Age lets a browser reuse a preflight result, so during the change window some clients act on the old policy and some on the new one. A client that cached a permissive preflight will skip the OPTIONS round trip entirely and go straight to a request the server now refuses to grant, producing a failure that does not reproduce in a fresh browser profile. The mechanics of that cache are covered in Cache Duration Tuning & Max-Age.
The fix for both is to make the migration a measured sequence rather than a single deploy.
Prerequisite State
- The API currently answers with
Access-Control-Allow-Origin: *and therefore cannot be sendingAccess-Control-Allow-Credentials: true; the two are mutually exclusive. - You can ship configuration changes independently of application releases, so the mode switch does not require a full deploy cycle.
- Request logs capture the
Originrequest header, or you can add it to the log format now. - You have a metrics sink — counters, structured logs, anything queryable — for the shadow phase to write to.
Step-by-Step Migration
Step 1 — Measure which origins actually call the API
Add Origin to the access log format and let it run. In Nginx that is one line in the log_format directive:
log_format cors_audit '$remote_addr $status $request_method $uri origin="$http_origin"';
server {
listen 443 ssl;
server_name api.brightwave.co;
access_log /var/log/nginx/cors_audit.log cors_audit;
}
After a couple of weeks, reduce the log to a ranked set of distinct origins. Rank matters: a single request from an origin is a very different signal from ninety thousand.
grep -o 'origin="[^"]*"' /var/log/nginx/cors_audit.log \
| sed 's/origin="//; s/"$//' \
| grep -v '^-\?$' \
| sort | uniq -c | sort -rn
Review the output line by line and attach an owner to each entry before it goes anywhere near the allowlist. An origin you cannot attribute is a decision, not an entry — either find the team behind it or plan to break it deliberately with notice.
Step 2 — Build the allowlist as data, not as code
Keep the list in configuration so the shadow and enforcing phases differ by one environment variable rather than by a code change. An exact-match Set avoids the substring flaws that make a hand-written comparison unsafe; the patterns worth using are set out in Dynamic Origin Validation Patterns.
// config/cors.js
const ALLOWLIST = new Set(
(process.env.CORS_ALLOWLIST || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
);
// CORS_MODE: "wildcard" | "shadow" | "enforce"
const MODE = process.env.CORS_MODE || "wildcard";
module.exports = { ALLOWLIST, MODE };
Step 3 — Deploy shadow mode and watch the denial counter
Shadow mode runs the real decision and records the outcome without acting on it. Every response still carries the wildcard, so no caller can break; meanwhile the counter tells you exactly how incomplete the list is.
const { ALLOWLIST, MODE } = require("./config/cors");
const metrics = require("./metrics");
function corsMiddleware(req, res, next) {
const origin = req.get("Origin");
if (!origin) return next(); // not a browser request; CORS does not apply
const allowed = ALLOWLIST.has(origin);
if (allowed) {
res.set("Access-Control-Allow-Origin", origin);
res.set("Vary", "Origin");
} else if (MODE === "wildcard") {
res.set("Access-Control-Allow-Origin", "*");
} else if (MODE === "shadow") {
res.set("Access-Control-Allow-Origin", "*");
metrics.increment("cors.shadow_denial", { origin, path: req.path });
}
// MODE === "enforce" and not allowed: emit no grant at all
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
res.set("Access-Control-Max-Age", "60");
return res.status(204).end();
}
next();
}
module.exports = corsMiddleware;
The decision this middleware makes is identical in all three modes for a known origin — only the unknown-origin path changes, which is what makes the rollout reversible:
Alert on the shadow counter rather than eyeballing it. A useful rule is that any origin appearing more than once in twenty-four hours must be resolved — added to the list, or its owner told the date it stops working.
Step 4 — Lower Max-Age, then flip to enforce
Ship Access-Control-Max-Age: 60 at least one full old-cache lifetime before the enforcing deploy. If the API previously advertised 86400, a browser can hold the old preflight result for a day, and you want that window drained before the policy tightens. Once the short value has been live longer than the old one, set CORS_MODE=enforce and restart.
The Nginx equivalent for teams that terminate CORS at the proxy uses a map with an empty default, which suppresses the header entirely for unknown origins:
map $http_origin $cors_allow_origin {
default "";
"https://shop.brightwave.co" $http_origin;
"https://admin.brightwave.co" $http_origin;
"https://partner-portal.mercora.example" $http_origin;
}
server {
listen 443 ssl;
server_name api.brightwave.co;
location /v2/ {
add_header Access-Control-Allow-Origin $cors_allow_origin always;
add_header Vary "Origin" always;
proxy_pass http://catalog_upstream;
}
}
Step 5 — Fix the cache before you celebrate
The wildcard had one accidental virtue: a single cached response was correct for every caller. A reflected origin is not, so the moment you start echoing values you must key the cache on Origin — otherwise the first caller’s grant is replayed to the second, and the failure appears only under cache warmth.
The cache-key mechanics and the CDN-specific pitfalls are covered in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation; ship that header in the same release as the mode flip, never afterwards.
Step 6 — Delete the fallback
Once the shadow counter has been at zero for a week under enforcement, remove the wildcard and shadow branches from the middleware and drop CORS_MODE from the environment. Leaving a dormant wildcard branch behind is how the policy quietly returns during the next incident, when somebody flips the variable at three in the morning and nobody flips it back. Restore Access-Control-Max-Age to its normal value in the same change.
Verification
Prove all three outcomes from the command line. An allowlisted origin must get its own value back:
curl -sS -D - -o /dev/null https://api.brightwave.co/v2/catalog \
-H 'Origin: https://shop.brightwave.co' \
| grep -iE '^(access-control-allow-origin|vary):'
An untrusted origin must get nothing — no wildcard, no reflection, no empty header:
curl -sS -D - -o /dev/null https://api.brightwave.co/v2/catalog \
-H 'Origin: https://evil.mercora.example' \
| grep -i '^access-control-allow-origin:'
Expected: no output at all. Then confirm the cache is segmented by requesting twice through the public hostname with two different origins and comparing the grants. Finish in the browser:
Security Boundary Note
Removing the wildcard is a genuine improvement, but only if what replaces it is an exact-match comparison. A migration that swaps * for origin.endsWith('.brightwave.co') has moved from a policy that grants everyone uncredentialed read access to one that grants https://brightwave.co.attacker.example credentialed access — strictly worse. The same applies to a regular expression whose dots are unescaped, or a prefix test that matches https://shop.brightwave.co.evil.example.
The second boundary is the one the migration unlocks: an allowlist makes Access-Control-Allow-Credentials: true legal for the first time, and it is tempting to enable it in the same release. Do not. Ship the allowlist, verify it, and treat credential support as its own change with its own review, because it is the step that converts a read-permission mistake into an authenticated-data disclosure. The trade-off between the two postures is laid out in Wildcard vs Dynamic Origin Reflection: When to Use Each, and development machines need their own handling as described in Safely Allowing localhost Origins in Development.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
| Writing the allowlist from memory instead of from logs | Rarely exercised integrations break on the enforcing deploy, often days later when a scheduled job runs | Run the observation phase for at least one full cycle of every scheduled caller |
Flipping to enforce while Access-Control-Max-Age is still large |
Some browsers act on a cached permissive preflight, so failures and rollbacks both lag by up to a day | Ship a 60-second Max-Age first and let the old value drain |
Reflecting the origin without adding Vary: Origin |
A shared cache replays one origin’s grant to another, producing intermittent blocks that no server log explains | Add Vary: Origin in the same release as the reflection |
Falling back to * when the origin is unknown |
The allowlist becomes decorative — every rejected origin still gets a working grant | Emit no Access-Control-Allow-Origin at all for a non-match |
FAQ
How long should the shadow phase run before I switch to enforce?
Long enough to cover the slowest cycle that reaches the API. Two weeks catches daily and weekly traffic, but a quarterly finance export or an annual audit tool will not appear in that window. Pick a duration that spans at least one full instance of every scheduled integration you know about, then treat the remaining shadow denials as the real signal: switch when the count has been flat at zero for several consecutive days, not when the calendar says so.
Do I need to change Access-Control-Max-Age during the migration?
Lower it before the enforcing deploy and raise it again afterwards. A browser that cached a permissive preflight will keep skipping the OPTIONS round trip until that entry expires, so a long Max-Age delays both the rollout and any rollback you need. A value of 60 seconds for the duration of the change keeps the feedback loop short, and the cache empties on its own within a minute of a revert.
What happens to clients that send no Origin header at all, like curl and mobile apps?
Nothing changes for them. CORS is enforced by browsers, and a request without an Origin header is not subject to it — native mobile clients, server-to-server calls, and command line tools read the response regardless of what Access-Control-Allow-Origin says. That is why the migration is safe for non-browser traffic, and also why the wildcard was never protecting anything: it only ever governed what browser script was permitted to read.