The Error
Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
This shows up in the browser console when your frontend (on one domain) tries to call a backend on a different domain, subdomain, or port — and Nginx doesn't send back the required CORS headers. The browser blocks the response before your JavaScript ever sees it.
Why It Happens
Browsers enforce the Same-Origin Policy. If the frontend origin differs from the backend origin (different domain, subdomain, or port), the browser checks for an Access-Control-Allow-Origin header in the response. Nginx doesn't add this header by default — you have to configure it explicitly.
Step-by-Step Fix
1. Find Your Nginx Config
ls /etc/nginx/sites-enabled/
cat /etc/nginx/sites-enabled/your-api.conf
2. Add CORS Headers to the Server Block
Single allowed origin (most common case)
server {
listen 80;
server_name api.example.com;
location / {
# Handle preflight OPTIONS request first
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept' always;
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept' always;
proxy_pass http://localhost:8080;
}
}
The always flag on every add_header is not optional. Without it, Nginx only attaches headers to 2xx responses — error responses (4xx, 5xx) won't get them, so the browser just shows a CORS error instead of the real error. Very frustrating to debug.
Multiple allowed origins (dev + staging + prod)
When you need to whitelist several specific origins, use a map block — put it outside the server block, usually at the top of the config or in /etc/nginx/conf.d/cors.conf:
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://staging.example.com" $http_origin;
"http://localhost:3000" $http_origin;
}
server {
listen 80;
server_name api.example.com;
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept' always;
add_header 'Access-Control-Max-Age' 1728000;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://localhost:8080;
}
}
The Vary: Origin header matters here — without it, CDNs or proxies might cache a response with one origin's header and serve it to a different origin.
Public API — allow any origin
add_header 'Access-Control-Allow-Origin' '*' always;
Only use the wildcard for genuinely public APIs. Browsers reject * when credentials (cookies, Authorization header) are involved.
Requests with cookies or Authorization headers
You must specify the exact origin (no wildcard) and add the credentials header:
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
3. Test Config and Reload
# Validate syntax first
sudo nginx -t
# Reload without dropping connections
sudo nginx -s reload
Verify the Fix
curl — fastest way to check
# Check regular GET request
curl -I \
-H "Origin: https://app.example.com" \
https://api.example.com/data
# Check preflight OPTIONS request
curl -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization" \
-I https://api.example.com/data
Look for Access-Control-Allow-Origin: https://app.example.com in the output. If curl shows it but the browser still complains, open an incognito window — browsers sometimes cache preflight failures.
Browser DevTools check
Network tab → click the failing request → Headers tab → Response Headers section. The header needs to be there. If you see it in curl but not here, check whether the request is going through a different route or a cached service worker is intercepting it.
Common Gotchas
- Child location blocks override parent headers: In Nginx, if a nested
locationblock has anyadd_headerdirective, it completely wipes out alladd_headerdirectives from the parent block — they don't merge. This is the most surprising behavior. Add all CORS headers directly inside everylocationblock that needs them. - Backend also sends CORS headers: If your app (Node, Flask, etc.) sets CORS headers AND Nginx adds them too, the response ends up with duplicates —
Access-Control-Allow-Origin: *, https://app.example.com. Browsers reject this. Handle CORS in exactly one place. - HTTP vs HTTPS origin mismatch:
http://app.example.comandhttps://app.example.comare different origins. Make sure the scheme in your config exactly matches what the browser sends. - Wildcard subdomains not natively supported: Nginx can't do
*.example.comout of the box. Use themapapproach above and list each subdomain explicitly. - Missing preflight handler: Browsers send an OPTIONS request before POST/PUT/DELETE with custom headers. If Nginx passes OPTIONS to your backend and the backend doesn't handle it, the preflight fails and the real request never fires.

