A CORS preflight failure means the browser sent an OPTIONS request and the server did not return the headers required for the real request. The server must allow the exact origin, the method, and every custom header, and it must answer the OPTIONS request with a 2xx status before the browser sends the actual request.
- CORS is enforced by the browser, not by the server or curl.
- Non-simple requests trigger an OPTIONS preflight before the real request.
- Access-Control-Allow-Origin must match the exact origin when credentials are included.
- A wildcard origin and Access-Control-Allow-Credentials: true cannot be combined.
- Reproduce the preflight with curl to see the headers the browser sees.
CORS errors are frustrating because the network tab often shows a successful response while the browser console reports a failure. The server did return data; the browser refused to let your JavaScript read it.
This guide separates the preflight from the actual request, shows how to inspect both, and covers the header combinations that cause most failures.
What triggers a preflight
A request is "simple" only when it uses GET, HEAD, or POST, uses a limited set of content types, and does not include custom headers. The following trigger an OPTIONS preflight:
PUT,PATCH, orDELETE.Content-Type: application/json, or any content type outside the three safelisted values.- Custom headers such as
AuthorizationorX-Request-Id. - A
Rangeheader that requests more than one range.
The safelisted request headers are a short, closed list: Accept, Accept-Language, Content-Language, Content-Type with application/x-www-form-urlencoded, multipart/form-data, or text/plain, and single-range Range. Any other header name makes the request non-simple, even when its value is empty. Credentials are a separate axis: credentials: "include" does not trigger a preflight by itself. A GET with cookies and no custom headers is still sent directly, but the browser checks the response under stricter rules, which the later section covers.
The browser sends the preflight first:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
The server must respond with a 2xx status and the allowed origin, method, and headers. Only then does the browser send the real request.
Access-Control-Max-Age controls how long the browser caches that preflight result. Browsers cap the value β Chromium has historically capped it at two hours and Firefox at 24 hours β so a server returning 86400 gets at most the browser's maximum. The cache key includes the origin, URL, method, and the set of header names, so adding one header to a request forces a fresh preflight for that combination. This is also why a backend change can appear to have no effect: the browser is replaying a cached success until the max age expires or the header set changes.
Read the actual error
Modern browsers give more detail than "CORS error". Look for one of these:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Response to preflight request doesn't pass access control check.
The value of the 'Access-Control-Allow-Origin' header must not be '*' when the request's credentials mode is 'include'.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
Each message points to a different missing or incorrect header. Do not change the origin to * as a reflex; that breaks credentialed requests and can expose data to unintended origins.
JavaScript never receives the HTTP status of a failed CORS response. fetch rejects with TypeError: Failed to fetch in Chromium, NetworkError when attempting to fetch resource in Firefox, or Load failed in Safari, and DevTools may show status 0. The real reason appears only in the console as a CORS message. That is why a try/catch around fetch cannot distinguish CORS from DNS failure, offline mode, or mixed content; the console and the network panel are the evidence, not the caught error.
Firefox phrases the failures differently, and its wording is often more actionable: Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at <url>. (Reason: CORS header 'Access-Control-Allow-Origin' missing). The Reason: text names the specific missing header or failed check, which saves a round of guessing when Chrome's longer explanation is ambiguous.
Reproduce the preflight with curl
The browser's network panel is useful, but curl gives you the raw response:
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: authorization, content-type"
A correct response looks like:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Vary: Origin
If the response is 401, 403, or 404, the preflight is being handled by the wrong layer. If the headers are missing, the CORS middleware is not running on the OPTIONS route. If the origin echoes * while credentials are used, the browser will reject it.
Two other statuses are worth naming. A 405 Method Not Allowed means the route exists but OPTIONS is not allowed; some frameworks only answer OPTIONS automatically when the CORS middleware is installed globally. A 301 or 302 means the preflight is being redirected; browsers have historically rejected redirects during preflight, so call the canonical URL directly, usually the HTTPS host without a trailing slash, instead of relying on the redirect.
Two curl variants isolate layers. Add --resolve api.example.com:443:<origin-ip> to send the request straight to the origin and compare that answer with the CDN's. Send Origin: null to see what a sandboxed iframe or a file:// page receives; if the server echoes null, it is granting access to every opaque origin, which is almost never intended.
The CORS checker and header parser can inspect a live response without writing a script.
Fix 1: Allow the exact origin
When credentials are involved, return the requesting origin only after checking an allowlist:
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
next();
});
The Vary: Origin header is required for caches. Without it, a CDN may cache the response for one origin and serve it to another.
Origin is a serialized origin: scheme, host, and port, with the default port omitted and no path or trailing slash. An allowlist entry written as https://app.example.com/ will never match, because the browser sends https://app.example.com. That trailing slash is one of the most common configuration mistakes. Matching should be exact after normalizing case for scheme and host, and it should not use prefix or suffix checks: https://app.example.com.evil.test starts with the right string and belongs to someone else.
Never add "null" to an allowlist to make a local file or sandboxed iframe work. Origin: null is sent by sandboxed iframes, data: and file: documents, and some redirect chains, so allowing it grants access to origins you cannot identify. For local development, run the frontend on a localhost port and allow that exact origin instead.
Fix 2: Answer the preflight before authentication
A preflight request does not include cookies or the Authorization header. If authentication middleware runs first, the browser sees a 401 and never sends the real request.
app.options("*", cors(corsOptions));
app.use(authenticate);
app.use(cors(corsOptions));
Order matters. The exact placement depends on your framework and proxy, but the principle is the same: let the preflight reach the CORS handler without requiring credentials.
Ordering problems are not limited to authentication. CSRF middleware, IP rate limiters, and request-size limits can also reject an OPTIONS request that carries no cookies and no body. A body-validation rule that requires Content-Type: application/json will return 415 for every preflight, because the browser sends none. Exempt preflight requests from middleware that depends on a body or credentials, and verify the exemption at the framework level rather than assuming the CORS package runs first.
Fix 3: Allow the headers you actually send
The browser compares the requested headers with Access-Control-Allow-Headers case-insensitively. If your client sends Authorization, that header must be allowed.
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id");
Avoid reflecting arbitrary headers from the request. An allowlist is safer and easier to audit.
Access-Control-Allow-Headers: * is not a universal shortcut. The wildcard is honored only for requests without credentials, and even then Authorization is explicitly excluded: it must be named in the list. An API that uses bearer tokens therefore always has to include Authorization by name. Header comparison is case-insensitive, but browsers send the requested names lowercased in Access-Control-Request-Headers, so a server that compares raw strings against a camel-cased allowlist can fail despite the HTTP rule; normalize both sides before comparing.
Fix 4: Expose response headers
CORS controls which response headers JavaScript can read. By default, only a small set is exposed. If your client needs X-Request-Id, ETag, or a pagination header, add:
Access-Control-Expose-Headers: X-Request-Id, ETag
This is separate from Access-Control-Allow-Headers, which controls what the client may send.
The safelisted response headers are a fixed, short list: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Everything else needs Access-Control-Expose-Headers, including common choices such as X-Request-Id, ETag, Location, and Content-Disposition. Set-Cookie is never exposed to JavaScript, with or without this header.
Access-Control-Expose-Headers: * behaves like its request-side counterpart: the wildcard exposes all non-safelisted headers only when credentials are not included. With credentials, * is treated as a literal header name and exposes nothing useful. Naming headers explicitly is more portable and easier to audit.
Credentials: the wildcard trap
This combination is invalid:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
The browser rejects it because a wildcard would allow any site to make credentialed requests. Use an exact origin. If you do not need cookies or client certificates, omit credentials and use a wildcard only for truly public data.
Credentials depend on cookie attributes as well as CORS headers. A cross-site request needs a cookie marked SameSite=None; Secure; a SameSite=Lax cookie is not sent on a cross-origin fetch at all. Even with perfect CORS headers, a browser that blocks third-party cookies will drop the cookie, and the server will see an anonymous request. Check the cookie column in DevTools before re-debugging the headers.
If you control both sides, a same-origin reverse proxy or a backend-for-frontend removes CORS from the browser path entirely and keeps tokens on the server. The tradeoff is an extra hop and another service to operate; the benefit is no preflight, no wildcard decisions, and no third-party cookie dependency.
Proxy and CDN layers
A common setup has three layers: browser, CDN, and origin. Any layer can add, remove, or duplicate CORS headers.
- Check the response headers at the edge, not only at the origin.
- Disable header rewriting for CORS headers unless you understand it.
- Ensure the CDN forwards the
Originheader. - Ensure the CDN does not cache a response with one origin's CORS headers for another origin.
- Check that the CDN answers OPTIONS requests or forwards them to the origin.
A duplicated header produces a distinct error: The 'Access-Control-Allow-Origin' header contains multiple values 'https://a.example.com, https://a.example.com', but only one is allowed. It usually means both the origin and the edge add the header. The fix is to delete one layer's rule, not to disable CORS at the edge.
Vary: Origin is not optional when the response varies by origin. A CDN that caches Access-Control-Allow-Origin: https://app.example.com and serves it to another origin does not create a data leak by itself, because the browser still compares the header with its own origin, but it breaks legitimate origins and makes debugging misleading. Some CDNs strip or rewrite Vary; confirm the header as it reaches the browser, not only as the origin emits it.
When the error is not CORS
Sometimes the console says CORS, but the underlying problem is a network failure, a mixed-content block, or a redirect to an HTML login page. If the response is an HTML page, the browser still reports a CORS failure because the expected headers are absent. Use the cURL converter to reproduce the request and inspect the status and body. The HTTP request workbench is useful for testing methods, headers, and response codes interactively.
mode: "no-cors" is not a fix. It silences the console error by making the response opaque, but JavaScript still cannot read the status or body, so the data might as well not exist. It only helps for fire-and-forget requests such as beacon-style logging, and even then the server may reject the request.
Mixed content is a different failure that looks similar: an HTTPS page cannot call http://api.example.com at all. The browser blocks the request before CORS runs, and the console mentions mixed content rather than a missing header. An expired TLS certificate and a DNS failure also surface as the same TypeError in JavaScript, so confirm that the request appears in the network panel before reading the CORS message.
A CORS debugging checklist
- Identify whether the request is simple or preflighted.
- Reproduce the OPTIONS request with curl.
- Confirm the status is 2xx.
- Confirm
Access-Control-Allow-Originmatches the exact origin. - Confirm the method is listed.
- Confirm every custom header is listed.
- Confirm credentials are configured correctly.
- Confirm
Vary: Originis present. - Check CDN and proxy behavior.
- Verify the response body and content type are what the client expects.
- Confirm the server logs show the
Originheader on both the OPTIONS and the actual request. - Confirm the allowlist entry has no trailing slash and includes the port when it is non-default.
- Confirm the preflight cache is not masking recent changes; a new origin or new header set forces a new preflight.
CORS is a browser contract, not a server security boundary. Once the preflight and the actual response agree, the error disappears; if they do not, the browser is telling you exactly which part of the contract is missing.
Frequently asked questions
Why does the request work in Postman but fail in the browser?
Postman does not enforce the browser same-origin policy. The browser checks the response headers and blocks JavaScript from reading a cross-origin response when the CORS headers are missing or incorrect. The server may be returning data successfully while the browser still blocks access.
What headers must a preflight response include?
At minimum, Access-Control-Allow-Origin. For a preflight, also include Access-Control-Allow-Methods and Access-Control-Allow-Headers for the method and headers being requested. If credentials are used, include Access-Control-Allow-Credentials: true and a specific origin.
Can I use Access-Control-Allow-Origin: * with cookies?
No. When a request includes credentials, the browser requires a specific origin in Access-Control-Allow-Origin. A wildcard is rejected. Reflect the requesting origin only after validating it against an allowlist.
Why does the OPTIONS request return 401 or 403?
Authentication middleware is often applied before CORS handling. The browser never sends credentials on the preflight request, so the server rejects it. Handle OPTIONS before authentication, or allow unauthenticated preflight requests to pass through.
Does CORS protect the server from unauthorized requests?
No. CORS is a browser-side control that limits which origins can read responses. It does not authenticate users and does not stop direct requests from curl, scripts, or attackers. Use authentication and authorization on the server.