The blocked by CORS policy error is probably the most misunderstood message in web development. Intuition says "CORS is blocking me", but it's exactly backwards: CORS is the mechanism that allows what the browser, for security reasons, forbids by default. Understanding this inversion changes everything — and suddenly those magic headers stop being Stack Overflow copy-paste.
Where the problem comes from: Same-Origin Policy
Browsers enforce the Same-Origin Policy (SOP): a script loaded from https://app.com cannot read responses from another origin — different domain, port or protocol:
https://app.com/page1 → https://app.com/api ✓ same origin
https://app.com → https://api.app.com ✗ different domain
https://app.com → https://app.com:8080 ✗ different port
http://app.com → https://app.com ✗ different protocol
Why does this restriction exist? Because you're logged into your bank in another tab. If any random website could fire requests at bank.com/transfers and read the response, your session cookie travels along automatically and the script reads your balance. SOP makes that scenario impossible: the request may leave, but JavaScript cannot read the cross-origin response without explicit permission.
Crucial nuance: SOP is a browser policy. curl, Postman or your backend can call any API freely. That's why "it works in Postman but not in my app": it's not the API, it's that Postman doesn't implement SOP.
What CORS really is
CORS (Cross-Origin Resource Sharing) is the standard system for relaxing SOP in a controlled way: the server declares via HTTP headers which external origins have permission to read its resources. The browser acts as enforcer: it checks those headers and decides whether to hand the response to JavaScript.
The fundamental header lives in the response:
Access-Control-Allow-Origin: https://app.com
or, for public APIs, anyone:
Access-Control-Allow-Origin: *
If your API call lacks this header (or the origin doesn't match), the browser receives the data but hides it from your JavaScript and shows the console error. The data arrived; the permission didn't.
Simple requests and preflight
Not every cross-origin request needs prior permission. The standard defines "simple requests": GET, HEAD, POST with classic content types (application/x-www-form-urlencoded, multipart/form-data, text/plain) and safe headers. These go out directly and the browser validates CORS headers upon receiving the response.
Everything else triggers a preflight: before the real request, the browser sends an OPTIONS request asking what's allowed:
OPTIONS /api/data HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type
The server must respond declaring what it accepts:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400
Only if every answer satisfies the browser does the real request launch. Three practical consequences:
- Your endpoint must answer
OPTIONS— most frameworks handle it via CORS middleware, but a manual handler forgets this case and everything fails mysteriously only for non-GET methods. - Preflight doubles latency on fresh requests.
Access-Control-Max-Agecaches the answer and avoids repeating it. - If the server returns an error on the OPTIONS (404, 405, auth required), the real request never happens. Typical symptom: "the API requires a token but I get 401 even with a valid token" — because the failure is in the preflight, which carries no token.
Complete headers: reference table
| Header | Where | Purpose |
|---|---|---|
Access-Control-Allow-Origin |
Response | Allowed origins (* or one specific) |
Access-Control-Allow-Methods |
Preflight | Allowed HTTP methods |
Access-Control-Allow-Headers |
Preflight | Allowed custom headers |
Access-Control-Max-Age |
Preflight | Seconds the preflight stays cached |
Access-Control-Allow-Credentials |
Both | Allows cookies/credentials |
Access-Control-Expose-Headers |
Response | Response headers readable from JS |
Origin |
Request | Sent automatically by the browser |
Access-Control-Request-Method |
Preflight | Method the real request will use |
Two details that surprise people:
Expose-Headers: even though the full response arrives, JavaScript only sees basic headers by default (Cache-Control, Content-Language...). If your API returns X-Request-Id and you want to read it, the server must list it here.
Allow-Credentials: sending cookies cross-origin requires three pieces simultaneously — credentials: "include" in the client fetch, Access-Control-Allow-Credentials: true on the server, and a concrete origin (the wildcard * is invalid with credentials). Miss one and cookies don't travel.
Common errors and their diagnosis
"Works with curl but not in the browser": not a bug, it's SOP. The server needs to emit CORS headers.
"I set Allow-Origin: * and it still fails": does the request carry credentials? Then * is illegal; specify the exact origin and add Allow-Credentials. Or maybe the error is on the preflight and your middleware doesn't cover OPTIONS.
"I added the headers but the browser doesn't see them": check for double emission (middleware + manual response duplicating the header — browsers reject duplicated values) or a proxy/CDN stripping them. Inspect the REAL response in DevTools' Network tab.
"Redirect breaks CORS": if https://api.com/a redirects to another origin, every hop needs its own CORS headers. A redirect toward login without CORS produces the generic error.
How to configure it well (and securely)
In Express with its standard middleware:
const cors = require("cors");
app.use(cors({
origin: ["https://app.com", "https://staging.app.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
maxAge: 86400
}));
Golden rules:
- Never blindly reflect the request's
Originheader withAllow-Credentials: true— that equals allowing any website to make authenticated calls against your API, exactly what SOP prevented. Explicit allowlist always. *only for genuinely public, cookie-free data.- Restrict
Allow-Headersto what you actually use: each extra header is attack surface. - Remember CORS protects your users, not your API: an attacker with curl ignores CORS entirely. It's no substitute for authentication or rate limiting.
Inspect your own headers
To audit what your server emits —CORS included— our HTTP Headers Checker shows the complete response of any URL, and to dig into the other security headers worth emitting alongside these, see the security headers guide.
FAQ
Does CORS apply to images and fonts? For reading from JavaScript, yes. An <img src> tag can load cross-origin resources without CORS (which is why canvas requires crossorigin="anonymous" to read pixels without tainting).
Can I disable CORS while developing? Extensions injecting headers exist, but they muddy diagnosis. Better: a dev proxy (Vite, Next rewrites) turning your requests same-origin.
Does CORS protect against CSRF? No. CSRF exploits requests that don't need to read responses. Defend with anti-CSRF tokens and SameSite cookies.
Analyze your API's headers with the HTTP Headers Checker, free and no sign-up.