How to allow multiple origins in Access-Control-Allow-Origin (CORS)

The header can hold only one origin (or *). To allow several sites, check the request's Origin against a list and send back that one origin.

1. Why a list doesn't work

We sent Access-Control-Allow-Origin: http://localhost:8965, http://127.0.0.1:8966 and called the API from both pages in Chrome: both were blocked. The browser wants exactly one origin, or *.

2. nginx: echo the origin from an allow-list

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"   $http_origin;
    "https://admin.example.com" $http_origin;
    "~^http://localhost(:[0-9]+)?$" $http_origin;
}

server {
    listen 80;
    location /api/ {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary Origin always;
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Vary Origin always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
            add_header Access-Control-Max-Age 600 always;
            return 204;
        }
        return 200 '{"ok":true}\n';
    }
}

How it works: map compares the request's Origin with the list and sets $cors_origin to that origin, or to an empty string. nginx doesn't send a header whose value is empty, so other sites get no CORS header and the browser blocks them. Vary: Origin stops caches from serving one site's answer to another. The OPTIONS branch answers preflight requests (sent before PUT/DELETE or requests with an Authorization or JSON Content-Type header). Inside if, the add_header lines are repeated on purpose: nginx doesn't inherit outer add_header lines into a block that has its own.

3. What we tested

RequestResult
GET from http://localhost:8965 (matches the localhost rule)Allowed
PUT with Authorization + JSON from the same page (preflight)Allowed
GET and PUT from http://127.0.0.1:8966 (not in the list)Blocked
Origin https://app.example.com.evil.com / null / none (curl)No CORS header sent
* without credentials, from both pagesAllowed
* with credentials: 'include'Blocked

4. Mistakes to avoid

Check a response with curl -sI -H 'Origin: https://app.example.com' https://api.your-site.com/. To see the headers a page gets in the browser, use DevTools → Network → the request → Headers.

Frequently asked questions

Can Access-Control-Allow-Origin have multiple values?

No. In our Chrome test, a comma-separated list was rejected for every origin. Send back one origin, chosen from an allow-list based on the request's Origin header.

Why do I need Vary: Origin?

Because the response changes with the Origin header. Without Vary: Origin, a CDN or browser cache can serve one site's CORS header to another site, which then gets blocked.

How do I allow localhost on any port?

Use an anchored regex such as ~^http://localhost(:[0-9]+)?$ in the nginx map. It matched localhost and localhost:3000 in our test, not localhost.evil.com.

Is Access-Control-Allow-Origin: * safe?

For public data that needs no login, yes. It doesn't work with cookies or other credentials, and any site can read the response.

Written with AI assistance. The nginx config was run in nginx:alpine 1.31.6 on 2026-10-01; every 'allowed' / 'blocked' result is a real fetch() from Chromium 153 on two different origins. PlainHeaders is our extension; the other methods are free.