CORS Explained: The Complete Guide to Cross-Origin Resource Sharing

Everything you need to understand about CORS — from the same-origin policy that triggers it, through the preflight handshake, to production-hardened configurations for Express.js, Nginx, and Apache. Written from years of debugging cryptic browser errors at the worst possible times.

18 min read

Table of Contents

  1. Introduction: The Most Confusing Error in Web Development
  2. Same-Origin Policy Explained
  3. What Is CORS?
  4. Simple Requests vs Preflight Requests
  5. CORS Headers Deep Dive
  6. Preflight OPTIONS Request
  7. CORS with Credentials
  8. Setting Up CORS in Express.js / Node.js
  9. CORS in Nginx and Apache
  10. Common CORS Errors and How to Fix Them
  11. Security Implications
  12. Conclusion

Introduction: The Most Confusing Error in Web Development

If you have spent any time building web applications that talk to APIs, you have almost certainly encountered this error message in your browser console: Access to fetch at 'https://api.example.com/data' from origin 'https://myapp.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. The first time you see it, you have no idea what just happened. Your API endpoint works fine when you hit it with curl or Postman. It works from your server-side code. But the moment you try to call it from the browser, everything breaks. Welcome to CORS.

Cross-Origin Resource Sharing is one of those topics that every web developer eventually has to understand, yet it remains persistently confusing because the error messages are vague, the underlying mechanism is non-obvious, and the fixes people find on Stack Overflow often involve disabling security features entirely. I have seen developers paste Access-Control-Allow-Origin: * into their production configs just to make the error go away, without understanding what they just opened up. I have seen teams install browser extensions that disable CORS checks, then wonder why their app works in development but breaks in production. I have even seen backend developers who thought CORS was a frontend problem that frontend developers should fix.

None of that is acceptable in production software. CORS exists for a reason. It is a security mechanism built into every modern browser, and understanding it properly is a non-negotiable skill for anyone building web applications. This guide will take you from the foundational concepts through every header, every request type, and every configuration option, with real code examples for the most common server environments. By the end, you will not just know how to fix CORS errors — you will understand why they happen and how to configure CORS correctly for any architecture.

Same-Origin Policy Explained

Before you can understand CORS, you need to understand what it is relaxing: the Same-Origin Policy (SOP). The same-origin policy is the fundamental security model of the web. It has been a core part of browser security since Netscape Navigator 2.0 in 1995, and every modern browser enforces it. Without it, the web as we know it would be completely insecure.

The same-origin policy states that a script running on one origin cannot read data from a different origin. Two URLs have the same origin if and only if they share the same protocol (scheme), host (domain), and port. If any one of those three differs, the origins are different, and the browser will restrict cross-origin interactions. Here are concrete examples:

URL A URL B Same Origin? Reason
https://example.com/page https://example.com/other Yes Same scheme, host, port
https://example.com http://example.com No Different scheme (https vs http)
https://example.com https://api.example.com No Different host (subdomain counts)
https://example.com https://example.com:8443 No Different port (443 vs 8443)
https://example.com https://other-site.com No Different host entirely

Why does the browser enforce this? Consider what would happen without it. You log into your bank at https://mybank.com. Your browser stores your session cookie. Then you visit a malicious website at https://evil-site.com. Without the same-origin policy, JavaScript on that evil site could make a fetch request to https://mybank.com/api/transfer, and the browser would happily attach your bank's session cookie to that request. The evil site could read the response, extract your account balance, initiate transfers, and you would never know. The same-origin policy prevents this by blocking scripts on one origin from reading responses from another origin.

It is critical to understand what the same-origin policy does and does not block. The browser does send the cross-origin request. The HTTP request leaves the browser and reaches the server. The server processes it and sends a response. The same-origin policy blocks the response from being read by JavaScript. The request itself is not prevented (in most cases). This distinction matters because side effects (database writes, email sends, etc.) can still happen even if the browser blocks the response. This is why CSRF (Cross-Site Request Forgery) is a separate concern from CORS.

Key Insight: The same-origin policy is enforced by the browser, not the server. Server-to-server communication is never subject to CORS restrictions. This is why your API works fine from curl, Postman, or your backend code, but fails from the browser. The browser is the one making the security decision.

There are some important exceptions to the same-origin policy. Images loaded via <img> tags, CSS loaded via <link> tags, scripts loaded via <script> tags, and media files are all exempt. The browser will load these resources cross-origin without restriction. The policy specifically targets programmatic access to cross-origin resources via JavaScript APIs like fetch(), XMLHttpRequest, and the Fetch API. This is why you can embed images from a CDN or load a JavaScript library from a third-party domain without CORS, but you cannot call a REST API from a different origin without the server explicitly allowing it.

What Is CORS?

CORS — Cross-Origin Resource Sharing — is a mechanism that allows a server to indicate which origins are permitted to read its responses. It is defined in the Fetch Living Standard maintained by the WHATWG and is implemented by every modern browser. CORS does not replace the same-origin policy; it is a controlled relaxation of it. Think of the same-origin policy as a locked door, and CORS as the mechanism by which the server hands out keys to specific visitors.

The CORS mechanism works through HTTP headers. When a browser makes a cross-origin request, the server's response must include specific headers that tell the browser "yes, I intended for this origin to access my resources." If those headers are missing or do not match the requesting origin, the browser blocks the response. The key header is Access-Control-Allow-Origin, which specifies which origin is allowed. But there are many more headers involved, especially for complex requests.

Here is the simplest possible CORS interaction. Your frontend at https://myapp.com makes a GET request to https://api.example.com/data:

# Request (sent by browser)
GET /data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com

# Response (from server)
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"message": "Hello from the API"}

The browser sees that the Access-Control-Allow-Origin header matches the requesting origin (https://myapp.com), so it allows JavaScript to read the response. If that header were missing, or if it specified a different origin, the browser would block the response and throw the CORS error you have seen so many times.

The important thing to understand is that CORS is a cooperative protocol between the browser and the server. The browser automatically adds the Origin header to cross-origin requests. The server checks this header and decides whether to include the appropriate CORS response headers. The browser then checks the response headers and decides whether to expose the response to JavaScript. At no point does the requesting page's JavaScript directly participate in this negotiation. It is handled entirely by the browser's fetch algorithm and the server's response headers.

Mental Model: Think of CORS as the server saying "I consent to being accessed by this origin." The browser is the enforcer. Without the server's explicit consent (via CORS headers), the browser will not let JavaScript read the response. The server is always in control of who can access its resources from a browser context.

Simple Requests vs Preflight Requests

Not all cross-origin requests are treated the same way. The CORS specification divides requests into two categories: simple requests that are sent directly, and preflighted requests that require the browser to send a preliminary OPTIONS request before the actual request. Understanding this distinction is essential because preflight requests are the source of most CORS confusion and performance concerns.

Simple Requests

A request is considered "simple" (officially called a request that does not trigger a preflight) if it meets all of the following conditions:

For simple requests, the browser sends the request directly with an Origin header, and the server responds with (or without) the CORS headers. The flow looks like this:

# 1. Browser sends request directly
GET /api/public-data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com

# 2. Server responds with CORS header
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"data": "public information"}

The critical implication of a simple request is that the server receives and processes the request regardless of CORS. The request hits your endpoint, your handler runs, database queries execute, and the response is generated. If the CORS headers are not present in the response, the browser simply hides the response from JavaScript. The request still happened. This is important to remember: a simple request with side effects (like a POST that creates a database record) will execute even if the browser ultimately blocks the response.

Preflight Requests

Any request that does not qualify as "simple" triggers a preflight. This includes requests that use methods like PUT, DELETE, or PATCH, requests that include custom headers like Authorization or X-Custom-Header, and requests with a Content-Type of application/json. Since virtually every modern API uses JSON payloads and Authorization headers, the vast majority of real-world API calls trigger preflight requests.

The preflight mechanism exists specifically to protect servers that were built before CORS existed. Before CORS, a server could safely assume that it would never receive a DELETE request from a browser with custom headers from a different origin, because the same-origin policy prevented it. CORS introduced the preflight as a way to ask the server "do you understand CORS, and do you consent to this type of request?" If the server does not respond correctly to the preflight, the browser never sends the actual request. This protects legacy servers from receiving unexpected cross-origin requests with dangerous methods.

The flow for a preflighted request has two steps:

# Step 1: Browser sends OPTIONS preflight
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

# Step 2: Server responds to preflight
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

# Step 3: Browser sends actual request (only if preflight succeeded)
DELETE /api/users/123 HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Authorization: Bearer eyJhbGciOi...

# Step 4: Server responds to actual request
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"deleted": true}
Performance Impact: Every preflighted cross-origin request results in two HTTP round trips: the OPTIONS preflight and the actual request. This can significantly impact perceived latency, especially on high-latency connections. Use the Access-Control-Max-Age header to let the browser cache preflight responses. Setting it to 86400 (24 hours) means the preflight only happens once per day per URL per browser session.

CORS Headers Deep Dive

CORS is controlled entirely through HTTP headers. There are request headers that the browser adds automatically, and response headers that the server must include to grant permission. Understanding every header and its nuances is essential for configuring CORS correctly. Here is the complete reference.

Request Headers (Set by the Browser)

Header Description Example
Origin The origin (scheme + host + port) of the page making the request. Added automatically by the browser on all cross-origin requests. Origin: https://myapp.com
Access-Control-Request-Method Used in preflight requests. Indicates which HTTP method the actual request will use. Access-Control-Request-Method: DELETE
Access-Control-Request-Headers Used in preflight requests. Lists the custom headers the actual request will include. Access-Control-Request-Headers: Authorization, Content-Type

Response Headers (Set by the Server)

Header Description Example Notes
Access-Control-Allow-Origin Specifies which origin is allowed to read the response. Can be a specific origin or * (wildcard). Access-Control-Allow-Origin: https://myapp.com Cannot use * when credentials are included. Must be an exact origin, not a pattern.
Access-Control-Allow-Methods Lists the HTTP methods allowed for cross-origin requests. Used in preflight responses. Access-Control-Allow-Methods: GET, POST, PUT, DELETE Only needed in preflight responses. Simple methods (GET, HEAD, POST) are always allowed.
Access-Control-Allow-Headers Lists the HTTP headers the client is allowed to use. Used in preflight responses. Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID CORS-safelisted headers (Accept, Content-Type with basic values, etc.) are always allowed.
Access-Control-Allow-Credentials Indicates whether the browser should include credentials (cookies, Authorization header) in the request. Access-Control-Allow-Credentials: true When true, Access-Control-Allow-Origin cannot be *. Must be a specific origin.
Access-Control-Max-Age How long (in seconds) the browser can cache the preflight response. Access-Control-Max-Age: 86400 Browsers have upper limits: Chrome caps at 7200 (2 hours), Firefox at 86400 (24 hours).
Access-Control-Expose-Headers Lists headers that JavaScript is allowed to read from the response. By default, only CORS-safelisted response headers are exposed. Access-Control-Expose-Headers: X-Total-Count, X-Request-ID Without this, custom response headers are invisible to JavaScript even if the request succeeds.

The most common mistake is treating Access-Control-Allow-Origin as a pattern-matching header. It is not. You cannot set it to *.example.com to allow all subdomains. You cannot set it to a comma-separated list of origins. The value must be either a single specific origin (like https://myapp.com) or the wildcard *. If you need to allow multiple specific origins, you must dynamically set the header based on the incoming Origin request header. We will cover how to do this in the configuration sections below.

Easily Forgotten: The Access-Control-Expose-Headers header is often overlooked. If your API returns pagination info in a custom header like X-Total-Count, your frontend JavaScript cannot read it unless you explicitly expose it. By default, only these response headers are accessible to JavaScript: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, and Pragma.

Preflight OPTIONS Request

The preflight request is an HTTP OPTIONS request that the browser sends automatically before certain cross-origin requests. It is the most misunderstood part of CORS, and it causes real confusion when developers see unexpected OPTIONS requests in their server logs or when their API frameworks do not handle OPTIONS methods at all. Let us walk through the entire preflight lifecycle step by step.

When Does the Browser Send a Preflight?

The browser sends a preflight whenever the actual request does not qualify as a "simple request." In practice, this means a preflight is triggered any time your request uses a method other than GET, HEAD, or POST; includes a Content-Type header of application/json (which is virtually every modern API call); or includes custom headers like Authorization. Since most API requests involve JSON payloads and auth headers, preflight requests are the norm, not the exception.

Step-by-Step Preflight Flow

Suppose your frontend code at https://dashboard.myapp.com makes this API call:

// Frontend code
const response = await fetch('https://api.myapp.com/users/42', {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer eyJhbGciOi...',
  },
  body: JSON.stringify({ name: 'Updated Name' }),
});

This request triggers a preflight because it uses the PUT method, includes Content-Type: application/json, and has an Authorization header. Here is what happens over the wire:

# STEP 1: Browser sends OPTIONS preflight (you did NOT write this code)
OPTIONS /users/42 HTTP/1.1
Host: api.myapp.com
Origin: https://dashboard.myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
# Note: No request body, no Authorization header on the preflight itself
# STEP 2: Server responds to preflight
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://dashboard.myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
# No response body
# STEP 3: Browser validates the preflight response
# Checks: Does Allow-Origin match? Does Allow-Methods include PUT?
# Does Allow-Headers include Authorization and Content-Type?
# All checks pass, so browser sends the actual request.
# STEP 4: Browser sends the actual PUT request
PUT /users/42 HTTP/1.1
Host: api.myapp.com
Origin: https://dashboard.myapp.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{"name": "Updated Name"}
# STEP 5: Server responds to actual request
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://dashboard.myapp.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"id": 42, "name": "Updated Name"}

Notice that the server must include CORS headers on both the preflight response (step 2) and the actual response (step 5). A common mistake is configuring CORS only for the OPTIONS handler and forgetting to include the headers on the actual response.

Common Preflight Pitfalls

There are several ways the preflight can fail. First, if your server does not handle the OPTIONS method at all, many frameworks return a 405 Method Not Allowed, and the browser sees a failed preflight. Second, if your server requires authentication on all routes (including OPTIONS), the preflight will fail because the browser never sends credentials on a preflight request — the Authorization header is not included in the OPTIONS request. You must exempt OPTIONS requests from authentication middleware. Third, some reverse proxies or load balancers strip the CORS headers or do not forward OPTIONS requests. Always verify that your CORS headers survive the entire chain from your application through any proxy layers to the browser.

Critical Mistake: Never require authentication on OPTIONS preflight requests. The browser sends the preflight without any credentials, so if your auth middleware rejects unauthenticated requests, the preflight will always fail with 401, and the actual request will never be sent. Exempt OPTIONS from your auth middleware.

CORS with Credentials

By default, cross-origin requests made by the browser do not include credentials. Credentials in this context means cookies, HTTP authentication (via the Authorization header), and TLS client certificates. If your API relies on cookies for session management or if you need the browser to automatically attach cookies to cross-origin requests, you must explicitly opt in to credentialed CORS on both the client and the server. This is where CORS gets particularly tricky, because the rules change significantly when credentials are involved.

Client-Side: Requesting with Credentials

To include credentials in a cross-origin request, you must set the credentials option in the Fetch API or the withCredentials property on XMLHttpRequest:

// Fetch API
const response = await fetch('https://api.myapp.com/user/profile', {
  method: 'GET',
  credentials: 'include',  // Send cookies with cross-origin request
});

// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.myapp.com/user/profile');
xhr.withCredentials = true;  // Send cookies with cross-origin request
xhr.send();

The credentials option accepts three values: 'omit' (never send credentials), 'same-origin' (only send credentials for same-origin requests, this is the default), and 'include' (always send credentials, even for cross-origin requests). For cross-origin requests that need cookies, you must use 'include'.

Server-Side: Allowing Credentials

When the server receives a credentialed cross-origin request, it must include the Access-Control-Allow-Credentials: true header in the response. But here is the critical constraint: when Access-Control-Allow-Credentials is true, the Access-Control-Allow-Origin header cannot be the wildcard *. It must be the exact origin of the requesting page. The same restriction applies to Access-Control-Allow-Headers and Access-Control-Allow-Methods — they cannot be wildcards when credentials are involved.

# WRONG - will not work with credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

# CORRECT - specific origin required
Access-Control-Allow-Origin: https://dashboard.myapp.com
Access-Control-Allow-Credentials: true

This is not a suggestion; it is a hard rule enforced by the browser. If you set Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true, the browser will reject the response entirely. This catches developers off guard because their simple CORS setup with * was working fine until they added credentials: 'include' on the client side.

The Dynamic Origin Pattern

Since you cannot use * with credentials, and since Access-Control-Allow-Origin only accepts a single origin value, you need a dynamic approach when multiple origins should be allowed with credentials. The standard pattern is to check the incoming Origin header against an allowlist and reflect it back if it matches:

// Express.js middleware for dynamic origin with credentials
const allowedOrigins = [
  'https://dashboard.myapp.com',
  'https://admin.myapp.com',
  'https://myapp.com',
];

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    // IMPORTANT: Vary header prevents caching issues with multiple origins
    res.setHeader('Vary', 'Origin');
  }
  next();
});
The Vary Header: When you dynamically set Access-Control-Allow-Origin based on the request's Origin header, you must include Vary: Origin in the response. This tells intermediate caches (CDNs, proxy servers) that the response varies based on the Origin header. Without it, a CDN might cache a response with Access-Control-Allow-Origin: https://myapp.com and serve it to a request from https://admin.myapp.com, which the browser will reject.

Cookie Requirements for Cross-Origin

Even with CORS properly configured, cookies have their own security attributes that affect cross-origin behavior. For a cookie to be sent with a cross-origin request, it must have SameSite=None and Secure attributes. The SameSite=None attribute tells the browser "this cookie should be sent with cross-site requests," and the Secure attribute is required whenever SameSite=None is used (browsers enforce this).

// Setting a cookie that works cross-origin
res.cookie('sessionId', sessionToken, {
  httpOnly: true,
  secure: true,           // Required for SameSite=None
  sameSite: 'none',       // Allow cross-origin sending
  domain: '.myapp.com',   // Share across subdomains
  maxAge: 24 * 60 * 60 * 1000,
});

If you set SameSite=Strict or SameSite=Lax (the default in modern browsers), the cookie will not be sent with cross-origin requests regardless of your CORS configuration. This is a common source of confusion: CORS is configured correctly, credentials: 'include' is set, Access-Control-Allow-Credentials: true is present, but the cookie still is not sent because its SameSite attribute prevents it.

Setting Up CORS in Express.js / Node.js

Express.js is the most common backend framework where developers encounter CORS issues. There are two main approaches: using the cors npm package (recommended for most cases), or implementing CORS middleware manually (when you need full control). Let us look at both.

Using the cors Package

npm install cors

The cors package provides a clean, declarative way to configure CORS. Here are progressively more sophisticated configurations:

const express = require('express');
const cors = require('cors');
const app = express();

// 1. Allow ALL origins (development only!)
app.use(cors());

// 2. Allow a specific origin
app.use(cors({
  origin: 'https://myapp.com',
}));

// 3. Allow multiple specific origins
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
}));

// 4. Dynamic origin with validation function
app.use(cors({
  origin: function (origin, callback) {
    const allowedOrigins = [
      'https://myapp.com',
      'https://admin.myapp.com',
      'https://staging.myapp.com',
    ];
    // Allow requests with no origin (mobile apps, curl, server-to-server)
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
}));

// 5. Full production configuration
app.use(cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  exposedHeaders: ['X-Total-Count', 'X-Request-ID'],
  credentials: true,
  maxAge: 7200,  // Cache preflight for 2 hours
  optionsSuccessStatus: 204,  // Some legacy browsers choke on 204
}));

Manual CORS Middleware

If you want full control over CORS behavior, or if you want to understand exactly what the cors package does under the hood, here is a complete manual implementation:

const allowedOrigins = new Set([
  'https://myapp.com',
  'https://admin.myapp.com',
]);

function corsMiddleware(req, res, next) {
  const origin = req.headers.origin;

  // Check if the origin is allowed
  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }

  // Handle preflight
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID');
    res.setHeader('Access-Control-Max-Age', '7200');
    return res.status(204).end();
  }

  // Expose custom headers for actual requests
  res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, X-Request-ID');

  next();
}

app.use(corsMiddleware);

Per-Route CORS Configuration

Sometimes different routes need different CORS policies. For example, your public API might allow any origin, while your admin endpoints should only accept requests from the admin dashboard:

const publicCors = cors({ origin: '*' });
const adminCors = cors({
  origin: 'https://admin.myapp.com',
  credentials: true,
});

// Public endpoint - any origin
app.get('/api/public/status', publicCors, (req, res) => {
  res.json({ status: 'ok' });
});

// Admin endpoint - restricted origin
app.get('/api/admin/users', adminCors, authenticate, (req, res) => {
  // ... admin-only logic
});
Express Middleware Order Matters: The CORS middleware must run before your route handlers and before any middleware that might send a response (like authentication middleware that returns 401). If your auth middleware rejects the request before the CORS middleware adds the headers, the browser will see a CORS error instead of a proper 401 response. Always put CORS middleware at the top of your middleware stack.

CORS in Nginx and Apache

Many production deployments use Nginx or Apache as a reverse proxy in front of the application server. In these setups, you often want to handle CORS at the proxy level rather than in your application code. This centralizes the CORS configuration, ensures consistent behavior across all backends, and prevents CORS headers from being duplicated (which itself causes errors).

Nginx Configuration

Here is a production-ready Nginx configuration that handles CORS with dynamic origin validation, preflight caching, and credentials support:

# /etc/nginx/conf.d/cors.conf
# Include this in your server or location block

# Map to validate and reflect the origin
map $http_origin $cors_origin {
    default "";
    "https://myapp.com"        $http_origin;
    "https://admin.myapp.com"  $http_origin;
    "https://staging.myapp.com" $http_origin;
}

server {
    listen 443 ssl;
    server_name api.myapp.com;

    location /api/ {
        # Handle preflight requests
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Request-ID' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Access-Control-Max-Age' 7200 always;
            add_header 'Content-Length' 0;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            return 204;
        }

        # CORS headers for actual requests
        add_header 'Access-Control-Allow-Origin' $cors_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Expose-Headers' 'X-Total-Count, X-Request-ID' always;
        add_header 'Vary' 'Origin' always;

        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

The map directive at the top is the key pattern. It checks the incoming Origin header against a whitelist and stores the result in $cors_origin. If the origin matches, it reflects the origin back. If it does not match, the variable is empty, and no CORS headers are sent. The always keyword ensures the headers are included even on error responses (4xx, 5xx), which is important because browsers check CORS headers regardless of the response status code.

Nginx add_header Gotcha: By default, add_header directives are NOT inherited by nested blocks and are NOT included on error responses. Use the always parameter to include headers on all response codes. Without always, a 500 error response will not have CORS headers, and the browser will show a CORS error instead of letting you see the actual error.

Apache Configuration

For Apache, use mod_headers and mod_rewrite to achieve similar functionality. First, ensure both modules are enabled:

a2enmod headers
a2enmod rewrite

Then configure CORS in your virtual host or .htaccess file:

# Apache CORS configuration
<IfModule mod_headers.c>
    # Set allowed origin dynamically based on request
    SetEnvIf Origin "^https://(myapp\.com|admin\.myapp\.com|staging\.myapp\.com)$" CORS_ORIGIN=$0

    Header always set Access-Control-Allow-Origin %{CORS_ORIGIN}e env=CORS_ORIGIN
    Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
    Header always set Access-Control-Expose-Headers "X-Total-Count, X-Request-ID" env=CORS_ORIGIN
    Header always set Vary "Origin"

    # Handle preflight requests
    <If "%{REQUEST_METHOD} == 'OPTIONS'">
        Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS"
        Header always set Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-ID"
        Header always set Access-Control-Max-Age "7200"
    </If>
</IfModule>

# Return 204 for preflight requests
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]

Avoiding Double Headers

A common problem when using a reverse proxy is that both the proxy and the application set CORS headers, resulting in duplicate headers. For example, the response might contain two Access-Control-Allow-Origin headers with different values. The browser treats this as an error and blocks the response. If you configure CORS at the proxy level, make sure your application does not also set CORS headers. Alternatively, use proxy_hide_header in Nginx to strip CORS headers from the upstream response before adding your own:

# Strip CORS headers from upstream before adding our own
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;
proxy_hide_header Access-Control-Expose-Headers;

Common CORS Errors and How to Fix Them

CORS error messages in browser consoles are notoriously unhelpful. They tell you something went wrong but rarely explain exactly what. Here is a reference table of the most common CORS errors, what causes them, and how to fix them.

Error Message Cause Fix
No 'Access-Control-Allow-Origin' header is present on the requested resource The server response does not include the Access-Control-Allow-Origin header at all. Add the header to your server response. Verify the header survives any reverse proxy layers. Check that the header is present on error responses too (use always in Nginx).
The 'Access-Control-Allow-Origin' header has a value that is not equal to the supplied origin The header is present but does not match the requesting origin. Often happens when the header is hardcoded to one origin but requested from another. Use dynamic origin reflection from an allowlist. Ensure you are setting the header to the exact requesting origin, not a different one.
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include' You set Access-Control-Allow-Origin: * but the client is sending credentials: 'include'. Replace * with the specific origin. Use dynamic origin reflection when supporting multiple origins.
Response to preflight request doesn't pass access control check The OPTIONS preflight response does not include correct CORS headers. Often because the server does not handle OPTIONS, returns 405, or auth middleware rejects it. Ensure your server handles OPTIONS requests. Exempt OPTIONS from authentication. Return 204 with CORS headers.
Method PUT is not allowed by Access-Control-Allow-Methods The preflight response's Access-Control-Allow-Methods header does not include the method the browser wants to use. Add the missing method to Access-Control-Allow-Methods in your preflight response.
Request header field authorization is not allowed by Access-Control-Allow-Headers The preflight response's Access-Control-Allow-Headers header does not include a header the browser wants to send. Add the missing header name to Access-Control-Allow-Headers in your preflight response.
The 'Access-Control-Allow-Origin' header contains multiple values The response contains more than one Access-Control-Allow-Origin header, typically because both the proxy and the application are setting it. Remove duplicate CORS headers. Configure CORS in only one layer (proxy or application, not both). Use proxy_hide_header in Nginx.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource Firefox's generic CORS error. Could be any of the above issues. Check the browser's Network tab. Inspect the actual response headers on the failed request. Compare them against the requirements above.

Debugging CORS Issues

When you encounter a CORS error, follow this systematic debugging process. First, open the browser's Network tab in developer tools and find the failed request. If there is an OPTIONS request, check its response headers and status code. If the OPTIONS request itself failed (no 2xx response), the preflight is the problem. If the OPTIONS succeeded but the actual request failed, check the CORS headers on the actual response.

Second, use curl to simulate both the preflight and the actual request from the command line. This lets you see the exact response headers without browser interference:

# Simulate a preflight request
curl -v -X OPTIONS https://api.myapp.com/users \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type"

# Simulate the actual request
curl -v -X PUT https://api.myapp.com/users/42 \
  -H "Origin: https://myapp.com" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-token" \
  -d '{"name": "Test"}'

Third, check each layer of your infrastructure independently. If you use a reverse proxy, send the curl request directly to the application server (bypassing the proxy) to determine whether the issue is in the proxy configuration or the application. CORS bugs that only manifest in production are almost always caused by a proxy or CDN layer stripping or duplicating headers.

Quick Diagnostic: If your API works from Postman or curl but fails from the browser, the issue is CORS (since Postman and curl do not enforce the same-origin policy). If it fails from curl too, the issue is with your API itself, not CORS.

Security Implications

CORS is a security mechanism, and misconfiguring it can open your application to serious attacks. The most dangerous mistake is treating CORS as an obstacle to overcome rather than a protection to configure correctly. Let us examine the real security risks of common CORS misconfigurations.

Why Access-Control-Allow-Origin: * Is Dangerous with Credentials

Setting Access-Control-Allow-Origin: * is fine for truly public resources that require no authentication — public APIs, open datasets, static assets. The problem arises when developers try to combine the wildcard with credentialed requests. The browser explicitly prevents this combination (as discussed in the credentials section), but some developers work around it by dynamically reflecting any origin that is sent in the request, effectively implementing "allow all origins" without using the literal *:

// DANGEROUS: Reflects any origin, effectively bypassing CORS
app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', req.headers.origin); // BAD!
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  next();
});

This is catastrophically insecure. It means any website on the internet can make authenticated requests to your API and read the responses. An attacker's site at https://evil-site.com can make a fetch request to your API with credentials: 'include', the user's cookies will be sent, the server will reflect https://evil-site.com as the allowed origin, and the attacker can read the response containing the user's private data. This is a complete bypass of the same-origin policy with full authenticated data exfiltration.

Never Reflect Arbitrary Origins with Credentials: If you dynamically set Access-Control-Allow-Origin based on the request's Origin header, you MUST validate that origin against an explicit allowlist. Blindly reflecting whatever origin is sent is equivalent to having no CORS protection at all, and it is worse than * because it also works with credentials.

The Null Origin Attack

Some CORS configurations allow the null origin. This is more dangerous than it appears. The null origin is sent by browsers in several contexts: requests from local HTML files opened with file://, requests from sandboxed iframes, and requests from redirects. An attacker can craft a page using a sandboxed iframe that sends requests with an Origin: null header. If your server allows null as a valid origin, the attacker can read cross-origin responses.

// DANGEROUS: Allowing null origin
const allowedOrigins = ['https://myapp.com', 'null']; // BAD!

// The null origin can be trivially spoofed via sandboxed iframes:
// <iframe sandbox="allow-scripts" src="malicious-page.html"></iframe>

Never include null in your origin allowlist. If you have requests coming from null origins in development (because you are opening HTML files directly), use a local development server instead.

Origin Validation Regex Pitfalls

When validating origins against a pattern, regex mistakes can accidentally allow malicious origins. A common mistake is writing a regex that matches substrings rather than full origins:

// DANGEROUS: Regex without anchoring
function isAllowed(origin) {
  return /myapp\.com/.test(origin);  // BAD!
}

// This would match:
// https://myapp.com       (intended)
// https://evil-myapp.com  (NOT intended!)
// https://myapp.com.evil.com (NOT intended!)

// SAFE: Anchor the regex and match the full origin
function isAllowed(origin) {
  return /^https:\/\/(www\.)?myapp\.com$/.test(origin);  // GOOD
}

Even better than regex, use an explicit Set or array of exact origin strings. String equality is harder to get wrong than regex pattern matching. Only use regex if you genuinely need to match a pattern (such as dynamic staging subdomains like https://pr-123.preview.myapp.com).

CORS Is Not a Server-Side Security Boundary

It is important to understand what CORS does not protect against. CORS is enforced by the browser, not the server. A malicious actor can send requests to your API from any HTTP client (curl, Postman, a Python script, a server) without any CORS restrictions. CORS only protects users in the browser context from having their credentials abused by other websites. Your server-side authorization and authentication must stand on their own regardless of CORS. Never rely on CORS as a substitute for proper API authentication and access control.

Similarly, CORS does not prevent the server from receiving and processing requests. As mentioned earlier, simple requests (and the actual request after a successful preflight) reach the server and execute. CORS only controls whether the browser exposes the response to JavaScript. If your endpoint has side effects (creating records, sending emails, transferring funds), those side effects will occur even if the browser blocks the response. Use CSRF tokens and proper authentication to protect against unwanted state changes, not CORS.

Defense in Depth: CORS is one layer in your security stack, not the whole stack. Always combine CORS with: proper authentication (JWT, sessions), CSRF protection (for cookie-based auth), rate limiting, input validation, and principle of least privilege on your API endpoints. Each layer covers gaps that other layers miss.

Conclusion

CORS is not as complex as it first appears. At its core, it is a protocol where the browser asks the server "is this origin allowed to read your response?" and the server answers through HTTP headers. The complexity comes from the details: the distinction between simple and preflighted requests, the interplay between credentials and the wildcard origin, the preflight caching behavior, and the many places in a deployment pipeline where headers can be added, removed, or duplicated.

The key takeaways from this guide are worth restating. First, CORS is enforced by the browser, not the server. Your API still receives and processes cross-origin requests; CORS only controls whether the browser exposes the response to JavaScript. Second, always use an explicit allowlist of origins rather than reflecting arbitrary origins or using the wildcard, especially when credentials are involved. Third, remember that preflight requests do not carry authentication credentials, so you must exempt OPTIONS requests from your auth middleware. Fourth, set Access-Control-Max-Age to cache preflight responses and reduce the performance overhead of the two-request handshake. Fifth, configure CORS in exactly one layer of your infrastructure — either the reverse proxy or the application server, but not both, to avoid duplicate headers.

If you are setting up CORS for the first time, start with the Express.js cors package configuration shown in this guide. It handles the edge cases correctly and provides a clean, declarative API. If you are debugging an existing CORS issue, use the debugging process outlined in the common errors section: check the Network tab, simulate with curl, and isolate each infrastructure layer. And if you are configuring CORS for a production system that handles sensitive data, take the security implications section seriously. A misconfigured Access-Control-Allow-Origin is a real vulnerability that attackers know how to exploit.

CORS errors are frustrating precisely because they stand between you and the feature you are trying to build. But once you understand the mechanism, they become predictable and straightforward to resolve. Every CORS error message maps to a specific missing or incorrect header, and every header has a specific purpose. There is nothing magical about it. The next time your browser console lights up red with a CORS error, you will know exactly where to look and what to fix.

Need to encode URLs for your API requests? Try our free URL Encoder tool.

Open URL Encoder Tool