Allow multiple CORS origins without reflecting an attacker
A CORS response cannot place two origins in Access-Control-Allow-Origin. The header value is one serialized origin or *. Supporting several trusted frontends therefore requires a per-request decision: parse the browser-…
A CORS response cannot place two origins in Access-Control-Allow-Origin. The header value is one serialized origin or *. Supporting several trusted frontends therefore requires a per-request decision: parse the browser-supplied Origin, compare it with an exact server-side allowlist, and return that one origin only when it is approved.
Separate the symptom from the trust decision
A browser console error is the last step of the diagnosis. First capture the request and response at the application, reverse proxy, and CDN. Look for duplicate headers added by multiple layers, redirects that lose headers, and an OPTIONS response that differs from the actual request.
curl -i https://api.example.test/profile \
-H 'Origin: https://app.example'
curl -i -X OPTIONS https://api.example.test/profile \
-H 'Origin: https://admin.example' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: content-type'
CORS controls whether browser JavaScript may read a response. It is not authentication, authorization, CSRF protection, or a firewall. A non-browser client can send any Origin value, and a disallowed cross-origin form may still cause a state-changing request unless the application has appropriate CSRF defenses.
Model origins exactly
An origin is a tuple of scheme, host, and port. Paths do not belong in an origin, and https://app.example is not the same origin as http://app.example or https://app.example:8443. Keep normalized, exact values in configuration:
allowedOrigins = {
"https://app.example",
"https://admin.example"
}
Do not use substring checks such as “ends with example,” permissive regular expressions, or blind reflection. They commonly admit lookalike hosts such as app.example.attacker.test. Avoid allowing the serialized null origin: sandboxed and non-hierarchical documents can produce it.
Return one approved origin and vary the cache
origin = request.headers["Origin"]
if origin in allowedOrigins:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers.append("Vary", "Origin")
else:
omit Access-Control-Allow-Origin
Vary: Origin is not decorative. Without it, a shared cache can store a response selected for one origin and reuse it for another, causing either intermittent failures or unintended disclosure. Preserve any existing Vary values instead of overwriting them. Review CDN cache keys and edge functions as well; not every intermediary handles dynamic CORS correctly by default.
If the resource is intentionally public and requests carry no credentials, Access-Control-Allow-Origin: * is simpler and more cache-efficient. Credentialed requests cannot use that wildcard. When cookies or HTTP authentication are involved, return an explicit approved origin and Access-Control-Allow-Credentials: true; then audit cookie SameSite, Secure, CSRF controls, and authorization independently.
Preflight is a capability check, not a blanket permit
For a valid preflight, approve only the requested methods and headers the endpoint actually supports:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://admin.example
Access-Control-Allow-Methods: PUT
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Do not mechanically echo Access-Control-Request-Headers. Validate each requested name against policy. Route OPTIONS through the CORS layer before authentication middleware that would otherwise redirect to a login page. Keep the actual response’s CORS decision consistent with the preflight decision.
Build a test matrix
Test every approved origin, a near-match attacker origin, no Origin, Origin: null, both preflighted and non-preflighted methods, success and error responses, redirects, and credentialed requests. Repeat through the CDN, load balancer, and application—not only against a developer server.
for origin in \
https://app.example \
https://admin.example \
https://app.example.attacker.test
do
curl -sS -D - -o /dev/null https://api.example.test/profile \
-H "Origin: $origin"
done
In browser developer tools, verify that the response has exactly one Access-Control-Allow-Origin, that its value exactly equals the requesting approved origin, and that Vary contains Origin. Confirm the attacker case receives no allow-origin header. Test application authorization separately; a CORS pass must never imply access to another user’s data.
Failure modes and rollback
- Comma-separated origins: invalid; return one selected origin.
- Two layers add the header: browsers reject multiple values. Assign ownership to one layer and remove the duplicate configuration.
- Blind origin reflection: effectively grants every requesting website read access, especially dangerous with credentials.
- Missing headers on errors: the browser hides useful error bodies. Apply the same approved-origin policy to controlled error responses without exposing sensitive diagnostics.
- Stale edge cache: purge affected objects after fixing
Varyor the cache key.
Roll out a new allowlist behind logging or a canary. Record approved and rejected origin decisions without logging credentials. If the change breaks clients, roll back to the previous exact allowlist—not to * with credentials—and invalidate incorrect cached variants. Narrow allowlists cost cache efficiency and operational coordination, but they keep trust explicit.
Attribution and verification
This guide was prompted by Thomas J Bradley’s Stack Overflow question and yesthatguy’s accepted answer, used under CC BY-SA. Its central allowlist-and-echo approach remains sound, but the implementation here avoids regex reflection hazards. The current single-origin grammar, credential rules, and CORS protocol were independently verified against the primary WHATWG Fetch Standard. Practical header and cache behavior was cross-checked with Mozilla’s current Access-Control-Allow-Origin reference and the HTTP field semantics in RFC 9110 §12.5.5.
Primary source: Review the official reference ↗