NGINX / Server Setup

NGINX CORS Configuration: The Complete Guide

by ,


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth yum upgrades for production use, this is the repository for you.
Active subscription is required.

Cross-Origin Resource Sharing (CORS) is one of the most misunderstood aspects of web development. If you’ve ever seen the dreaded “Access to XMLHttpRequest has been blocked by CORS policy” error in your browser console, you know exactly what I mean. The frustrating part? Most NGINX CORS configuration guides on the internet are incomplete, outdated, or flat-out wrong.

In this comprehensive guide, you’ll learn how to properly configure CORS in NGINX, understand why most configurations fail, and implement production-ready solutions that actually work—including handling preflight requests, credentials, and multiple origins.

What is CORS and Why Does NGINX Need It?

CORS (Cross-Origin Resource Sharing) is a security mechanism that browsers enforce to prevent malicious websites from making requests to your server. As explained in the MDN Web Docs on CORS, when a web page at app.example.com tries to fetch data from api.example.org, the browser blocks this request by default unless the server explicitly allows it through CORS headers.

The key header that controls this is Access-Control-Allow-Origin. When your NGINX server returns this header with an appropriate value, the browser permits the cross-origin request.

Here’s what happens without proper CORS headers:

  1. Browser sends a request to your API
  2. Your server processes it and returns data
  3. Browser checks for CORS headers
  4. No Access-Control-Allow-Origin header found
  5. Browser blocks the response and throws an error

The server did its job—it processed the request and returned data. But without the CORS header, the browser refuses to let JavaScript access the response.

Why Most NGINX CORS Guides Are Wrong

Before diving into configurations, let’s address the elephant in the room: most NGINX CORS tutorials on the internet are fundamentally flawed. Here’s why:

Problem 1: Missing the always Parameter

The most common mistake is omitting the always parameter from add_header directives. Consider this typical configuration you’ll find online:

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

This configuration will fail when your backend returns an error response (4xx or 5xx status codes). Here’s why:

NGINX’s add_header directive, by default, only adds headers to responses with these status codes: 200, 201, 204, 206, 301, 302, 303, 304, 307, and 308. When your API returns a 400 Bad Request, 401 Unauthorized, 403 Forbidden, or 500 Internal Server Error, the CORS headers are silently dropped.

This behavior is defined directly in NGINX’s source code (ngx_http_headers_filter_module.c):

switch (r->headers_out.status) {
case NGX_HTTP_OK:
case NGX_HTTP_CREATED:
case NGX_HTTP_NO_CONTENT:
/* ... other 2xx and 3xx codes ... */
    safe_status = 1;
    break;
default:
    safe_status = 0;
    break;
}

if (!safe_status && !h[i].always) {
    continue;  // Header is SKIPPED!
}

The result? Your JavaScript code cannot even read the error message from your API because the browser blocks the entire response. You see a generic CORS error instead of the actual 401 “Invalid token” message.

For more details on the pitfalls of add_header, see our article on the pitfalls of add_header in NGINX.

The fix is simple—add always:

add_header Access-Control-Allow-Origin "*" always;

Problem 2: Ignoring Preflight Requests

Many guides show how to add CORS headers but completely ignore preflight requests. A preflight is an OPTIONS request that browsers send before certain “non-simple” requests to check if the actual request is permitted.

Browsers send preflight requests when:
– Using methods other than GET, HEAD, or POST
– Using POST with content types other than application/x-www-form-urlencoded, multipart/form-data, or text/plain
– Setting custom headers like Authorization or X-Custom-Header

If your NGINX configuration doesn’t handle OPTIONS requests, your preflight will fail, and the actual request will never be sent.

Problem 3: Credentials with Wildcards

You cannot use Access-Control-Allow-Origin: * when your requests include credentials (cookies, HTTP authentication). The browser explicitly rejects this combination. Many guides don’t mention this critical limitation.

Basic NGINX CORS Configuration

Let’s start with a simple, working configuration for APIs that don’t require credentials:

server {
    listen 80;
    server_name api.example.com;

    location / {
        # CORS headers for all responses
        add_header Access-Control-Allow-Origin "*" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;

        # Handle preflight requests
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin "*" always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            return 204;
        }

        proxy_pass http://backend;
    }
}

If you’re using this with a reverse proxy setup, make sure to include the CORS headers in your location block that handles the proxying.

A Note About if in NGINX

You may have heard that “if is evil” in NGINX configurations. However, using if with return is one of the explicitly safe patterns. The problems occur when combining if with proxy_pass, fastcgi_pass, or try_files. Using if ($request_method = OPTIONS) { return 204; } is perfectly safe and is the standard way to handle preflight requests.

Understanding This Configuration

Access-Control-Allow-Origin "*": The wildcard allows requests from any origin. Use this for public APIs.

Access-Control-Allow-Methods: Specifies which HTTP methods are permitted. Always include OPTIONS for preflight.

Access-Control-Allow-Headers: Lists which headers can be sent with requests. Add any custom headers your API expects.

Access-Control-Max-Age 86400: Tells browsers to cache the preflight response for 24 hours (86400 seconds), reducing the number of OPTIONS requests.

The OPTIONS block: When a preflight request arrives, we return a 204 No Content response with all necessary CORS headers. The actual request to the backend is never made for preflight—NGINX handles it directly.

Why duplicate headers in the OPTIONS block? NGINX’s if directive creates a separate configuration context. Headers defined outside the if block aren’t inherited inside it. This is a common source of confusion and bugs.

Better Alternative: Using more_set_headers

The add_header directive has two annoying behaviors:
1. Requires the always parameter to work on error responses
2. Headers don’t inherit into if blocks, requiring duplication

The more_set_headers directive from the headers-more module solves both problems:

# Requires: load_module modules/ngx_http_headers_more_filter_module.so;

map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        # Headers are added to ALL responses (no 'always' needed)
        # Headers ARE inherited into if blocks (no duplication needed)
        more_set_headers "Access-Control-Allow-Origin: $cors_origin";
        more_set_headers "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS";
        more_set_headers "Access-Control-Allow-Headers: Content-Type, Authorization";

        if ($request_method = OPTIONS) {
            more_set_headers "Access-Control-Max-Age: 86400";
            more_set_headers "Content-Length: 0";
            return 204;
        }

        proxy_pass http://backend;
    }
}

Advantages of more_set_headers

  1. No always parameter needed — headers are added to all responses by default, including 4xx and 5xx errors
  2. Proper inheritance — headers from parent blocks are inherited into if blocks
  3. Cleaner configuration — no need to duplicate headers inside the OPTIONS block
  4. Can also clear headers — use more_clear_headers to remove unwanted headers

Installing the Headers-More Module

On RHEL/Rocky Linux/AlmaLinux with the GetPageSpeed repository:

dnf install nginx-mod-headers-more

Then add to your nginx.conf:

load_module modules/ngx_http_headers_more_filter_module.so;

If the headers-more module isn’t available on your system, continue using the add_header configurations with the always parameter as shown in the other examples.

NGINX CORS with Multiple Origins

Using a wildcard (*) isn’t always appropriate. When you need to allow specific origins while blocking others, use the map directive:

# This block must be at the http level (nginx.conf or included file)
map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
    "https://dashboard.example.com" $http_origin;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        # Handle preflight
        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 "Content-Type, Authorization, X-Requested-With" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            return 204;
        }

        # CORS headers for actual requests
        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 "Content-Type, Authorization, X-Requested-With" always;

        proxy_pass http://backend;
    }
}

How the Map Directive Works

The map directive creates a variable ($cors_origin) based on the value of another variable ($http_origin—the Origin header from the request).

  • If the Origin header matches one of your allowed domains, $cors_origin is set to that domain
  • If the Origin doesn’t match, $cors_origin is set to an empty string

When $cors_origin is empty, the Access-Control-Allow-Origin header is still sent, but with an empty value. Browsers treat this as “not allowed” and block the request. This is actually the correct behavior—the absence of a valid origin in the response header signals denial.

Using Regex Patterns for Subdomains

You can use regular expressions in the map directive to match patterns:

map $http_origin $cors_origin {
    default "";
    "https://example.com" $http_origin;
    "~^https://.*\.example\.com$" $http_origin;
}

The ~ prefix indicates a case-sensitive regex. This pattern matches any subdomain of example.com (with HTTPS only).

Security warning: Be careful with regex patterns. A pattern like ~example\.com would match malicious-example.com, which is probably not what you want. Always anchor your patterns properly.

NGINX CORS Configuration with Credentials

When your API uses cookies, HTTP authentication, or client certificates, you must:

  1. Set Access-Control-Allow-Credentials: true
  2. Specify exact origins (no wildcards)
  3. Mirror the requesting origin in Access-Control-Allow-Origin
map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        # Handle preflight
        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 "Content-Type, Authorization, X-Requested-With" always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            return 204;
        }

        # CORS headers for actual requests
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;

        proxy_pass http://backend;
    }
}

Critical: When using Access-Control-Allow-Credentials: true, the Access-Control-Allow-Origin header cannot be *. It must be the exact origin that made the request. This is why we use the map directive to reflect the allowed origin back to the client.

Client-Side Requirements

For credentialed requests to work, your JavaScript must also opt in:

// Fetch API
fetch('https://api.example.com/data', {
    credentials: 'include'
});

// XMLHttpRequest
xhr.withCredentials = true;

Understanding Preflight Requests in Depth

Preflight requests deserve special attention because they’re the source of most CORS-related debugging sessions. Let’s understand exactly when browsers send them and how to handle them properly.

When Browsers Send Preflight Requests

A request is considered “simple” (no preflight) if it meets ALL these criteria:

  1. Method is GET, HEAD, or POST
  2. Only these headers are set: Accept, Accept-Language, Content-Language, Content-Type
  3. Content-Type (if set) is only: application/x-www-form-urlencoded, multipart/form-data, or text/plain

Any deviation from these rules triggers a preflight. Common triggers include:

  • Using PUT, PATCH, or DELETE methods
  • Setting the Authorization header
  • Setting Content-Type: application/json
  • Using custom headers like X-Requested-With

Anatomy of a Preflight Request

A preflight request looks like this:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

The browser is asking: “Can I make a POST request with these headers from this origin?”

Your server must respond with appropriate headers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

If this response is valid, the browser proceeds with the actual request. If not, the actual request is never sent.

Optimizing Preflight with Access-Control-Max-Age

Every preflight request adds latency. The Access-Control-Max-Age header tells browsers how long to cache the preflight response:

add_header Access-Control-Max-Age 86400 always;  # 24 hours

Browsers have different maximum values they’ll honor:
– Chrome: 7200 seconds (2 hours)
– Firefox: 86400 seconds (24 hours)
– Safari: No documented limit

Setting a value higher than the browser’s maximum is harmless—it will just use its maximum.

NGINX Access-Control-Allow-Origin: Complete Examples

Here are production-ready configurations for common scenarios:

Public API (No Authentication)

server {
    listen 80;
    server_name api.example.com;

    location / {
        add_header Access-Control-Allow-Origin "*" always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type" always;

        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin "*" always;
            add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            return 204;
        }

        proxy_pass http://backend;
    }
}

Private API with Token Authentication

map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $http_origin;
    "https://staging.example.com" $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 "Content-Type, Authorization, X-Requested-With" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            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 "Content-Type, Authorization, X-Requested-With" always;

        proxy_pass http://backend;
    }
}

API with Cookies/Sessions

map $http_origin $cors_origin {
    default "";
    "https://app.example.com" $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 "Content-Type, X-Requested-With" always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0 always;
            return 204;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;

        proxy_pass http://backend;
    }
}

Exposing Custom Response Headers

If your API returns custom headers that JavaScript needs to read, use Access-Control-Expose-Headers:

add_header Access-Control-Expose-Headers "X-Total-Count, X-Page-Number" always;

By default, only these headers are exposed to JavaScript: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, and Pragma.

Testing Your NGINX CORS Configuration

After setting up CORS, test it thoroughly before deploying:

Testing with curl

# Test preflight request
curl -I -X OPTIONS \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  https://api.example.com/endpoint

# Test actual request
curl -I \
  -H "Origin: https://app.example.com" \
  https://api.example.com/endpoint

# Test error response (verify headers are present on errors)
curl -I \
  -H "Origin: https://app.example.com" \
  https://api.example.com/endpoint/that-returns-404

What to look for:
Access-Control-Allow-Origin header is present and has the correct value
– For preflight: all requested headers are in Access-Control-Allow-Headers
– For preflight: requested method is in Access-Control-Allow-Methods
– For credentials: Access-Control-Allow-Credentials: true is present
– On error responses: CORS headers are still present (the always parameter working)

Testing with Browser DevTools

  1. Open your web application
  2. Open DevTools (F12) → Network tab
  3. Make a cross-origin request
  4. Look for:
    • Preflight OPTIONS request (if applicable)
    • CORS headers in the response
    • Any CORS errors in the Console tab

Common NGINX CORS Mistakes and Solutions

Let’s review the most frequent mistakes and their solutions:

Mistake 1: Headers Disappear on Error Responses

Symptom: CORS works for successful responses but fails for 4xx/5xx errors. You might see issues similar to those described in our 502 Bad Gateway troubleshooting guide.

Cause: Missing always parameter.

Solution:

# Wrong
add_header Access-Control-Allow-Origin "*";

# Correct
add_header Access-Control-Allow-Origin "*" always;

Mistake 2: Duplicate Headers

Symptom: Response contains multiple Access-Control-Allow-Origin headers, browser rejects it.

Cause: Both NGINX and your backend application are setting CORS headers.

Solution: Configure CORS in only one place. Either:
– Remove CORS headers from your application and handle in NGINX
– Or remove from NGINX and handle in your application

If you must keep both, use proxy_hide_header to remove the upstream headers:

proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Credentials;

Mistake 3: Wildcard with Credentials

Symptom: Browser shows “The value of the ‘Access-Control-Allow-Origin’ header must not be ‘*’ when the request’s credentials mode is ‘include’.”

Cause: Using * with Access-Control-Allow-Credentials: true.

Solution: Use specific origins with the map directive as shown earlier.

Mistake 4: Missing Headers in Preflight Response

Symptom: Preflight succeeds but actual request fails with “Request header field X-Custom-Header is not allowed.”

Cause: Custom header not listed in Access-Control-Allow-Headers.

Solution: Add all custom headers your API uses:

add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Custom-Header, X-Another-Header" always;

Mistake 5: Not Handling OPTIONS Method

Symptom: OPTIONS requests return 405 Method Not Allowed or pass through to backend.

Cause: No explicit handling for OPTIONS method.

Solution: Add the OPTIONS handling block shown in configurations above.

CORS and Caching Considerations

When you’re using CORS with multiple origins and a CDN or caching layer, you need to be careful about the Vary header. For a deeper understanding of caching headers in NGINX, see our guide on browser caching for static files:

add_header Vary "Origin" always;

This tells caches that the response varies based on the Origin header. Without this, a cache might serve a response meant for app.example.com to a request from admin.example.com, causing CORS failures.

Performance Optimization Tips

1. Maximize Preflight Cache Time

add_header Access-Control-Max-Age 86400 always;

2. Use Simple Requests When Possible

Design your API to allow simple requests where possible:
– Accept Content-Type: text/plain or form-encoded data for simple endpoints
– Avoid requiring custom headers when not necessary

3. Consider Same-Origin Architecture

If CORS is causing significant issues, consider:
– Serving your frontend and API from the same origin
– Using a reverse proxy to make the API appear same-origin
– Server-side API calls instead of browser-side

Security Considerations

CORS is a security feature, and misconfiguring it can expose your application to attacks:

  1. Never use Access-Control-Allow-Origin: * for authenticated APIs—this allows any website to make authenticated requests on behalf of your users.

  • Validate origins on the server side—don’t just reflect the Origin header without checking it against an allowlist.

  • Be specific with allowed methods and headers—only allow what your API actually needs.

  • Consider using HTTPS only—your allowed origins should use HTTPS to prevent man-in-the-middle attacks.

  • For additional security measures, consider combining CORS with rate limiting to protect your API endpoints.

    Conclusion

    Proper NGINX CORS configuration requires attention to detail that many guides overlook. The key takeaways are:

    1. Always use the always parameter to ensure headers are sent on error responses (or use more_set_headers which does this automatically)
    2. Handle OPTIONS preflight requests explicitly with a return 204 block
    3. Use the map directive for multiple origins instead of hardcoding values
    4. Never use wildcards with credentials—the browser will reject it
    5. Test error responses specifically—this is where most configurations fail
    6. Consider more_set_headers for cleaner configurations without duplication

    With these configurations, your CORS setup will be robust enough for production use, handling edge cases that simpler configurations miss.

    Quick Reference: NGINX CORS Headers

    Header Purpose Example
    Access-Control-Allow-Origin Specifies allowed origin(s) * or specific domain
    Access-Control-Allow-Methods Allowed HTTP methods GET, POST, PUT, DELETE, OPTIONS
    Access-Control-Allow-Headers Allowed request headers Content-Type, Authorization
    Access-Control-Allow-Credentials Allow cookies/auth true
    Access-Control-Max-Age Preflight cache time (seconds) 86400
    Access-Control-Expose-Headers Headers JS can read X-Total-Count, X-Custom

    Further Reading

    D

    Danila Vershinin

    Founder & Lead Engineer

    NGINX configuration and optimizationLinux system administrationWeb performance engineering

    10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    You may use these HTML tags and attributes:

    <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

    This site uses Akismet to reduce spam. Learn how your comment data is processed.