Fix Nginx CORS Error: No 'Access-Control-Allow-Origin' Header Is Present

intermediate Nginx2026-07-04| Nginx 1.14+ on Ubuntu 20.04/22.04, Debian, CentOS — acting as reverse proxy in front of Node.js, Flask, FastAPI, Go, or any backend API

Error Message

Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
#nginx#cors#http-headers#reverse-proxy#cross-origin#javascript#api

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 location block has any add_header directive, it completely wipes out all add_header directives from the parent block — they don't merge. This is the most surprising behavior. Add all CORS headers directly inside every location block 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.com and https://app.example.com are 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.com out of the box. Use the map approach 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.

Related Error Notes