Your frontend runs on http://localhost:5173, your API on http://localhost:4000, and the fetch call that worked fine in Postman fails in the browser with a wall of red text: Access to fetch at 'http://localhost:4000/api/users' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Your server may well have handled that request and returned a 200. The browser refused to hand the response to your JavaScript.
That refusal is the same-origin policy. CORS is how a server tells the browser to relax it for specific callers, and every fix below comes down to one response header the browser didn’t get.
What same-origin policy actually blocks
An origin is the combination of scheme, host, and port. http://localhost:5173 and http://localhost:4000 are different origins because the port differs, even though the host and scheme match. https://api.example.com and http://api.example.com are different origins too, because the scheme differs: one runs over TLS and one doesn’t.
Same-origin policy is a browser rule, not a server rule. A script running on one origin cannot read the response of a request it made to a different origin unless that origin explicitly allows it. For a plain GET, the request still goes out and the server still processes it: you’ll see it in the server logs and in the Network tab with a real status code. What the browser withholds is the response body. That’s why a CORS error looks like a client-side failure while the server reports nothing wrong.
The point is to stop a page at evil.example from using your logged-in browser session to read your bank’s API responses. Without the policy, any site you visit could issue authenticated requests to any other site and read the answers.
What CORS allows, and what it doesn’t protect
Cross-Origin Resource Sharing is an opt-in exception to that rule. The server adds response headers naming which origins may read its responses, and the browser honors them instead of blocking.
The server doesn’t refuse anything. CORS headers are instructions to the browser, not access control. A curl request, a backend-to-backend call, or a native mobile app hits the same endpoint with no CORS check at all. If your API needs protection, that’s authentication and authorization.
When the browser sends a CORS preflight request
The browser sorts every cross-origin request into one of two kinds, and only one of them asks permission first.
A request is simple when all of the following hold:
- The method is
GET,HEAD, orPOST. - The only headers set are ones the browser sets automatically, plus a short allowed list (
Accept,Accept-Language,Content-Language,Content-Type,Range). - If
Content-Typeis set, its value isapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
A simple request goes straight out, and the browser checks the response headers to decide whether your script gets the body. Anything else is preflighted: a PUT or DELETE, a JSON body (Content-Type: application/json), or a custom header like Authorization or X-API-Key. Before sending the real request, the browser sends an OPTIONS request asking the server what it permits:
| |
The server answers with what it allows. Only if that answer covers the actual request does the browser send it:
| |
Access-Control-Max-Age is how long, in seconds, the browser may cache that answer and skip the preflight on the next matching request. A JSON API that sends a bearer token triggers a preflight on nearly every call, so this header saves a round trip each time. Browsers cap it: Chromium won’t cache for more than 7200 seconds, whatever you send.
CORS response headers and what each one does
| Header | What it does |
|---|---|
Access-Control-Allow-Origin | The origin permitted to read the response: one literal origin, or * for any origin. * is rejected on credentialed requests. |
Access-Control-Allow-Methods | Methods permitted on this resource, returned in the preflight response. |
Access-Control-Allow-Headers | Request headers permitted, returned in the preflight response. Authorization must be listed by name; * doesn’t cover it. |
Access-Control-Allow-Credentials | true lets the browser expose the response to a credentialed request (cookies, HTTP auth). Requires a literal origin, not *. |
Access-Control-Expose-Headers | Response headers, beyond the default safelist, that JavaScript is allowed to read. |
The credentials rule is the one that trips people up. “Credentials” here means cookies and browser-managed HTTP authentication, sent when you call fetch(url, { credentials: 'include' }). On such a request, the server must send Access-Control-Allow-Credentials: true and name the exact origin in Access-Control-Allow-Origin. A credentialed response with Access-Control-Allow-Origin: * is rejected even if every other header is correct.
A bearer token you set yourself in an Authorization header is not a credential in this sense. It’s a custom header: it triggers a preflight and has to appear in Access-Control-Allow-Headers, but it doesn’t need Allow-Credentials.
How to fix CORS errors in Express
The cors package sets these headers for you.
| |
| |
In production you often have more than one legitimate frontend. Pass an array or a function instead of a single string:
| |
Mounted with app.use, cors() answers the OPTIONS preflight for every route. If you mount it only on specific routes that use a non-GET method, add app.options("/api/users", cors()) so the preflight for that path gets a response too.
How to enable CORS in ASP.NET Core
Register a named policy in Program.cs and call UseCors between UseRouting and UseAuthorization. The order matters: a preflight carries no auth, so if authorization runs first it rejects the OPTIONS request before CORS can answer it.
| |
WithOrigins takes literal origins, for the same reason the raw header does. Combine AllowCredentials() with AllowAnyOrigin() and ASP.NET Core throws an InvalidOperationException instead of sending a response the browser would reject anyway. For per-endpoint control, put [EnableCors("_allowFrontend")] on a controller or action and call app.UseCors() with no policy name: the middleware still has to be in the pipeline for the attribute to take effect.
How to avoid CORS in development with a Vite proxy
On your laptop, the least-effort fix is to stop making a cross-origin request at all. Vite, webpack-dev-server, and most other dev servers can proxy a path to your backend, so the browser only ever talks to one origin:
| |
A fetch to /api/users now goes to the Vite dev server on http://localhost:5173, which forwards it to http://localhost:4000 and relays the response. The browser sees one origin, so there’s nothing for CORS to block.
This is a development convenience. The production version of the same idea is a reverse proxy that serves the frontend and the API from one public origin, which removes the need for CORS headers on that API altogether.
CORS workarounds you should not use
Each of these makes the red text go away for you and leaves the real problem, a header your API isn’t sending, in place:
Access-Control-Allow-Origin: *on an endpoint that uses cookies. The browser rejects the credentialed response, so this doesn’t even fix the error. Drop the cookies to make the wildcard work and you’ve got an endpoint that answers every site on the internet with no origin check.- A “CORS unblock” browser extension. It patches responses inside your own browser. The error disappears for you and stays for every real user, including a colleague testing staging from another machine.
- Launching Chrome with
--disable-web-security. This turns off same-origin policy for every tab in that browser instance, not only your app, for as long as it stays open. One forgotten tab on a malicious site defeats the exact protection the policy exists for.
Which CORS fix to use
If the error only appears locally, against a backend on a different port, use the dev server proxy and skip CORS configuration entirely.
If real users will call your API cross-origin from a separate frontend domain or a third-party site, set Access-Control-Allow-Origin to the exact origins you intend to serve. Add Access-Control-Allow-Credentials only if the browser sends cookies. Then open DevTools’ Network tab and check the OPTIONS request before assuming it worked: a 2xx with Allow-Origin, Allow-Methods, and Allow-Headers covering your real request means the request behind it will go through.