/commands/cors/allow-multiple-origins
Allow multiple origins in Access-Control-Allow-Origin
- tool
- CORS
- versions
- Fetch Standard, Node.js 18+
- os
- linux, macos, windows
Access-Control-Allow-Origin accepts a single origin or *, never a list. To allow several origins, keep an allowlist on the server, compare the request's Origin header with it, and when it matches send that one origin back in Access-Control-Allow-Origin, together with Vary: Origin so caches keep a separate copy per origin. Origins that are not on the list get no Access-Control-Allow-Origin header, and the browser blocks the response.
Reflect the origin from an allowlist (Node.js)
cat > api.js <<'EOF'
const http = require("http");
const allowed = new Set(["https://app.example.com", "https://admin.example.com"]);
http.createServer((req, res) => {
res.setHeader("Vary", "Origin");
const origin = req.headers.origin;
if (origin && allowed.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.writeHead(204);
return res.end();
}
res.setHeader("Content-Type", "application/json");
res.end('{"ok":true}');
}).listen(5208, "127.0.0.1");
setTimeout(() => process.exit(0), 10000);
EOF
node api.js &
sleep 1
for o in https://app.example.com https://admin.example.com https://evil.example; do
echo "== Origin: $o"
curl -s -i http://127.0.0.1:5208/items -H "Origin: $o" | tr -d '\r' | grep -iE '^(HTTP|access-control|vary)'
done
waitOutput
== Origin: https://app.example.com HTTP/1.1 200 OK Vary: Origin Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true == Origin: https://admin.example.com HTTP/1.1 200 OK Vary: Origin Access-Control-Allow-Origin: https://admin.example.com Access-Control-Allow-Credentials: true == Origin: https://evil.example HTTP/1.1 200 OK Vary: Origin
Each allowed origin gets its own value back; https://evil.example gets a 200 without the header, which is enough for the browser to refuse it. Vary: Origin is sent on every response, including the refused one, so a shared cache never mixes them up.
The same in nginx
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
proxy_pass http://127.0.0.1:3000;
}
}map sets $cors_origin to the request origin only for listed values. nginx does not send a header whose value is empty, so other origins get no header. always adds the header to error responses (4xx, 5xx) too. Use either nginx or the application, not both, or the header appears twice.
Ways to match origins
| Approach | When to use it | Watch out for |
|---|---|---|
| Exact allowlist (Set, array, map) | A known list of front ends | Scheme and port are part of the origin: http://localhost:3000 differs from http://localhost:5173 |
| Regular expression | Many subdomains of one domain | Anchor both ends, as in ^https://([a-z0-9-]+\.)?example\.com$; an unanchored example\.com also matches evil-example.com |
| Access-Control-Allow-Origin: * | Public data, no cookies or auth | Not allowed with credentials |
| Reflect any Origin | Never with credentials | Every site can read authenticated responses |
Pitfalls
- Comma-separated values or two
Access-Control-Allow-Originheaders are rejected by the browser with "The 'Access-Control-Allow-Origin' header contains multiple values". - The
Originheader has no trailing slash or path: compare againsthttps://app.example.com, nothttps://app.example.com/. - Credentialed requests need an explicit origin, never
*; see wildcard origin with credentials. - Verify each origin from the command line as in test CORS with curl.
FAQ
Can I put several origins in Access-Control-Allow-Origin separated by commas?
No. The header takes exactly one origin, the literal *, or null. A list such as https://a.example, https://b.example never matches, and the browser reports that the header contains multiple values.
Why do I need Vary: Origin?
The response now depends on the Origin request header. Without Vary: Origin, a browser cache or CDN can store the response made for one origin and serve it to another, which then fails CORS.
Is reflecting the Origin header back always safe?
Only when you compare it with an allowlist first. Echoing any Origin together with Access-Control-Allow-Credentials: true lets every website read authenticated responses from your API.