corshttpsecurityapi

CORS explained: why your request fails and how to actually fix it

Understand CORS once and for all: Same-Origin Policy, Access-Control headers, OPTIONS preflight requests, credentials and the most common errors with fixes.

August 24, 2026·9 min read

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:

  1. 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.
  2. Preflight doubles latency on fresh requests. Access-Control-Max-Age caches the answer and avoids repeating it.
  3. 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 Origin header with Allow-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-Headers to 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.

Try it without code

HTTP Headers Checker

HTTP headers + security analysis HSTS/CSP.

Open HTTP Headers Checker

Built by

Miguel Ángel Colorado Marin (MACM)

Full-Stack Developer · Guadalajara, España

I develop web apps, digital tools and full projects — from design to deployment.

Contact me