← Back to Blog

CORS Explained: Fix Cross-Origin Errors Once and For All

Every web developer has stared at "blocked by CORS policy" in the browser console. The error is cryptic, the fix is not obvious, and bad advice (just add Access-Control-Allow-Origin: *) creates security holes. This guide explains exactly what CORS is, why it exists, and how to configure it correctly in every environment.

The Problem: Why Your API Call Gets Blocked

You are building a React app on https://app.example.com. You try to call your API at https://api.example.com/users. The API works fine in Postman. It works fine with curl. But in the browser you get:

Access to fetch at 'https://api.example.com/users' 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 is not a bug. It is a security feature called the Same-Origin Policy. The browser is doing exactly what it is designed to do: protecting your users from malicious websites that might silently read their data from other services.

What Is an Origin?

An "origin" is the combination of protocol + domain + port. All three must match for two URLs to be considered the same origin:

https://app.example.com:443  (origin)
  |       |              |
  protocol domain        port

Same origin:
  https://app.example.com/page1  →  https://app.example.com/api  (same)

Different origin (all of these are cross origin):
  https://app.example.com  →  https://api.example.com      (different subdomain)
  http://example.com       →  https://example.com           (different protocol)
  https://example.com:3000 →  https://example.com:8080      (different port)
  https://example.com      →  https://other.com             (different domain)

CORS is a browser enforcement mechanism. It does not apply to server-to-server requests, curl, Postman, Insomnia, or mobile apps. If your API works in Postman but not in the browser, CORS is the reason - the browser is blocking it, not the server.

Why the Same-Origin Policy Exists

Imagine you visit evil.com. Without the Same-Origin Policy, that page could silently make a fetch request to https://your-bank.com/api/transfer using your existing bank session cookie. The bank's server would receive the request, see your session cookie, and process the transfer.

The Same-Origin Policy prevents this by blocking cross origin requests from JavaScript. CORS is the mechanism by which servers can selectively relax this restriction for trusted origins - explicitly saying "yes, I allow requests from https://app.example.com."

How CORS Works: The Headers

Simple Requests

For "simple" requests (GET, HEAD, POST with standard content types like application/x-www-form-urlencoded or text/plain), the browser sends the request immediately and then checks the response headers:

--- Request ---
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

--- Response ---
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

If the response contains Access-Control-Allow-Origin with a value matching the requesting origin (or *), the browser makes the response available to JavaScript. If the header is absent or does not match, the browser blocks the response even though the request completed on the server.

Preflight Requests (OPTIONS)

For "non-simple" requests - PUT, DELETE, PATCH, or POST with application/json, or any request with custom headers like Authorization - the browser first sends an OPTIONS preflight request to ask the server if the actual request is allowed:

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

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

Only if the preflight response approves the request does the browser proceed to send the actual PUT request. The Access-Control-Max-Age header caches the preflight result (in seconds) so the browser does not repeat it on every request.

A common mistake is forgetting to handle OPTIONS requests on the server. If your server returns 404 or 405 for OPTIONS, the preflight fails and the actual request never reaches your handler.

CORS with Credentials (Cookies and Authorization)

By default, cross origin requests do not include cookies, HTTP authentication, or client side certificates. To send credentials:

// Client: must opt in with credentials
fetch('https://api.example.com/profile', {
  credentials: 'include'  // sends cookies and HTTP auth
});

// With axios
axios.get('https://api.example.com/profile', {
  withCredentials: true
});

The server must respond with two specific headers:

Access-Control-Allow-Origin: https://app.example.com  // MUST be exact origin, NOT *
Access-Control-Allow-Credentials: true

Critical rule: When Access-Control-Allow-Credentials: true is set, the Access-Control-Allow-Origin header cannot be a wildcard (*). The browser will reject it with an error. You must specify the exact requesting origin. This is a deliberate security constraint.

Step-by-Step: Correct CORS Configuration

Node.js / Express

const cors = require('cors');

// Option 1: Simple static allowlist
app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400  // cache preflight for 24 hours
}));

// Option 2: Dynamic validation (for multiple environments)
const allowlist = new Set([
  'https://app.example.com',
  'https://admin.example.com',
  'http://localhost:3000'  // dev only
]);

app.use(cors({
  origin: function(origin, callback) {
    // Allow requests with no origin (mobile apps, curl, server side)
    if (!origin || allowlist.has(origin)) {
      callback(null, true);
    } else {
      callback(new Error('CORS: origin not allowed'));
    }
  },
  credentials: true
}));

Python / FastAPI

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "https://admin.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Content-Type", "Authorization"],
    max_age=86400,
)

Nginx

server {
    location /api/ {
        # Handle preflight
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $http_origin always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Max-Age 86400 always;
            return 204;
        }

        # Add CORS headers to all responses
        add_header Access-Control-Allow-Origin $http_origin always;
        add_header Access-Control-Allow-Credentials "true" always;

        proxy_pass http://backend;
    }
}

Note the always flag on add_header - without it, Nginx only adds the header on 2xx responses, not on 4xx/5xx errors.

Apache (.htaccess)

Header always set Access-Control-Allow-Origin "https://app.example.com"
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"
Header always set Access-Control-Max-Age "86400"

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

Inspect and Format Your API Responses

Use our free JSON Formatter to validate and inspect the JSON responses from your API during CORS debugging. Paste any response and get instant syntax highlighting and error detection.

Open JSON Formatter

Diagnosing CORS Errors

The browser error message is often vague. Here is how to systematically diagnose the actual issue:

Step 1: Check the preflight manually

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

Look for Access-Control-Allow-Origin in the response. If it is absent, the server is not sending CORS headers at all.

Step 2: Match the exact error message

  • "No 'Access-Control-Allow-Origin' header is present" - The server is not sending the header. Add CORS middleware or headers to your server.
  • "has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header ... does not match" - Origin mismatch. Check for http vs https, www vs no-www, trailing slash, or port number differences.
  • "The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'" - You are using credentials: include but the server returns *. Change to the explicit origin.
  • "Request header field Authorization is not allowed by Access-Control-Allow-Headers" - The Authorization header is not listed in Access-Control-Allow-Headers. Add it.
  • "Method PUT is not allowed by Access-Control-Allow-Methods" - The HTTP method is not in the allowed methods list.

Step 3: Use browser DevTools

Open DevTools → Network tab. Find the failing request. Look for the OPTIONS preflight request (it will appear just before the main request). Check both its request headers and response headers. The mismatch will be visible.

Security Considerations

CORS configuration has direct security implications. Common mistakes that create vulnerabilities:

  • Reflecting the Origin header blindly: Some implementations set Access-Control-Allow-Origin to whatever the request's Origin header says, without validation. This defeats CORS entirely - any origin becomes allowed. Always validate against an explicit allowlist.
  • Using * with credentials: The browser rejects this combination (as described above), but some server side CORS libraries have bugs or misconfigurations. Verify your credentials flow carefully.
  • Overly broad origins: Setting Access-Control-Allow-Origin: https://example.com does not allow subdomains. But some developers use regex matching and accidentally allow evil-example.com or notexample.com. Use exact string matching or a well-tested allowlist.
  • Forgetting non-GET methods: A CORS configuration that only allows GET requests but your API also has POST and PUT endpoints means those endpoints will fail for cross origin callers.

Frequently Asked Questions

Why does my API work in Postman but not in the browser?

CORS is enforced exclusively by the browser. Postman, curl, and server side HTTP clients do not enforce the Same-Origin Policy. They send any request and receive any response without restriction. The browser adds the Origin header automatically and then checks the response for appropriate CORS headers. If those headers are absent or incorrect, the browser blocks the response from JavaScript, even though the server processed the request and returned data.

Is it safe to use Access-Control-Allow-Origin: *?

Using * means any origin on the internet can make cross origin requests to your API and read the responses. For fully public APIs (public data, no authentication, no user-specific data) this is acceptable. For any API that serves authenticated data or performs state-changing operations, * is a security risk. It should never be used with Access-Control-Allow-Credentials: true. Default to an explicit allowlist of origins you control.

How do I handle CORS in a development environment?

The cleanest approach for local development is to run a reverse proxy so your frontend and API share the same origin. With Vite: configure the proxy option in vite.config.js. With Create React App: use the proxy field in package.json. With Webpack Dev Server: configure devServer.proxy. Alternatively, add http://localhost:3000 (or your dev port) to your server's CORS allowlist behind an environment variable check, ensuring it never reaches production.

What is the difference between a simple request and a preflighted request?

Simple requests are sent directly without a preflight check. They must use GET, HEAD, or POST with only the headers that browsers add automatically (no custom headers like Authorization) and a content type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Any request outside these constraints triggers a preflight OPTIONS request first. In practice, most API requests (JSON POST, PUT, DELETE, requests with Authorization headers) are preflighted.

Can CORS be bypassed by using a server side proxy?

Yes - and this is a legitimate architectural pattern. If you cannot add CORS headers to a third-party API, you can route requests through your own server. Your browser makes a same origin request to your server, your server makes the request to the third-party API (server-to-server, no CORS), and returns the response to your browser. This is how many integrations with legacy or third-party APIs work. The downside is added latency and the need to manage a proxy service.

Why does my CORS work for GET but not POST?

A GET request with no custom headers may be a "simple request" that does not trigger a preflight. Your server might be returning Access-Control-Allow-Origin for GET responses (perhaps added by a framework's default configuration) but not correctly handling OPTIONS preflight requests. The POST with Content-Type: application/json triggers a preflight, which your server returns 404 or 405 for, causing the POST to fail. The fix is to add explicit OPTIONS handling to your server that returns the full set of CORS headers with a 204 response.

The Bottom Line

CORS errors mean the server has not explicitly authorized your origin. The fix is always server side: add the correct response headers, handle OPTIONS preflight requests, and use an explicit allowlist of trusted origins rather than a wildcard. Never add CORS browser extensions as a "fix" - they bypass security for all sites, not just yours. Never "fix" CORS by disabling the browser security flag - that only masks the problem locally and does nothing for your users.

Get the configuration right once, and you will not have to touch it again. Use our free tool to format and inspect API responses during debugging - open the JSON Formatter here. Also see our HTTP Status Codes reference for understanding the response codes you see during CORS troubleshooting.

UK
Written by Usman Khan
DevOps Engineer | MSc Cybersecurity | CEH | AWS Solutions Architect

Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.