A POST request with a JSON body throws a CORS error in the browser console, and the instinctive reaction is to assume something is fundamentally broken with the server’s CORS setup. Often nothing is broken at all. Sending JSON specifically is one of the most common, least obvious triggers for a CORS preflight request, and if the server doesn’t handle the resulting OPTIONS request correctly, the actual POST never even gets a chance to run. The browser blocks it before it leaves, based entirely on how the preflight response looked.
Most explanations of CORS either stay too abstract, defining “cross-origin” and stopping there, or dump the entire specification on you without saying which parts actually matter for the request you’re debugging right now. This tool answers one specific, practical question directly: for the exact request you’re building, does a preflight fire, and if it does, exactly which headers does your server’s response need to include for the real request to succeed.
Check Your Request
What Actually Counts as a “Simple Request”
The CORS specification lets a small category of requests skip the preflight step entirely, called simple requests, and the criteria are narrower than most people assume. A request only qualifies as simple if it meets every one of these conditions at once: the method is GET, HEAD, or POST (nothing else, not PUT, PATCH, or DELETE), only a small allowlist of headers is present (Accept, Accept-Language, Content-Language, and Content-Type), and if Content-Type is set, its value is restricted to exactly application/x-www-form-urlencoded, multipart/form-data, or text/plain.
Miss any one of these conditions, and the browser automatically sends a preflight OPTIONS request first, entirely on its own, before your actual request goes out. This isn’t something your JavaScript code controls or can skip. It’s browser behavior baked into the Fetch and XMLHttpRequest specifications specifically so that a cross-origin request with side effects doesn’t fire against a server that hasn’t explicitly agreed to allow it.
Why JSON Almost Always Triggers a Preflight
This is the single most common source of “but I didn’t change anything, why is CORS suddenly broken” confusion. application/json is not one of the three allowed simple Content-Type values, which means the moment an API switches from form-encoded submissions to sending JSON, a request that previously skipped preflight entirely now triggers one. Nothing about the actual endpoint changed. The request just crossed from “simple” into “needs preflight” purely because of the Content-Type header, and if the server’s OPTIONS handler wasn’t built to respond to preflight requests correctly, the JSON version fails while an old form-encoded version of the same endpoint kept working fine.
Custom headers cause the identical effect. Adding an Authorization header, or any custom header your API uses for versioning or tracing, immediately disqualifies a request from being simple, regardless of the method or Content-Type, since Authorization isn’t on the safelist either. If you’re implementing JWT-based authentication and testing tokens directly, this is exactly the kind of header that will trigger a preflight on every authenticated request; this JWT decoder is useful for inspecting the token itself once you’ve confirmed the CORS handshake around it is working correctly.
What the Browser Actually Does During Preflight
When a request needs preflight, the browser sends a separate OPTIONS request to the same URL, before your actual request, including two specific headers describing what the real request intends to do: Access-Control-Request-Method, stating the HTTP method the real request will use, and Access-Control-Request-Headers, listing any custom headers the real request will include. The server’s response to this OPTIONS request is what the browser evaluates to decide whether to proceed.
If the server’s OPTIONS response doesn’t include the right headers, or includes values that don’t match what the browser is asking permission for, the browser stops there. It never sends the actual GET, POST, or whatever the real request was going to be. This is a critical point that trips people up while debugging: the server might handle the real request perfectly correctly if it ever received it, but the browser blocked it before it ever left, based purely on the preflight response. Checking server logs for the actual endpoint and finding nothing is expected in this case, since the real request genuinely never arrived.
Access-Control-Allow-Origin, Explained Properly
This header tells the browser which origins are allowed to read the response. It has to either exactly match the requesting origin, protocol, domain, and port all included, or be the wildcard *, meaning any origin is allowed. An easy mistake here is assuming a partial match works, like allowing example.com when the actual request came from https://app.example.com, a genuinely different origin as far as CORS is concerned, since subdomains count as distinct origins from their parent domain.
The wildcard has one hard restriction that catches people off guard: it cannot be used at all when the request includes credentials. This isn’t a configuration choice, it’s an explicit rule in the specification specifically to prevent a scenario where any website could make an authenticated, cookie-carrying request to your API and read the response, purely because a wildcard origin combined with credentials would defeat the entire purpose of same-origin protection.
Access-Control-Allow-Credentials, and Why It Breaks With a Wildcard
When a request includes credentials, cookies being sent automatically, or an Authorization header carrying a token, the server’s response needs an explicit Access-Control-Allow-Credentials: true header, and the corresponding Access-Control-Allow-Origin has to be the exact requesting origin, never a wildcard. If both conditions aren’t met precisely, the browser discards the response even if the server actually processed the request successfully on its end, which is another case where the request “worked” server-side but the browser still reports a failure to the calling JavaScript.
The most common credentialed-request mistake
Setting Access-Control-Allow-Origin: * alongside Access-Control-Allow-Credentials: true is invalid, and browsers reject this combination outright rather than picking one to honor. If your request includes credentials and you’re seeing a CORS failure despite what looks like correct configuration, check specifically whether the origin header is still set to a wildcard rather than the actual, specific requesting origin.
Access-Control-Allow-Methods and Access-Control-Allow-Headers
These two headers only matter for preflighted requests, and they need to reflect exactly what the real request is going to do. Access-Control-Allow-Methods should list every HTTP method your endpoint actually needs to support cross-origin, not just the one being tested at the moment, since a fixed list here that only includes GET will still block a PUT request even if the server would otherwise handle PUT correctly.
Access-Control-Allow-Headers needs to explicitly list every custom header the client sends, matched against what the browser requested via Access-Control-Request-Headers during preflight. Missing a single header here, forgetting to add a newly introduced custom header like a client version identifier, is a common way an API that worked fine yesterday suddenly starts failing today, purely because a new header was added to requests without updating this allowlist to match.
Access-Control-Max-Age: Caching the Preflight Result
Without this header, the browser sends a fresh preflight OPTIONS request before every single actual request, which adds a real, measurable extra round-trip to every cross-origin call. Access-Control-Max-Age, set to a number of seconds, tells the browser it can cache the preflight result and skip repeating it for subsequent identical requests within that window. Setting this to a reasonable value, commonly somewhere between a few minutes and a few hours depending on how often your CORS configuration actually changes, meaningfully reduces the number of preflight round-trips a busy client application makes without any downside beyond a brief delay before a genuine configuration change takes effect for clients that already cached the old result.
Decoding Common CORS Error Messages
| Console error | What it actually means |
|---|---|
| “No ‘Access-Control-Allow-Origin’ header is present on the requested resource” | The server’s response didn’t include the header at all, either because CORS isn’t configured on that endpoint, or because the OPTIONS preflight failed before reaching your actual route handler |
| “The ‘Access-Control-Allow-Origin’ header has a value that is not equal to the supplied origin” | The header is present but doesn’t exactly match the requesting origin, often because it’s hardcoded to a different domain, protocol, or port than what’s actually making the request |
| “Response to preflight request doesn’t pass access control check” | The OPTIONS response itself is missing a required header or has an incorrect value, most often Allow-Methods or Allow-Headers not matching what the real request needs |
| “The value of the ‘Access-Control-Allow-Credentials’ header is ” which must be ‘true'” | Credentials are being sent, but the server’s response is either missing this header entirely or has it set to something other than exactly the string “true” |
| “Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true” | The exact wildcard-plus-credentials conflict described above; the origin needs to become a specific, exact value instead of * |
Framework-Specific Notes
Node.js / Express
The cors npm package handles the correct response headers automatically for most cases, but its default configuration allows all origins with a wildcard, which silently breaks the moment your requests start including credentials. Explicitly configuring the origin option to a specific value or a validating function, rather than leaving it at the permissive default, is worth doing deliberately before an application handles any authenticated cross-origin traffic.
Laravel
Laravel’s built-in CORS configuration, in config/cors.php, lets you specify allowed origins, methods, and headers explicitly. A common oversight is forgetting to add newly introduced custom headers to the allowed_headers array as an API evolves, which reproduces exactly the “worked yesterday, broken today” pattern described earlier once a new header gets introduced without a matching configuration update.
Nginx
Handling CORS at the Nginx layer, rather than in application code, means explicitly configuring an if ($request_method = OPTIONS) block (or an equivalent modern approach, since if inside location blocks is notoriously easy to misuse in Nginx) to return the correct headers and an empty 204 response for preflight requests specifically, since Nginx won’t proxy an OPTIONS request through to your application by default in every configuration.
Common Mistakes
Testing only with tools like Postman or curl and assuming CORS is fine because the request succeeds there is a frequent, misleading pattern, since CORS is a browser-enforced mechanism specifically. Postman and curl don’t enforce it at all, so a request that works perfectly in either tool can still fail entirely in an actual browser, and the only reliable way to verify CORS behavior is testing from an actual browser context making a genuinely cross-origin request.
Reflecting the request’s Origin header back verbatim as a blanket solution, rather than validating it against an actual allowlist, technically resolves the immediate error but quietly reopens the exact security boundary CORS exists to enforce, since it effectively allows every origin while looking like a properly configured, specific response.
Forgetting that preflight responses need their own explicit handling, separate from your actual route logic, catches a lot of people who’ve correctly added CORS headers to their real endpoint responses but never configured anything for the OPTIONS method itself, which then returns a default 404 or 405 that fails the preflight check before the real request logic is ever reached.
FAQ
Why does my GET request not trigger a preflight but my POST with JSON does?
GET requests without custom headers typically qualify as simple requests and skip preflight entirely. A POST with a JSON body fails the simple-request criteria specifically because application/json isn’t one of the three allowed Content-Type values, which forces a preflight regardless of the method being otherwise simple.
Can I disable CORS preflight checks?
Not from the client side, since it’s enforced by the browser itself, not something client-side JavaScript can opt out of. The only way to avoid triggering one is keeping a request within the simple-request criteria, which usually isn’t practical for a real JSON API.
Does CORS protect my API from unauthorized access?
No, and this is a common misunderstanding. CORS controls which browser-based origins are allowed to read a response, but it does nothing to stop a direct request made outside a browser context, like from a server, a script, or a tool like curl. Actual authorization still needs to happen through proper authentication checks on the server itself.
Why does my request work in development but fail in production?
This is almost always an origin mismatch. A CORS configuration allowing http://localhost:3000 during development won’t automatically allow your actual production domain, and this needs to be added explicitly rather than assumed to carry over.

