CORS errors are a rite of passage for web developers — the cryptic "has been blocked by CORS policy" message in the browser console, the API call that works in curl but fails in the browser, the temptation to just add a wildcard and move on. Understanding why CORS exists makes it far easier to configure correctly and avoid the shortcuts that introduce security holes.
What changed in 2026
- Private network access controls tightened. Chrome now enforces the Private Network Access spec — requests from public origins to local IP addresses (
192.168.x.x, localhost) require an explicit Access-Control-Allow-Private-Network: true header.
- CORS errors in DevTools became actionable. Chrome 125+ shows the exact missing header and links to the relevant MDN page directly in the console.
- Edge runtimes (Cloudflare Workers, Vercel Edge Functions) run in a browser-like security model, so CORS issues can appear in places they never did with traditional Node.js servers.
- The CORS spec finally stabilized — the edge cases around wildcard credentials were formally prohibited, clarifying years of inconsistent browser behavior.
Why CORS exists: the same-origin policy
The browser's same-origin policy prevents a script on evil.com from reading the response of a request to yourbank.com. Without it, malicious sites could silently read your session data from third-party APIs using your cookies.
Two URLs share an origin only if scheme + host + port all match exactly:
| URL A |
URL B |
Same origin? |
https://api.example.com |
https://api.example.com/data |
Yes |
https://api.example.com |
http://api.example.com |
No (scheme) |
https://api.example.com |
https://www.example.com |
No (host) |
https://api.example.com |
https://api.example.com:8080 |
No (port) |
CORS is the mechanism that lets a server opt in to cross-origin access by including specific response headers. It does not bypass the policy — it extends it in a controlled way.
Simple vs preflighted requests
Simple requests
A request is "simple" (no preflight) if it uses GET, HEAD, or POST with only allowed headers (Content-Type: application/x-www-form-urlencoded, multipart/form-data, or text/plain).
The browser sends the request, receives the response, then checks Access-Control-Allow-Origin. If the header matches, JS gets the response. If not, JS is blocked — but the request was already sent to the server.
Preflighted requests
Any request with custom headers (Authorization, Content-Type: application/json), PUT/DELETE/PATCH, or credentials: include triggers a preflight:
OPTIONS /api/data
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
The server must respond:
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: Authorization, Content-Type
Access-Control-Max-Age: 86400
Access-Control-Max-Age caches the preflight result — critical for performance. Without it, every request triggers a preflight round-trip.
Configuring CORS correctly
Express / Node.js
import cors from "cors";
const allowedOrigins = [
"https://app.example.com",
"https://staging.example.com",
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
credentials: true, // required for cookies / auth headers
maxAge: 86400,
}));
Nginx (for static servers or reverse proxies)
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Max-Age 86400;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials "true" always;
proxy_pass http://upstream;
}
CORS header reference
| Header |
Direction |
Purpose |
Access-Control-Allow-Origin |
Response |
Which origins may read the response |
Access-Control-Allow-Methods |
Response (preflight) |
Which HTTP methods are permitted |
Access-Control-Allow-Headers |
Response (preflight) |
Which request headers are permitted |
Access-Control-Allow-Credentials |
Response |
Whether cookies/auth may be sent |
Access-Control-Max-Age |
Response (preflight) |
How long to cache the preflight result |
Access-Control-Expose-Headers |
Response |
Headers JS may read beyond the safe list |
Origin |
Request |
The origin of the requesting page |
How to fix common errors
"No 'Access-Control-Allow-Origin' header is present" — your server is not setting the header. Add CORS middleware or a manual header in the response.
"The value of 'Access-Control-Allow-Origin' must not be the wildcard '*' when the request includes credentials" — you are using credentials: include and Allow-Origin: *. Specify the exact origin dynamically.
Preflight returning 404 or 405 — your framework is not handling OPTIONS requests. Add an explicit OPTIONS route or configure the CORS middleware to handle it.
Works in development, fails in production — the allowed origins list does not include the production URL. Add it and redeploy.
How to pick the right CORS configuration
- Public read-only API (no auth, no cookies)?
Access-Control-Allow-Origin: * is fine.
- Auth-protected API? Enumerate allowed origins explicitly. Never use
* with credentials.
- Microservices calling each other (server to server)? CORS does not apply — it is a browser policy. Server-to-server calls are not subject to it.
- Development localhost? Add
http://localhost:3000 (or the relevant port) to the allowed origins, conditionally based on environment.
Common mistakes
Setting Access-Control-Allow-Origin: * globally for an authenticated API. This disables the protection for credentialed requests — either the browser blocks it or you have a security hole.
Not reflecting the Vary: Origin header. If your server returns different Allow-Origin values per request, include Vary: Origin so CDNs and proxies do not cache the wrong value.
Handling CORS in frontend code. CORS is enforced by the browser based on server headers. Adding headers to your fetch() call does not help — fix the server.
Forgetting Access-Control-Expose-Headers. Headers like X-Request-Id or Link are not accessible to JS by default even with CORS. Add them to Expose-Headers.
What to skip
- Disabling CORS in the browser via an extension — valid locally for debugging, never in production.
- Proxying all requests through your own server to bypass CORS — valid engineering pattern (a BFF), but do not do it out of confusion about what CORS is.
- Wildcard
Allow-Headers: * — not supported in all browsers; enumerate the headers you actually allow.
FAQ
Does CORS apply to server-to-server requests?
No. CORS is enforced by browsers only. A Node.js fetch() call, a Python requests.get(), or a curl command is never subject to CORS.
Why does my API work in Postman but not in the browser?
Postman is not a browser and does not enforce the same-origin policy. CORS is a browser security mechanism, not a server configuration requirement for non-browser clients.
Can I use a wildcard subdomain like *.example.com?
The CORS spec does not support wildcard subdomains. You must enumerate each allowed origin or dynamically reflect the origin after validating it against a whitelist.
What is the difference between CORS and CSP?
CORS controls which origins can read your API responses. Content Security Policy (CSP) controls which resources your page is allowed to load. They are orthogonal and often both needed.
Where to go next