Why the same request works in Postman and fails in your browser
· 9 min read
Why does the same request work in Postman and fail in a browser? A plain-English guide to reading a cors preflight failed console message.

Your code called an API, the API answered, and the browser discarded the answer before your code could read it. Every version of this has the same root cause, and the name developers usually land on is a cors preflight failed message. That sequence also explains the most confusing symptom of all. The same request works in Postman, works pasted into the address bar, and fails from your own page. Nothing about the request changed. What changed is who was judging it.
Four assumptions sit behind that console message, and each one costs hours. The error is not a server fault, because the server did not fault. The browser is not broken, because withholding a cross-origin response from a script is what it was built to do. And the header you are probably misspelling is not a header at all. CORS is not a firewall either: it decides whether a script may read a response, not which servers a request can reach.
Same request, three environments, three different verdicts
Two URLs share an origin when they share a scheme, a host, and a port. RFC 6454 puts it plainly: "Roughly speaking, two URIs are part of the same origin (i.e., represent the same principal) if they have the same scheme, host, and port." The scheme sits in that tuple for a reason the same RFC spells out. Without it there would be no isolation between http://example.com and https://example.com, since both share a host name.
MDN's definition of the same-origin policy names the party doing the enforcing. The policy restricts how a document or script loaded by one origin can interact with a resource from another, and it lives in the user agent, not on the API.
That is the whole answer to the Postman puzzle. Typing a URL into the address bar is a top-level navigation, not a script-initiated fetch, so no cross-origin read check applies to the page you land on. Sending the same URL through Postman or curl means your client sends no Origin header at all, so the CORS layer on the server never engages and the request simply goes out. In both cases you get to see the response. Your own page is the only environment in that list where a policy decision happens at all.
What a CORS preflight is
A preflight is an OPTIONS request the browser sends by itself, before the request you wrote, and then waits on. The user agent is asking the server a question about permission: may a script from this origin issue this method with these headers? Only after that answer comes back does the real request leave. If the answer is no, the real request is never sent, which is why a failed preflight often leaves no trace of your actual call in the application's own logs.
The message you see is the answer to that question, generated by the browser, on the browser's side of the exchange, after your server has already replied.
The probe is credential-free by design
No cookies, no Authorization header, no client certificate. The probe has to be safe to send to a resource the page is not yet permitted to touch, so it carries nothing that identifies the user. That is also why attaching a bearer token makes an ordinary GET into a preflighted request: decode one and look at what it carries. That single fact explains a large share of the cases where someone adds the correct headers and still sees nothing change.
What turns a simple request into a CORS preflight failed error
The WHATWG Fetch Standard gives two conditions. A request needs a preflight when its use-CORS-preflight flag is set, or when its unsafe-request flag is set and either the method is not CORS-safelisted or a CORS-unsafe request-header name appears in the header list. Read that as three practical triggers.
The method. Only GET, HEAD, and POST are CORS-safelisted. A PUT, DELETE, or PATCH needs the probe every time it runs. GET and HEAD never make a request unsafe on their own.
The headers. Per MDN's CORS guide, the only headers you may set manually are the CORS-safelisted request headers: Accept, Accept-Language, Content-Language, Content-Type, and Range with a single range value. Anything else your code adds, including X-Requested-With, X-Api-Key, or Authorization, pushes the request out of the simple category.
The content type. The same page permits only three type and subtype combinations: application/x-www-form-urlencoded, multipart/form-data, and text/plain. This is the most common single trigger. A REST endpoint that returns JSON is usually called with Content-Type: application/json, which is not on that list, so the request you assumed was simple becomes a preflighted one. MDN keeps the list short because those three are the types guaranteed not to be interpreted as something more dangerous than the caller meant.
There is a fourth trigger, and it lives in the body rather than the metadata. A request body streamed from a ReadableStream sets the preflight flag on its own. An ordinary JSON string body does not need that, but it has already tripped the flag through its content type. cors.dev's guide to the preflight failure documents both routes.
Reading a cors preflight failed message in the console
Your catch block will tell you Failed to fetch and nothing more. That is deliberate. MDN's page on CORS errors states that for security reasons specifics about what went wrong with a CORS request are not available to JavaScript, and the console is the only place the detail exists.
The console line reads roughly as Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at ... (Reason: ...). The parenthetical is the useful part, and browsers differ in how much they put there. MDN documents sixteen named Firefox reasons, each with its own page, which makes them a usable lookup table:
- No
Access-Control-Allow-Originheader at all, the missing header reason. - The header is present and its value does not match the sending origin, the mismatch reason.
- A wildcard origin combined with credentials, the credentials with wildcard reason.
Pair this with the Network panel. The preflight's own OPTIONS entry shows the real status code and the raw response headers. A 404 or 405 there means no route handles OPTIONS at all, which looks nothing like a CORS error in the console but behaves identically from the browser's side.
The header rules that bite: wildcard, credentials, and Authorization
The CORS check in the Fetch Standard runs in an order that explains most of the confusion. Read Access-Control-Allow-Origin from the response. If it is absent, fail. If the request's credentials mode is not include and the value is *, succeed. If the origin does not match byte for byte, fail. Then, and only then, if credentials were included, require Access-Control-Allow-Credentials to be exactly true.
The wildcard is an escape hatch that exists only on the non-credentialed path. Credentialed requests need two specific headers instead of one: your origin echoed back, and Access-Control-Allow-Credentials: true. MDN's reference page for that header makes the incompatibility explicit.
Authorization carries its own rule, and it surprises people. The Fetch Standard defines a CORS non-wildcard request-header name as one matching Authorization, and the preflight cache lookup applies a wildcard only when the header name is not on that list. So Access-Control-Allow-Headers: * never covers Authorization, and no configuration makes it do so. Anyone telling you to send a wildcard for an authenticated API is wrong about the spec.
PortSwigger's CORS material documents the two allowlist mistakes that survive review. Browsers send a real null origin for cross-origin redirects, requests from serialized data, file: URLs, and sandboxed frames, so whitelisting null to make local development work opens a hole. And an allowlist checked with a suffix match can be reached by registering hackersnormal-website.com when the approved entry is normal-website.com.
Why a cors preflight failed error comes back after the fix
A server that omits Access-Control-Max-Age gets a five second preflight cache. The Fetch Standard says so directly: if max-age is failure or null, set it to 5. It then clamps that value to an imposed limit the specification deliberately does not publish. So Access-Control-Max-Age: 86400 is a request rather than a promise, and the fix that worked two minutes ago may still be waiting out a cache entry. MDN's reference page for the header lists the same default.
Vary: Origin is the second half of this story. A cached response stored without it can be replayed to an origin it was never approved for, which produces the classic it works on my machine. MDN includes it in its worked preflight example for exactly that reason.
Then there are the failures that are not about headers at all. Auth middleware that rejects the OPTIONS probe with a 401 or 403. A router with no OPTIONS handler. A redirect that fires on OPTIONS and fails immediately. A CDN or reverse proxy that strips the response headers you spent an afternoon adding. cors.dev groups these as infrastructure causes, and they account for a large share of the reports where the headers are demonstrably correct.
Four myths that send people down the wrong path
CORS is not a server error
Your server returned 200, and you can see that in the logs. The 200 and the red console message are consistent rather than contradictory, because the decision to withhold the response happened after the server finished its part. A status code tells you the request succeeded. It says nothing about whether a script is permitted to read the answer.
Your browser is not broken
Hiding the details from JavaScript is a documented security property, not a defect. A page that could probe a server's CORS configuration from script, and read the response of any endpoint it liked, would turn every browser into a credential-testing tool for every site its owner visits. The console message is the browser reporting a policy decision it made correctly.
Access-Control-Allow-Original is not a header
Worth stating flatly, because it is easy to type and easy to miss. Access-Control-Allow-Original does not exist. The string appears in no normative definition: not in the WHATWG Fetch Standard, not in RFC 9110 on HTTP semantics, not in RFC 6454 on origins.
A misspelled header is not ignored with a warning. It is a header nobody defined, so your response carries no Access-Control-Allow-Origin at all, and the browser reports the missing-header failure. Meanwhile your source code shows a line that looks exactly right. Note too that RFC 9110 defines no Access-Control-* header whatsoever. The protocol lives in the Fetch Standard; RFC 6454 governs the origin tuple. Conflating those two is a recurring citation error.
CORS is not a firewall
What a passing CORS check actually says is: this page's script may read this response. It does not stop the request from leaving the machine, it does not authenticate the caller, and it does not make the data private from anyone who asks the server directly. CORS is a read permission granted to one specific class of client, and PortSwigger's writeup is the standard reference for what it fails to protect against.
What you can fix without server access
MDN lists three remedies for someone who cannot touch the API, and each has a sharp edge. Restructure the request so it qualifies as simple, which usually means changing a content type or dropping a custom header. Use no-cors, which MDN describes as returning an opaque response whose status is 0, whose headers are empty, and whose body cannot be read by JavaScript. It suits analytics beacons and service worker cache warming, where you send and never read, and the quiet console is not a fix. Or route the call through a proxy you control.
There is a fourth option that costs nothing and is worth adding everywhere you can reach: set Vary: Origin on the CORS response. It grants no permission on its own. It stops a shared cache from handing your approval to somebody else.
Related Tools & Further Reading
- The ToolSura web security hub for header, cookie, and transport checks that sit alongside any CORS policy.
- The developer tools collection for the request and response inspection this article keeps describing in the abstract.
- MDN's CORS guide for the complete request-header and content-type tables, with worked examples.
- PortSwigger's CORS vulnerabilities for the origin-bypass cases a policy test will not catch.
- RFC 6454, the Web Origin Concept for why scheme, host, and port define the boundary in the first place.
Written by
Abhay Khant
Abhay Khant is the founder of ToolSura, a privacy-first developer tools platform. Writes about client-side architecture, AI tooling, and the open web.