When JavaScript on https://app.example.com calls an API on https://api.example.com, the browser enforces the same-origin policy: the request is blocked unless the API responds with CORS (Cross-Origin Resource Sharing) headers allowing it. You can set those headers in the application or in Nginx in front of it. This guide covers the Nginx side.

If you want the background first, see CORS explained.

The quick way (public APIs only)

For a truly public, read-only API without cookies or auth:

location /api/ {
    add_header Access-Control-Allow-Origin "*" always;
    proxy_pass http://127.0.0.1:3000;
}

* allows any origin — but browsers refuse * when the request includes credentials (cookies, HTTP auth). For anything with logins, list allowed origins explicitly.

Allow specific origins with a map

In the http {} context:

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"     $http_origin;
    "https://admin.example.com"   $http_origin;
    "~^https://([a-z0-9-]+)\.example\.com$" $http_origin;   # any subdomain
}

The map echoes the request’s Origin back only if it’s on the list; otherwise the variable is empty and Nginx doesn’t send the header (add_header skips empty values).

In the server block:

location /api/ {
    # Preflight
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Max-Age 86400 always;
        add_header Vary "Origin" always;
        return 204;
    }

    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Expose-Headers "Content-Disposition, X-Total-Count" always;
    add_header Vary "Origin" always;

    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
}

(if with return and add_header is a safe use of if in a location.)

What the headers mean

Header Purpose
Access-Control-Allow-Origin Which origin may read the response
Access-Control-Allow-Methods Methods allowed (preflight)
Access-Control-Allow-Headers Request headers allowed (preflight) — e.g. Authorization, Content-Type: application/json
Access-Control-Allow-Credentials Allow cookies/auth; requires a specific origin, not *
Access-Control-Expose-Headers Response headers JavaScript may read
Access-Control-Max-Age How long browsers cache the preflight result
Vary: Origin Caches must store separate responses per origin

Preflight requests

Browsers send an OPTIONS preflight before “non-simple” requests — e.g. PUT/DELETE, JSON bodies (Content-Type: application/json) or custom headers like Authorization. If the preflight response lacks the right headers, the real request is never sent. Answering preflights in Nginx (as above) is fast and keeps them away from your app. Access-Control-Max-Age reduces how often they occur.

Don’t set CORS twice

If the application (Express cors, Django CORS headers, Laravel) already sends CORS headers and Nginx adds them too, browsers see duplicate Access-Control-Allow-Origin headers and reject the response (“contains multiple values”). Choose one place. To strip the app’s headers in Nginx: proxy_hide_header Access-Control-Allow-Origin;.

Serving fonts and assets cross-origin

Web fonts loaded from another domain (e.g. a CDN subdomain) need CORS too:

location ~* \.(woff2?|ttf|otf)$ {
    add_header Access-Control-Allow-Origin "https://example.com" always;
    expires 30d;
}

Testing

# Simple request
curl -sI -H "Origin: https://app.example.com" https://api.example.com/api/items | grep -i access-control

# Preflight
curl -si -X OPTIONS https://api.example.com/api/items \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" | head -20

# Disallowed origin: no Access-Control-Allow-Origin in the response
curl -sI -H "Origin: https://evil.example" https://api.example.com/api/items | grep -i access-control

Avoid CORS altogether

Serving the front end and API from the same origin — e.g. example.com for the app and example.com/api/ proxied to the backend — removes the need for CORS entirely. See deploying a React app with Nginx.

Remember: CORS protects users’ browsers, not your API. It doesn’t stop curl, scripts or servers from calling your endpoints — authentication and rate limiting do. See API security best practices.

  • Nginx
  • CORS
  • APIs