Tutorial

CORS Explained: What It Is, Why It Fails, and How to Fix It

May 10, 2026

Back to Blog

The Most Confusing Error in Web Development

If you have spent any time building web applications that consume APIs, you have almost certainly encountered this dreaded console message: "Access to fetch at 'https://api.example.com' from origin 'https://mysite.com' has been blocked by CORS policy." It appears out of nowhere, your perfectly valid API call fails, and you are left wondering why the browser is sabotaging your own code.

CORS — Cross-Origin Resource Sharing — is one of the most misunderstood concepts in web development. Developers often patch it with quick fixes without understanding the underlying security model, which leads to either broken applications or dangerously permissive configurations. This guide will explain CORS from the ground up: what it is, why browsers enforce it, how preflight requests work, and how to configure it correctly across different server platforms.

What you will learn: The same-origin policy, CORS headers, simple vs. preflighted requests, proper server configuration for Nginx, Apache, Node.js, and PHP, common mistakes, debugging techniques, and the security implications of getting it wrong.

The Same-Origin Policy: Where It All Begins

Before you can understand CORS, you need to understand the same-origin policy — the fundamental security mechanism that browsers have enforced since the late 1990s. The same-origin policy states that a script running on one origin can only access resources from the same origin. An "origin" is defined by three components:

ComponentExampleDifferent Origin?
Protocol (scheme)http:// vs https://Yes
Host (domain)example.com vs api.example.comYes
Port:443 vs :8443Yes

So https://example.com and https://api.example.com are different origins, even though they share the same root domain. Likewise, http://example.com and https://example.com differ because of the protocol. This strictness exists for a very good reason: without it, any malicious website could make requests to your bank, email provider, or any other service where you are logged in, using your cookies and session tokens.

Important: The same-origin policy is enforced by the browser, not by the server. Server-to-server requests (like cURL from your terminal or backend HTTP calls) are never subject to CORS restrictions. This is why your API works perfectly from Postman but fails in the browser.

What Is CORS, Exactly?

CORS is a controlled relaxation of the same-origin policy. It is a mechanism that allows servers to declare which external origins are permitted to access their resources. The server communicates this permission through specific HTTP response headers, and the browser enforces the rules.

Think of it this way: the same-origin policy is a locked door. CORS is the server handing out keys to specific visitors. Without CORS, the door stays locked for everyone except those already inside (same origin). With CORS, the server can say, "I trust requests from https://myapp.com, let them through."

Browser (myapp.com)
Request to api.example.com
Server checks origin
Sends CORS headers
Browser allows/blocks

The Key CORS Headers

CORS relies on a set of HTTP response headers that the server sends back to the browser. Understanding these headers is essential for proper configuration.

Access-Control-Allow-Origin

This is the most important CORS header. It tells the browser which origin(s) are allowed to access the resource.

# Allow a specific origin Access-Control-Allow-Origin: https://myapp.com # Allow any origin (use with caution!) Access-Control-Allow-Origin: *

Access-Control-Allow-Methods

Specifies which HTTP methods are permitted for cross-origin requests. This header is used in the preflight response.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

Access-Control-Allow-Headers

Lists the custom headers that clients are allowed to send. If your frontend sends Authorization or Content-Type: application/json, you must whitelist them here.

Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With

Access-Control-Allow-Credentials

When set to true, it tells the browser that cookies and authentication headers should be included in cross-origin requests. This has a critical interaction with Access-Control-Allow-Origin — you cannot use the wildcard * when credentials are enabled.

Access-Control-Max-Age

Specifies how long (in seconds) the browser should cache the preflight response. This reduces the number of OPTIONS requests for repeated API calls.

# Cache preflight for 24 hours Access-Control-Max-Age: 86400

Simple Requests vs. Preflighted Requests

Not all cross-origin requests trigger a preflight. The browser categorizes requests into two types, and this distinction is where much of the confusion around CORS originates.

Simple Requests

A request is considered "simple" when it meets all of these criteria:

  • Uses GET, HEAD, or POST method only
  • Only uses "safe" headers: Accept, Accept-Language, Content-Language, Content-Type
  • Content-Type is limited to: application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No event listeners on the XMLHttpRequest upload object
  • No ReadableStream in the request body

For simple requests, the browser sends the request directly and checks the CORS headers in the response. If the headers do not permit the origin, the browser blocks the JavaScript code from reading the response.

Preflighted Requests

Any request that does not meet the "simple" criteria triggers a preflight — an extra OPTIONS request that the browser sends before the actual request. This is extremely common in modern applications because sending JSON (Content-Type: application/json) or including an Authorization header immediately disqualifies a request from being "simple."

Browser sends OPTIONS
Server responds with CORS headers
Browser validates
Actual request sent
Common pitfall: Many developers do not handle OPTIONS requests on their server. If your server returns a 404 or 405 for OPTIONS, the preflight fails and the actual request is never sent. Always ensure your server responds to OPTIONS with a 200/204 and the correct CORS headers.

Configuring CORS in Nginx

Nginx is the most common web server where CORS is configured. Here is a robust configuration that handles both simple and preflighted requests:

# /etc/nginx/conf.d/cors.conf map $http_origin $cors_origin { default ""; "~^https://(www\.)?myapp\.com$" $http_origin; "~^https://staging\.myapp\.com$" $http_origin; } server { location /api/ { # Handle preflight if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' $cors_origin; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization'; add_header 'Access-Control-Allow-Credentials' 'true'; add_header 'Access-Control-Max-Age' 86400; return 204; } # Actual request headers add_header 'Access-Control-Allow-Origin' $cors_origin always; add_header 'Access-Control-Allow-Credentials' 'true' always; proxy_pass http://backend; } }
Why use map? The map directive lets you whitelist multiple origins dynamically. Since Access-Control-Allow-Origin only accepts a single origin (or *), you cannot list multiple domains directly. The map checks the incoming Origin header against your regex patterns and echoes back the matching one.

Configuring CORS in Apache

Apache uses mod_headers to set CORS headers. Make sure the module is enabled first:

a2enmod headers a2enmod rewrite systemctl restart apache2

Then add the following to your virtual host or .htaccess:

# .htaccess or VirtualHost config SetEnvIf Origin "^https://(www\.)?myapp\.com$" CORS_ORIGIN=$0 Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" Header always set Access-Control-Allow-Headers "Content-Type, Authorization" Header always set Access-Control-Allow-Credentials "true" # Handle preflight RewriteEngine On RewriteCond %{REQUEST_METHOD} OPTIONS RewriteRule ^(.*)$ $1 [R=204,L]

Configuring CORS in Node.js (Express)

For Node.js applications using Express, the cors middleware package is the standard approach:

npm install cors // server.js const express = require('express'); const cors = require('cors'); const app = express(); const corsOptions = { origin: ['https://myapp.com', 'https://staging.myapp.com'], methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400 }; app.use(cors(corsOptions));

If you need dynamic origin validation (e.g., checking against a database of allowed origins), you can pass a function:

const corsOptions = { origin: function (origin, callback) { const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com']; if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true };

Configuring CORS in PHP

In plain PHP, you set CORS headers at the top of your script before any output:

<?php $allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com']; $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (in_array($origin, $allowedOrigins)) { header("Access-Control-Allow-Origin: $origin"); header("Access-Control-Allow-Credentials: true"); } if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); header("Access-Control-Allow-Headers: Content-Type, Authorization"); header("Access-Control-Max-Age: 86400"); http_response_code(204); exit; }

For Laravel applications, use the built-in config/cors.php configuration file that ships with the framework.

Common CORS Mistakes (and How to Fix Them)

These are the mistakes I see developers make repeatedly. Each one leads to either a broken application or a security vulnerability.

Mistake 1: Wildcard with Credentials

Wrong

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The browser will reject this combination. You cannot use * when credentials are involved.

Correct

Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

You must specify the exact origin when using credentials.

Mistake 2: Forgetting the Vary Header

When you dynamically set Access-Control-Allow-Origin based on the incoming Origin header, you must also set Vary: Origin. Without it, CDNs and browser caches may cache a response with Origin A and serve it for Origin B, causing CORS failures.

Mistake 3: Missing OPTIONS Handler

Your framework's router may not have a route for OPTIONS requests. Frameworks like Express.js handle this automatically with the CORS middleware, but if you are routing manually, you need to explicitly handle OPTIONS and return a 204 with the appropriate headers.

Mistake 4: CORS Headers Only on Success Responses

If your server returns a 500 error without CORS headers, the browser will report a CORS error instead of showing the actual error message. Always add CORS headers to all responses, including errors. In Nginx, use the always keyword:

# The 'always' keyword adds the header even on error responses add_header 'Access-Control-Allow-Origin' $cors_origin always;

Mistake 5: Duplicated Headers

If both your reverse proxy (Nginx) and your application (Node.js/PHP) set CORS headers, the browser receives duplicate headers and may reject the response. Choose one layer to handle CORS and remove it from the other.

Debugging CORS Errors with Browser DevTools

When you hit a CORS error, the browser DevTools are your best friend. Here is a systematic debugging process:

1
Open the Network tab — Look for the failed request. Check if there is a separate OPTIONS request. If the OPTIONS request is missing or failing, that is your problem.
2
Inspect response headers — Click on the request and look at the Response Headers tab. Check whether Access-Control-Allow-Origin is present and matches your origin.
3
Check the Console tab — The browser provides a specific error message telling you exactly which CORS rule was violated. Read it carefully — it usually tells you exactly what is missing.
4
Test with cURL — Use cURL to simulate the request from the command line. This bypasses CORS entirely and tells you if the server itself is working. If cURL succeeds but the browser fails, you know it is a CORS configuration issue, not a server issue.
# Simulate a preflight request with cURL curl -X OPTIONS https://api.example.com/data \ -H "Origin: https://myapp.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization" \ -v 2>&1 | grep -i "access-control" < Access-Control-Allow-Origin: https://myapp.com < Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS < Access-Control-Allow-Headers: Content-Type, Authorization < Access-Control-Allow-Credentials: true

Security Implications of CORS

CORS is a security mechanism, and getting it wrong can expose your application to serious vulnerabilities. Here are the key security considerations:

Never reflect the Origin header blindly. Some developers read the Origin header and echo it back as Access-Control-Allow-Origin without validation. This effectively disables the same-origin policy for your API, allowing any website to make authenticated requests to your backend.
ConfigurationSecurity LevelUse Case
Access-Control-Allow-Origin: *ModeratePublic APIs with no authentication
Specific origin whitelistHighMost applications
Reflected origin (no validation)DangerousNever use this
No CORS headers (default)StrictSame-origin only

When Wildcard Is Safe

Using Access-Control-Allow-Origin: * is perfectly safe for truly public APIs that do not use authentication — things like public weather data, open datasets, or CDN-hosted resources. The wildcard becomes dangerous only when combined with authenticated endpoints or when your API uses cookies for authentication.

CORS Does Not Replace Authentication

CORS only controls whether the browser allows JavaScript to read the response. The server still receives and processes the request regardless of CORS headers. This means CORS is not a substitute for proper authentication, authorization, or rate limiting. A malicious actor can always bypass CORS by using a non-browser HTTP client.

CORS and Cookies: The Credential Trap

When your frontend and API are on different subdomains (e.g., app.example.com and api.example.com), cookie-based authentication introduces additional CORS complexity:

// Frontend: must set credentials flag fetch('https://api.example.com/data', { method: 'GET', credentials: 'include' // Sends cookies cross-origin }); // Or with Axios: axios.get('https://api.example.com/data', { withCredentials: true });

On the server side, three conditions must be met simultaneously: Access-Control-Allow-Origin must be set to the exact origin (not *), Access-Control-Allow-Credentials must be true, and cookies must be set with SameSite=None; Secure attributes. Missing any one of these will cause the request to fail silently.

Proxy-Based Alternatives

Sometimes the cleanest solution is to avoid CORS entirely by using a reverse proxy. If your frontend and API share the same origin through a proxy, no CORS headers are needed.

# Nginx reverse proxy — same domain, no CORS needed server { server_name myapp.com; location / { root /var/www/frontend; } location /api/ { proxy_pass http://localhost:3000/; } }

In this setup, both the frontend and API appear to be on https://myapp.com, so the browser treats them as same-origin. No CORS configuration is needed, no preflight requests are sent, and cookies work without any special attributes. This is often the simplest and most secure approach.

Quick Reference Cheat Sheet

ScenarioSolution
Public API, no authAccess-Control-Allow-Origin: *
Private API, cookiesSpecific origin + Credentials: true
Multiple allowed originsNginx map or dynamic validation
OPTIONS returns 404Add explicit OPTIONS handler returning 204
Error responses missing CORSUse always in Nginx add_header
Duplicate CORS headersHandle CORS in one layer only (proxy OR app)
Want to avoid CORS entirelyUse a reverse proxy for same-origin

Wrapping Up

CORS is not an enemy — it is a security feature that protects your users. The confusion usually comes from not understanding who enforces it (the browser), what it protects against (cross-origin data theft), and how to properly configure it (server-side response headers). Once you understand the flow — Origin header sent, server checks and responds, browser enforces — the errors stop being mysterious and start being straightforward to fix.

The most important takeaways: always handle OPTIONS requests, never reflect the Origin header without validation, never use wildcard with credentials, add CORS headers to error responses too, and consider whether a reverse proxy might be a simpler solution than CORS configuration altogether. With these principles in mind, you will never be stuck on a CORS error for more than a few minutes again.

Share:
Built in Go.