Understanding CORS errors and how to fix them
CORS errors are one of the few browser errors that show up only in the console and never in your server logs, which is exactly why they confuse people: the request often succeeds on the server and fails only in the browser's eyes.
What CORS actually is
Cross-Origin Resource Sharing is a browser-enforced security policy, not a server-enforced one. When JavaScript on `https://app.example.com` tries to fetch `https://api.example.org`, the browser checks whether the response includes headers that explicitly allow `app.example.com` to read it. If those headers are missing or wrong, the browser throws the response away — but the server already processed the request. That's why you sometimes see a database row get created even though the client saw an error: the write succeeded, only the response was blocked from JavaScript.
The same-origin policy this protects against is old and strict: scheme, host and port all have to match exactly. `http://example.com` and `https://example.com` are different origins. `example.com` and `api.example.com` are different origins. `localhost:3000` and `localhost:5173` are different origins — this one bites almost everyone during local development.
Simple requests vs preflighted requests
A "simple" request (GET/POST/HEAD with only a few safe headers and a body type of form-encoded, multipart or plain text) goes straight to the server, and the browser inspects the response headers.
Anything else — a custom header like `Authorization` or `X-Api-Key`, a JSON body with `Content-Type: application/json`, or a method like `PUT`/`DELETE`/`PATCH` — triggers a preflight: the browser sends an `OPTIONS` request first, asking permission, before it sends the real one.
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorizationIf your server doesn't answer `OPTIONS` requests (a common gap when routes are only registered for GET/POST), the preflight itself fails and the real request never leaves the browser.
The headers that actually matter
A server needs to answer both the preflight and the real response with headers matching what the browser asked for:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400`Access-Control-Allow-Origin` can be `*`, but `*` cannot be combined with credentials (cookies or `Authorization` headers sent via `credentials: "include"`). If you need credentials, you must echo back the exact requesting origin and add:
Access-Control-Allow-Credentials: trueNote there is no wildcard option for credentialed requests — the server has to look at the incoming `Origin` header and reflect it (after checking it against an allow-list), not hardcode a single value, if you serve more than one frontend.
Reading the exact error message
Chrome and Firefox report specific reasons, and each maps to a specific fix:
- **"No 'Access-Control-Allow-Origin' header is present"** — the server didn't send CORS headers at all, or sent them only on success and not on error responses (a common bug: a 500 error handler that skips the CORS middleware).
- **"The 'Access-Control-Allow-Origin' header contains multiple values"** — usually two pieces of middleware (e.g. a CDN and the app) both add the header, resulting in `*, https://app.example.com`. Remove one.
- **"Method PUT is not allowed"** — `Access-Control-Allow-Methods` didn't include the method used.
- **"Request header field authorization is not allowed"** — `Access-Control-Allow-Headers` is missing that header name.
- **"has been blocked by CORS policy: Response to preflight request doesn't pass access control check"** — the `OPTIONS` handler exists but isn't returning the right headers, or is returning a non-2xx status.
A minimal Express fix
app.use((req, res, next) => {
const allowed = ["https://app.example.com", "http://localhost:5173"];
const origin = req.headers.origin;
if (allowed.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});Put this before your routes, not after, so preflight requests short-circuit before hitting business logic.
What CORS is not for
CORS does not protect your API — it protects browser users. A server-to-server request, a mobile app, or `curl` never enforces CORS at all; only browsers do. If your API needs to be actually private, use authentication and authorization, not an origin allow-list — the allow-list only decides which websites are allowed to read a response inside a browser tab.
Debugging checklist
1. Check whether the failing request is a preflight (`OPTIONS`) or the real request in the network tab. 2. Confirm the response — including error responses — carries CORS headers. 3. Confirm the allowed origin matches exactly, including scheme and port. 4. If using credentials, confirm the origin is echoed, not `*`, and `Allow-Credentials: true` is present on both requests. 5. Confirm every custom header and method you send is listed in the corresponding `Allow-Headers`/`Allow-Methods`.
Generating this header set by hand for every environment (dev, staging, multiple frontend domains) is exactly the kind of repetitive, error-prone task worth automating with a small config generator rather than copy-pasting from an old project.