What CORS actually is
Browsers enforce the same-origin policy. JavaScript on https://app.example.com cannot read a response from https://api.example.com unless that server permits the origin. An origin consists of the scheme, host and port. That makes http://localhost:3000 different from http://localhost:5173, and http different from https on the same host.
Cross-Origin Resource Sharing is the response-header protocol a server uses to grant that access. Two details explain many otherwise confusing reports:
- The server may have completed the request. The browser can receive a response and then withhold it from JavaScript because the required permission header is missing. That is why the request can appear in server logs while
fetchstill rejects. - The same-origin policy is a browser constraint.
curl, Postman, native mobile clients and server-side code do not apply it. A request working in Postman confirms reachability, not a valid CORS configuration.
Read the error message first
Chrome and Firefox usually name the failed check. Match the relevant phrase to the response before choosing a fix.
| Error text contains | What it means | Fix |
|---|---|---|
No "Access-Control-Allow-Origin" header | The server sent no CORS headers at all | Enable CORS on the server |
does not match the supplied origin / The "Access-Control-Allow-Origin" header has a value ... that is not equal | Headers exist but name a different origin | Add the exact origin to the allowed list; check for a trailing slash |
Response to preflight request doesn't pass | The OPTIONS request failed or returned an error status | Handle OPTIONS before auth middleware, return 204 |
Request header field ... is not allowed | Your custom header is not in Access-Control-Allow-Headers | Add the header name to that list |
Method ... is not allowed | Access-Control-Allow-Methods omits your verb | Add PUT, PATCH or DELETE as needed |
credentials mode is "include" with a wildcard | You cannot combine * with cookies | Echo the specific origin instead of * |
Cause 1: no CORS headers at all
Most servers do not send CORS headers until you configure them. At minimum, the response needs an Access-Control-Allow-Origin value that permits the requesting origin.
// Express middleware manages the response headers
import cors from 'cors';
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
# FastAPI middleware configuration
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Return OPTIONS before proxying the request
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age 86400;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
proxy_pass http://backend;
}
Cause 2: the origin does not match exactly
Origin matching is exact. These values differ in ways that matter to CORS:
https://app.example.com ← the real origin
https://app.example.com/ ← trailing slash: invalid in this header
http://app.example.com ← different scheme
https://www.app.example.com ← different host
https://app.example.com:8443 ← different port
A trailing slash in an allow-list is easy to overlook. The browser’s Origin header has no path or trailing slash, so https://app.example.com/ does not match https://app.example.com.
Cause 3: the preflight is failing
Before a request that does not qualify as "simple," the browser sends an OPTIONS request for permission. A simple request uses GET, HEAD or POST, has no custom request headers, and limits Content-Type to text/plain, multipart/form-data or application/x-www-form-urlencoded.
A JSON request triggers a preflight because application/json is not a simple content type. An Authorization header does as well, so preflights are routine for authenticated JSON APIs.
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
A cross-origin preflight carries no credentials such as cookies or an Authorization header. Authentication middleware placed before CORS handling may reject OPTIONS with 401, leaving the browser to report a CORS failure. Register the CORS handler before authentication and inspect the preflight response directly.
Cause 4: wildcard plus credentials
If a request sends cookies or uses credentials: "include", the server cannot respond with a wildcard origin. The specification requires a specific allowed origin so an arbitrary site cannot make credentialed requests on the user’s behalf.
// Browsers reject this combination
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// Return the validated origin and vary the cached response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Include Vary: Origin when the response origin changes by request. Otherwise a CDN or proxy can reuse a response cached for one origin when serving another, creating failures that depend on cache order.
Cause 5: you cannot read the response header you need
Cross-origin JavaScript can read only the CORS-safelisted response headers by default. Expose custom values such as pagination counts, rate-limit state or request IDs explicitly:
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id, X-RateLimit-Remaining
A missing expose header does not reject the request. The fetch succeeds, but JavaScript receives null when it tries to read that response header.
Cause 6: it is a redirect, or the server is down
Before changing headers, rule out two failures that browsers may surface alongside CORS messages:
- A redirect on a preflight. Preflight requests may not follow redirects. Calling
http://api.example.comwhen the server redirects tohttps://can therefore appear as a CORS failure. Point the front end at the final URL. - The server is unreachable. When nothing is listening, there is no response carrying CORS headers. Check the network panel for a
(failed)status before treating the issue as header configuration.
The local development fix: use a proxy
When you cannot change an API during local development, route the request through the development server. The browser calls same-origin /api, and the server-side proxy forwards it to the remote backend.
// Vite proxy configuration
export default {
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
};
// Create React App proxy configuration
{
"proxy": "http://localhost:8080"
}
The same-origin pattern also works well in production. Serving the front end and an /api route behind one CDN or load balancer removes the cross-origin boundary, along with its preflights and third-party-cookie constraints.
What not to do
- Do not rely on a "CORS unblock" browser extension. It changes one browser and can hide the problem until deployment. Keep it to short diagnostic use.
- Do not set
mode: "no-cors"on fetch. It does not disable CORS. It gives JavaScript an opaque response with status0and an unreadable body. - Do not route production traffic through a public CORS proxy. That gives a third party access to each request and response, including tokens.
- Do not treat
Access-Control-Allow-Origin: *as a general fix for an authenticated API. It does not work with credentials, and reflecting arbitrary origins would expose credentialed actions.
A five-minute debugging checklist
- Open the network panel and select the failing request, not only the JavaScript error. A status of
(failed)or0points to reachability rather than a CORS response. - Look for a preceding
OPTIONSrequest. If it returned 401, 403, 404 or 500, that is your problem: CORS handling is not running before auth or routing. - Compare the
Originrequest header with theAccess-Control-Allow-Originresponse header, character by character. Watch for the trailing slash. - If cookies are involved, confirm the response is not using
*and thatAccess-Control-Allow-Credentials: trueis present. - Reproduce with curl to confirm the server side is fine:
curl -H "Origin: https://app.example.com" -I https://api.example.com/endpoint.
Frequently asked questions
Why does it work in Postman but not in the browser?
Postman does not enforce the browser’s same-origin policy and does not use CORS response headers to decide whether your code may read a response. A successful Postman request confirms that the endpoint is reachable, but it does not validate the browser-facing CORS configuration.
Can I fix CORS from my front-end code?
The permission must come from the server that owns the resource; a client cannot grant itself cross-origin access. You can instead avoid the browser’s cross-origin request by sending it through a same-origin server or proxy you control.
Why do I get a CORS error only on POST and not on GET?
A POST with a JSON body triggers a preflight OPTIONS request, while a simple GET does not. Inspect that preflight. Authentication middleware often rejects it before the CORS handler runs because the preflight does not carry credentials.
Is Access-Control-Allow-Origin: * dangerous?
A wildcard can be appropriate for a public, unauthenticated API. It cannot be used with cookies or other credentialed requests. Do not work around that restriction by reflecting arbitrary origins, since doing so would let any site act with a logged-in user’s credentials.
Why does my API work locally but fail in production?
Compare the deployed Origin value with the server’s allow-list; production domains are often missing even when localhost is present. Also check the scheme and confirm that a CDN is not reusing origin-specific responses without Vary: Origin.
What is a "simple request"?
It is a GET, HEAD or POST with no custom request headers and a Content-Type limited to text/plain, multipart/form-data or application/x-www-form-urlencoded. Simple requests skip preflight. JSON and Authorization headers do not meet those conditions.