You test an endpoint in Postman and it works perfectly. Correct status code, correct JSON, no complaints. Then you copy the same URL into your frontend, run it, and the console fills with red. Same endpoint, same method, same body, completely different outcome.
The natural conclusion is that something is wrong with your fetch code. Usually there is nothing wrong with your fetch code. The request is fine, and the server is fine. What changed is who is making the request.
Postman is not a browser. It is a program that opens a connection and sends bytes. A browser is a program that opens a connection, sends bytes, and then enforces a long list of rules designed to protect the user from the website they are currently visiting. Those rules do not exist in Postman, so Postman can never tell you whether a browser would have allowed the same call.
What is Postman actually skipping?#
Three things, and each one causes a different failure.
The first is the origin. Every request a browser makes comes from a page, and that page has an origin made of scheme, host and port. https://app.example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:5173. Postman has no origin at all, because it is not on a page. So it can never be making a cross origin request, which means the whole set of cross origin rules simply does not apply to it.
The second is cookie handling. Postman keeps a cookie jar that ignores SameSite, ignores Secure, and cheerfully stores things a browser would throw away. I wrote about that in detail in why your cookie is not being set, and it is the second most common cause of this exact symptom.
The third is the same origin policy, which is the rule underneath all of it. A page is not allowed to read the response from another origin unless that origin explicitly agrees. Without this, any website you visited could quietly read your email, because your browser would happily attach your cookies to a request to your mail provider and hand the result to whatever script asked for it.
So when Postman succeeds and the browser fails, the browser is usually not broken. It is doing the one job Postman was never asked to do.
Is it a CORS error?#
Open the console and look for wording like this:
Access to fetch at 'https://api.example.com/users' from origin
'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.If you see that, the server never gave the browser permission to share the response with your page. The fix is on the server, which has to say who is allowed:
Access-Control-Allow-Origin: http://localhost:3000There is one detail here that saves a lot of confusion. CORS is enforced by the browser, not by the server. The server does not block anything. It simply states, in a response header, which origins are allowed to read what it sent. The browser reads that statement and decides whether to hand the response to your JavaScript or throw it away.
This is why adding a CORS library to your backend feels like it "unlocks" the API. Nothing was locked. You just started answering a question the browser had been asking all along.
Why is there an OPTIONS request I never wrote?#
Look at the Network tab and you will often find an OPTIONS request sitting in front of your real one, which nobody in your codebase asked for. That is a preflight.
Before sending certain cross origin requests, the browser sends a small OPTIONS request to ask whether the real one is acceptable. It does this whenever the request is not what the specification calls simple, which in practice means any of the following:
- The method is something other than
GET,HEADorPOST. - You set a custom header, such as
AuthorizationorX-Api-Key. - The
Content-Typeis anything other thanapplication/x-www-form-urlencoded,multipart/form-dataortext/plain.
That last one catches almost everybody, because application/json is not on the list. A perfectly ordinary JSON POST is preflighted, which is why so many people meet OPTIONS for the first time while debugging their login form.
The preflight asks its question with headers, and the server has to answer all of them:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400If the preflight fails, the real request is never sent at all. This produces one of the strangest symptoms in web development, where your backend logs show nothing, your endpoint is definitely running, and the browser insists it tried. It did try. It asked permission first, did not like the answer, and stopped.
Access-Control-Max-Age is worth setting, because it tells the browser how long it may reuse the answer instead of preflighting every single call.
Why does the preflight come back 401 or 403?#
This one deserves its own section, because the cause is almost always the same and it is very easy to miss.
An OPTIONS preflight is sent without credentials. No cookies, no Authorization header, nothing. It is a question about permission, not an attempt to do anything. If your authentication middleware runs before your CORS middleware, it sees a request with no credentials attached and rejects it with a 401, long before anything gets a chance to answer the browser's question.
The result looks like a broken login, but the login was never attempted. The fix is ordering. CORS handling has to come first in your middleware chain, and the OPTIONS method has to be allowed through without authentication.
Why does the response arrive but my code still fails?#
Sometimes the Network tab shows a healthy 200 with a full response body, and your fetch still rejects. Reading that as a contradiction is understandable, but both things are true at once.
Unless a request is preflighted, the browser sends it, the server receives it, and it does whatever it was going to do. The row you are looking at in the Network tab is real. What the browser then does is check the CORS headers on the way back, decide your page is not allowed to read the result, and reject the promise with a TypeError and no status code.
The important consequence is one that catches people out on the security side. A blocked response does not mean a blocked request. If that call created a record or charged a card, that still happened. CORS protects the user from reading data, and it was never a substitute for the server checking whether the caller was allowed to act. It is the same principle as the fact that a server can never trust your browser, just seen from the other direction.
If you need to read a custom response header, note that the browser hides those too. The server has to list them explicitly with Access-Control-Expose-Headers, which is why your pagination or rate limit header looks missing even though you can see it in the Network tab.
Why does it break only when I send cookies?#
The moment your request carries credentials, the rules get stricter.
First, you have to opt in on the client, because fetch does not send cookies cross origin by default:
await fetch("https://api.example.com/me", { credentials: "include" });Then the server has to opt in as well, with one extra rule that surprises everybody:
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin: * is not allowed once credentials are involved. The wildcard means "anyone may read this", and the browser refuses to combine that with a request carrying somebody's session. You have to name the exact origin, which usually means reading the incoming Origin header and echoing it back from a list you trust. If you do that, send Vary: Origin too, otherwise a cache can serve one origin's response to another.
And if the cookie itself is the problem rather than the CORS configuration, the request will look permitted while the user stays logged out. That is a different bug with its own checklist, and the quickest way to tell them apart is whether the console shows a CORS message at all. No message and no session usually means the cookie never made it.
A checklist to work through#
Go in this order, because each step rules out the one after it:
- Does the console show a CORS message? If not, it is probably a cookie or session problem, not a CORS one.
- Is there an
OPTIONSrequest in the Network tab, and what did it return? Fix that before looking at anything else. - If the preflight returned
401or403, move your CORS middleware above your auth middleware. - Does the response carry
Access-Control-Allow-Origin, and does it match your origin exactly, including scheme and port? - Are you sending credentials? Then the wildcard is out and
Access-Control-Allow-Credentials: trueis required. - Are you trying to read a custom header? Add
Access-Control-Expose-Headers. - Are your frontend and backend using the same hostname spelling, rather than mixing
localhostand127.0.0.1?
Frequently asked questions#
Can I fix CORS from the frontend?#
No, and this is worth being blunt about. The permission has to come from the server that owns the data, because the entire point is that a page cannot grant itself access to another origin. Browser extensions that disable the check only change your own machine, and your users will still be blocked.
Is a proxy a legitimate fix?#
Yes, and it is the standard answer when you do not control the API. Your own backend calls the third party server to server, where none of these rules apply, and your frontend talks only to your backend on the same origin. Most dev servers, including Vite and Next.js, have a proxy option built in for exactly this during development.
Why does it work in production but not locally?#
In production your app and API are often served from the same origin, or from a properly configured one, so no cross origin rules apply. Locally they sit on two different ports, which makes them two different origins. Anything marked Secure also disappears over plain HTTP, which is why local development finds these bugs first.
Does a CORS error mean my API is secure?#
Not at all. CORS only stops other websites from reading responses in a user's browser. It does nothing about direct calls, which is precisely why Postman reached your endpoint without any difficulty. If an endpoint should be restricted, the server has to check that itself on every request.
Why does Postman succeed when the browser fails?#
Because Postman has no origin, does not apply the same origin policy, never sends a preflight, and ignores cookie attributes. A green result in Postman proves your server works. It proves nothing about whether a browser will let your page read the answer.
