CORS errors explained and fixed: preflight, credentials and headers
"Blocked by CORS policy" means the browser did not get the response headers it needed. Learn how preflight requests and credentials work, and how to configure CORS correctly in Express, Next.js route handlers and Nginx.
Muhammad Faizan
Founder & Lead Engineer
The browser console shows something like: Access to fetch at 'https://api.example.com/orders' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. The same request works in curl. The API logs might even show it succeeded.
CORS errors are confusing because the failure happens in the browser, the fix lives on the server, and the error text often describes a symptom rather than the cause. This guide explains the mechanism, then shows correct configurations for Express, Next.js route handlers and Nginx.
Why it happens
Browsers apply the same-origin policy: JavaScript on https://app.example.com cannot read responses from a different origin (scheme, host and port) unless that server explicitly allows it. CORS (Cross-Origin Resource Sharing) is the set of response headers a server uses to grant that permission. Without them, the browser may still send the request, but it hides the response from your code.
For anything beyond a simple request, the browser first sends a preflight: an OPTIONS request asking whether the real request is allowed. A request needs a preflight if it uses a method other than GET, HEAD or POST, sets headers such as Authorization, or sends a Content-Type other than form encodings or text/plain. In practice, every fetch that posts JSON or sends a bearer token is preflighted.
Most CORS bugs come from one of these:
- The
OPTIONSrequest hits a route that does not exist (404), requires auth (401) or is redirected (301/302). Preflights do not carry credentials and must not redirect. - The server allows the origin but not the headers or method the request uses, so
Access-Control-Allow-HeadersorAccess-Control-Allow-Methodsis missing a value. - The request uses
credentials: "include"and the server answers withAccess-Control-Allow-Origin: *. - The CORS headers are added on success responses only, so a 500 or 401 from the API surfaces as a CORS error instead of the real error.
- A proxy, CDN or framework layer strips or duplicates the headers. Two
Access-Control-Allow-Originvalues are as invalid as none.
Step 1: Read the preflight in the Network tab
Open DevTools, enable Preserve log, and find the OPTIONS request just before the failing call. Check its status code and response headers. The console message tells you which header the browser was unhappy with. You can reproduce the preflight from a terminal to iterate faster:
curl -i -X OPTIONS https://api.example.com/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type, authorization"
# Expect 204 (or 200) with:
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET, POST, PUT, DELETE
# Access-Control-Allow-Headers: content-type, authorization
# Access-Control-Allow-Credentials: true (only if you use cookies)
# Vary: OriginStep 2: Configure CORS in your server
Express
Use the cors middleware and register it before your routes and before any authentication middleware, so preflights are answered without requiring a token. With app.use, the middleware also responds to OPTIONS requests automatically, which avoids the Express 5 path-syntax change that broke many app.options("*") setups.
import express from "express";
import cors from "cors";
const allowedOrigins = new Set([
"https://app.example.com",
"http://localhost:3000",
]);
const app = express();
app.use(
cors({
origin(origin, callback) {
// Allow same-origin and server-to-server requests (no Origin header)
if (!origin || allowedOrigins.has(origin)) return callback(null, origin ?? true);
return callback(null, false);
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "Idempotency-Key"],
maxAge: 600, // cache preflight results for 10 minutes
}),
);
app.use(express.json());
// ...auth middleware and routes go after cors()Next.js route handlers
If your frontend and API share an origin, you do not need CORS at all; relative fetch calls are same-origin. When other origins call your route handlers, export an OPTIONS handler and add the same headers to every response, including errors.
const allowed = new Set(["https://app.example.com"]);
function corsHeaders(origin: string | null): HeadersInit {
if (!origin || !allowed.has(origin)) return { Vary: "Origin" };
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "600",
Vary: "Origin",
};
}
export async function OPTIONS(request: Request) {
return new Response(null, { status: 204, headers: corsHeaders(request.headers.get("origin")) });
}
export async function POST(request: Request) {
const headers = corsHeaders(request.headers.get("origin"));
try {
const order = await request.json();
// ...create the order
return Response.json({ ok: true, order }, { status: 201, headers });
} catch {
// Errors need CORS headers too, or the browser reports a CORS error instead
return Response.json({ error: "Invalid request" }, { status: 400, headers });
}
}Nginx
When Nginx sits in front of your API, handle CORS in one place: either Nginx or the application, never both. Use a map to validate the origin, answer preflights directly, and add always so headers are sent on error responses too. Remember that add_header directives in a location block replace, not extend, those defined at the server level.
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"http://localhost:3000" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
proxy_pass http://api_upstream;
}
}Step 3: Check the real request, not just the preflight
If the preflight now passes but the actual request still fails, look at the second request in the Network tab. A missing Access-Control-Allow-Origin on the real response usually means the request reached a different code path: an error handler, an auth middleware that rejected it, or an upstream proxy that returned its own 502 page. Fix the headers on that path and the browser will show you the real status code and body instead of a CORS message.
Resist the temptation to set mode: "no-cors" on the fetch call. It does not disable CORS; it tells the browser to make the request anyway and hand you an opaque response whose status and body you cannot read. The console error disappears, but your code receives nothing useful.
Why "just use *" fails with credentials
If your frontend sends cookies or HTTP auth with credentials: "include" (or withCredentials in Axios), the browser requires Access-Control-Allow-Origin to be the exact requesting origin and Access-Control-Allow-Credentials: true. The wildcard is rejected, and with credentials * in Access-Control-Allow-Headers or Access-Control-Allow-Methods is treated as a literal header name rather than a wildcard. The console says so explicitly: the value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard when the request's credentials mode is 'include'.
Echoing back whatever Origin you receive gets past the check but is a security hole: any website can then make authenticated requests on behalf of your logged-in users and read the responses. Always validate against an allowlist, and add Vary: Origin so caches do not serve one origin's response to another.
Prevention tips
- Avoid cross-origin calls where you can: serve the API under the same origin, or proxy it through your frontend's server.
- Keep the origin allowlist in configuration per environment, including preview URLs if you need them.
- Configure CORS in exactly one layer to avoid duplicate headers.
- Add an integration test that sends a preflight and asserts the headers, including on a 4xx response.
- Set
Access-Control-Max-Ageto cut down on repeated preflights.
Key takeaways
- CORS is enforced by the browser; the fix is always on the server or by removing the cross-origin call.
- JSON bodies and
Authorizationheaders trigger a preflightOPTIONSrequest that must return 2xx without auth or redirects. - With credentials, echo a validated origin, send
Access-Control-Allow-Credentials: trueandVary: Origin; never use*. - Add CORS headers to error responses too, or real errors will look like CORS failures.
Designing an API that several frontends, partners or mobile apps will call? Our backend and API team builds auth and cross-origin access in from the start. Get in touch.
Frequently asked questions
Why does my request work in Postman or curl but fail in the browser?
CORS is enforced by browsers, not servers. Postman and curl do not apply the same-origin policy, so they show you the response the browser is refusing to hand to your JavaScript.
Can I fix CORS from the frontend?
No. The server you are calling must send the right headers. From the frontend you can only avoid cross-origin calls, for example by proxying requests through your own backend on the same origin.
Why can't I use Access-Control-Allow-Origin: * with cookies?
The spec forbids the wildcard when credentials are included. The server must echo the specific allowed origin, send Access-Control-Allow-Credentials: true and add Vary: Origin.
Need help with backend & apis?
Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.