You fetch an API from JavaScript and get a cryptic "blocked by CORS policy" error. Nothing in your code is wrong — it's the browser enforcing a security rule. Here is exactly what CORS is, why it exists, and how to fix it.
Originally published on MoreOnlineTools Blog.
What Is CORS and Why Does It Exist?
CORS stands for Cross-Origin Resource Sharing. It is a browser security mechanism that controls how web pages load resources from a different origin (domain, protocol, or port) than the page itself.
The underlying rule CORS enforces is called the Same-Origin Policy (SOP). Browsers have applied SOP since the late 1990s: a script on https://example.com cannot by default read responses from https://api.otherdomain.com. Without this rule, any malicious website could silently fetch your bank page, read the response, and steal your account data using your already-logged-in session cookies.
CORS does not block the request itself — your browser still sends it. CORS blocks JavaScript from reading the response when the server has not explicitly said "this origin is allowed."
What Counts as a Different Origin?
Two URLs share the same origin only if all three of these match exactly:
-
Protocol:
httpvshttpsare different origins -
Domain:
example.comvsapi.example.comare different origins -
Port:
localhost:3000vslocalhost:8080are different origins
So https://app.example.com and https://api.example.com are cross-origin, even though they share the same root domain. Subdomains always count as different origins.
Simple Requests vs Preflight Requests
Not all cross-origin requests trigger the same CORS flow. Browsers split them into two categories:
Simple Requests
A request is "simple" if it meets all of these conditions:
- Method is
GET,POST, orHEAD - Only uses safe headers:
Accept,Accept-Language,Content-Language, orContent-Typelimited toapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain - No event listeners on
XMLHttpRequestUpload
For simple requests, the browser sends the request directly and checks the response for Access-Control-Allow-Origin. If the header is missing or the origin does not match, the browser blocks JavaScript from reading the response and logs a CORS error.
Preflight Requests
Any request that is not "simple" — for example, a PUT or DELETE, a request with Authorization or Content-Type: application/json, or a custom header — triggers a preflight.
A preflight is an automatic OPTIONS request the browser sends before the actual request. The browser asks the server: "I want to send a POST with JSON and an Authorization header from this origin. Is that allowed?" The server must respond with the appropriate CORS headers. Only then does the browser proceed with the actual request.
OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.mysite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
A correct server response to a preflight:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Access-Control-Max-Age tells the browser how many seconds to cache the preflight result — avoiding a round-trip on every subsequent request.
The Key CORS Response Headers
| Header | Purpose | Example value |
|---|---|---|
Access-Control-Allow-Origin |
Which origins may read the response |
https://app.mysite.com or *
|
Access-Control-Allow-Methods |
Which HTTP methods are permitted | GET, POST, DELETE, OPTIONS |
Access-Control-Allow-Headers |
Which request headers are permitted | Content-Type, Authorization |
Access-Control-Allow-Credentials |
Whether cookies/auth may be included | true |
Access-Control-Expose-Headers |
Which response headers JS can read | X-Request-Id, X-RateLimit-Remaining |
Access-Control-Max-Age |
Seconds to cache the preflight result | 86400 |
The Wildcard Trap: Why * Is Not Always Enough
Setting Access-Control-Allow-Origin: * seems like the easiest fix — and for public read-only APIs it usually works fine. But there is one critical limitation: you cannot use * with credentials.
If your request includes cookies, HTTP authentication, or a TLS client certificate (i.e. you set credentials: "include" in fetch()), the browser requires an explicit origin, not a wildcard. The server must also explicitly set Access-Control-Allow-Credentials: true. Using * in this scenario results in a CORS error even though the header is present.
// ❌ This combination is ALWAYS rejected by the browser:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// ✅ Correct for credentialed requests:
Access-Control-Allow-Origin: https://app.mysite.com
Access-Control-Allow-Credentials: true
How to Fix CORS: Backend Configuration
PHP (Native)
<?php
$allowed_origins = [
'https://app.mysite.com',
'https://staging.mysite.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 86400');
// Handle preflight
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
Node.js / Express
const cors = require('cors');
app.use(cors({
origin: ['https://app.mysite.com', 'https://staging.mysite.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
}));
Nginx
add_header Access-Control-Allow-Origin "https://app.mysite.com" 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-Allow-Credentials "true" always;
if ($request_method = OPTIONS) {
add_header Access-Control-Max-Age 86400;
return 204;
}
Apache (.htaccess)
Header always set Access-Control-Allow-Origin "https://app.mysite.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"
Multiple Allowed Origins (Dynamic Validation)
You cannot list multiple origins in a single Access-Control-Allow-Origin header — it only accepts one value or *. The correct pattern is to validate the incoming Origin header against a server-side allowlist and echo it back if it matches:
// Generic pattern (any language)
IF request.origin IN allowlist:
response.header("Access-Control-Allow-Origin", request.origin)
response.header("Vary", "Origin") // Important for CDN caching
ELSE:
response.header("Access-Control-Allow-Origin", "null") // deny
Note the Vary: Origin header — without it, a CDN or proxy might cache a response intended for one origin and serve it to a different one.
CORS Is Not a Security Feature for the Server
This is the most common misconception about CORS. CORS does not protect your API from unauthorized access. It only controls what browsers do with responses. A curl request, a Postman request, or a server-to-server request completely ignores CORS — browsers are the only enforcement point.
If you need to restrict who can call your API, use proper authentication (JWT, API keys, OAuth) and authorization checks on the server side. CORS is for protecting users' browsers from malicious websites reading cross-site responses — not for protecting your API from unauthorized callers.
Common CORS Errors and What They Mean
| Error message | Root cause | Fix |
|---|---|---|
| No 'Access-Control-Allow-Origin' header is present | Server never sends the header | Add the header to your server/backend |
| The value of 'Access-Control-Allow-Origin' is not equal to the supplied origin | Header is present but origin does not match | Use dynamic origin echo or correct whitelist |
| Credential flag is true, but the 'Access-Control-Allow-Credentials' header is not 'true' | Using credentials without the header | Add Access-Control-Allow-Credentials: true and remove wildcard |
| Request header field Authorization is not allowed by Access-Control-Allow-Headers | The preflight rejected the Authorization header | Add Authorization to Access-Control-Allow-Headers
|
| Method PUT is not allowed by Access-Control-Allow-Methods | Method missing from the preflight response | Add the method to Access-Control-Allow-Methods
|
CORS in Local Development
During development you often hit CORS errors because your frontend runs on localhost:3000 (Vite, Create React App, etc.) while your backend runs on localhost:8000. Different ports = different origins.
The cleanest solution is to add http://localhost:3000 to your server's CORS allowlist in development mode. An alternative is to use your bundler's proxy feature:
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
}
With a dev proxy, the browser thinks all requests go to localhost:3000 — no cross-origin, no CORS. This is the approach used by most modern frontend frameworks.
Key Takeaways
- CORS is enforced by browsers only — servers and tools like curl are unaffected
- It restricts reading responses, not sending requests
- Simple requests go through directly; non-simple requests trigger an
OPTIONSpreflight -
Access-Control-Allow-Origin: *cannot be used together with credentials - Always echo the specific origin dynamically and include
Vary: Originwhen using an allowlist - CORS does not replace authentication — it is a browser-level protection for users, not an API access control mechanism
Want to check whether your server sends correct CORS and security headers? Try our free HTTP Security Headers Analyzer — paste any URL and get an instant breakdown of every header, including Access-Control-Allow-Origin. Runs entirely in your browser.
Top comments (0)