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.