Fixing CORS Errors: How CORS Actually Works β A Practitioner's Guide
Why the browser blocked your request, what preflights actually check, why the fix is never in your frontend code, and the exact debugging recipe for the five CORS errors everyone hits: missing headers, wildcard-with-credentials, blocked preflights, disallowed headers, and the one that only breaks in production.
Fixing CORS Errors: How CORS Actually Works β A Practitioner's Guide
There's a specific moment every web developer knows. The API works in Postman. It works in curl. The backend logs show a clean 200. And the browser console says:
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
The instinct is to treat this as an error to be silenced β find the magic header, sprinkle it somewhere, move on. That instinct is why CORS bugs come back every few months. The mechanism is actually small and coherent; it just protects something different from what most people assume. Fifteen minutes of mental model saves the fifth afternoon of whack-a-mole.
What CORS is actually for
Start from the threat. You're logged into your bank in one tab. In another tab, you open a shady website. That site's JavaScript quietly does:
const res = await fetch('https://yourbank.com/api/accounts');
const yourMoney = await res.json(); // β this is the part that must not work
If the browser attached your bank cookies and let that script read the response, every website you visit could read your data from every other website you're logged into. The same-origin policy β a browser rule dating back to 1995 β forbids it: scripts may only read responses from their own origin.
Two consequences of that framing explain 90% of CORS confusion:
- CORS is enforced by the browser, for the user's benefit. Your server is not being protected. curl, Postman, and other servers ignore CORS entirely β which is why "it works in Postman" is expected, not a clue that something's broken.
- CORS is the opt-out from that restriction, not the restriction itself. Cross-Origin Resource Sharing is the server saying "these other origins may read my responses." No CORS headers means the default applies: nobody may.
So when the console says blocked by CORS policy, translate it as: "the server didn't say your site is allowed to read this." The fix lives on the server. Always.
What counts as a different origin
An origin is the exact triple of scheme + host + port. All of these are different origins from https://app.example.com:
| URL | Why it's a different origin |
|---|---|
http://app.example.com | scheme (http β https) |
https://www.app.example.com | host (subdomains count) |
https://app.example.com:8443 | port |
https://api.example.com | host |
Same-with-different-path, however, is the same origin β paths don't matter. When in doubt, decompose the URL and compare the three parts mechanically. The "works on localhost, breaks in production" classic is usually just this table: http://localhost:3000 was allowlisted, https://www.app.example.com wasn't.
The browser puts that triple on the wire in a header it sets itself and your JavaScript cannot touch:
Origin: https://app.example.com
Note what isn't there: no path, no query string, no trailing slash. That last one is the most common typo in a server allowlist β 'https://app.example.com/' matches nothing, because no browser has ever sent it. Run this in the DevTools console on the failing page and whatever it prints is what the server must allow, byte for byte:
new URL(window.location.href).origin; // "https://app.example.com"
Simple requests vs. preflighted requests
The browser sorts every cross-origin request into one of two flows, and your own code decides which:
// simple β a form could have sent this, so it goes straight out
fetch('https://api.example.com/data');
// preflighted β JSON body, so an OPTIONS request happens first
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ok: true }),
});
Simple requests are those a plain HTML form or <img> tag could have produced anyway: GET, HEAD, or POST with a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain, and no custom headers. These are sent immediately β the server receives and processes them β and the browser only checks the response's Access-Control-Allow-Origin before deciding whether your script may read it. (Yes, that means CORS did not stop the request from executing. Let that sink in before relying on it for anything security-shaped.)
Everything else gets a preflight. Send JSON (Content-Type: application/json), use PUT/DELETE/PATCH, or attach an Authorization header, and the browser first sends its own request you never wrote:
OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization, content-type
That's the browser asking permission. Read it as three questions: I'm this origin, I want to use this method, and I want to send these headers β may I? The server must answer with headers that cover the ask:
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: Authorization, Content-Type
Access-Control-Max-Age: 86400
Vary: Origin
Only then does the real PUT go out, and only then does the handler you actually wrote run. If the preflight fails, your request never happened β which is why staring at server logs for the missing request is so maddening. Look for the OPTIONS line instead.
The whole timeline: fetch is called β the browser sees the request isn't simple β it sends OPTIONS β the server answers β the browser compares that answer to the ask β the real PUT goes out β the browser checks Access-Control-Allow-Origin again on the real response before handing it to your promise. Two checks, two places to get it wrong: a passing preflight says nothing about the real response, which needs its own header.
Access-Control-Max-Age: 86400 tells the browser to cache this permission for a day instead of preflighting every call. It's the cheapest CORS-related performance win there is; browsers cap it (Chrome at 2 hours, Firefox at 24), but even that eliminates most of the OPTIONS chatter.
One carve-out worth memorizing: the wildcard * is legal in Access-Control-Allow-Headers for non-credentialed requests, but by spec it does not cover Authorization, which must always be listed by name. That's why a config that "allows everything" still breaks the day you add JWT-based auth to the frontend.
The credentials rule, or: why * suddenly stopped working
By default, cross-origin requests carry no cookies. The moment you want session-cookie auth across origins, the frontend opts in:
fetch('https://api.example.com/me', { credentials: 'include' });
β¦and the rules tighten. For a credentialed request, the browser rejects the response unless:
Access-Control-Allow-Credentials: trueis present, andAccess-Control-Allow-Originnames the exact origin β the wildcard*is forbidden, and so are wildcards inAllow-HeadersandAllow-Methods.
This is the second-most-common CORS error in the wild, and the error message says it plainly: "The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'."
The correct pattern is an allowlist check that echoes the origin back:
// Express β the manual version, so you can see exactly what's being set
const ALLOWED = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // echo, don't wildcard
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.vary('Origin'); // append, don't clobber
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '7200');
}
if (req.method === 'OPTIONS') return res.sendStatus(204); // before auth middleware!
next();
});
Two details in there earn their keep:
Vary: Origin, set withres.vary()β without the header, a CDN can cache the response with origin A's value baked in and serve it to origin B, producing CORS failures that appear and disappear "randomly." Useres.vary('Origin')rather thanres.setHeader('Vary', 'Origin'):setHeaderreplaces the whole field and silently deletes theVary: Accept-Encodingyour compression middleware set, breaking gzip caching.res.vary()appends.OPTIONShandled before auth β preflights arrive with no cookies and noAuthorizationheader, by spec. If your auth middleware runs first, it 401s the anonymous preflight and the browser reports it as CORS. Middleware order is the fix, not more headers.
Server recipes: the cors package and nginx
Nobody hand-writes that middleware twice. For Express the cors package is the standard answer β and worth seeing fully specified, not as the app.use(cors()) one-liner that ships wildcards to production:
// npm install cors
const cors = require('cors');
const ALLOWED = ['https://app.example.com', 'https://staging.example.com'];
app.use(cors({
origin(origin, callback) {
// `origin` is undefined for same-origin requests and for non-browser
// clients (curl, server-to-server) β those aren't CORS requests at all.
if (!origin || ALLOWED.includes(origin)) return callback(null, true);
callback(null, false); // just omit the header; don't throw
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['X-Request-Id'],
maxAge: 7200,
}));
// everything below this line is now preflight-safe
app.use(requireAuth);
app.use('/api', apiRoutes);
The package handles two things people re-implement badly: it adds Vary: Origin whenever origin is anything other than *, and it answers preflights itself with a 204 rather than passing them down the stack. Two to watch: app.use(cors(...)) must sit above your auth middleware, and calling callback(new Error(...)) for a disallowed origin β which half the tutorials do β sends it to your error handler, which returns a 500 without CORS headers, so the browser reports "no Access-Control-Allow-Origin" instead. Passing false is honest: the header is simply absent.
The nginx flavor of the same idea, for when the API sits behind a reverse proxy:
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 7200 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://backend;
}
The Vary Origin in the OPTIONS branch is not decoration: preflight responses are cacheable too β that's what Access-Control-Max-Age is for β and without it a shared cache will reuse origin A's preflight answer for origin B.
Three nginx-specific traps come with this. The $http_origin echo allows every origin with credentials: fine behind a VPN, reckless in public, so pair it with a map block resolving an allowlist to either the origin or an empty string. The always parameter matters more than it looks β without it nginx skips add_header on error responses, so 4xx and 5xx replies arrive with no CORS headers and the frontend sees a CORS error stacked on top of the real one. And add_header doesn't merge across levels: adding one inside a location drops every header inherited from the enclosing server block, which is how a CORS fix silently disables your HSTS and CSP. The Apache and nginx hardening guide covers what you'll need to repeat there.
Headers your script can't read: Access-Control-Expose-Headers
Here's a failure that doesn't look like CORS at all, because there's no console error. Your API returns a request ID for support tickets, DevTools shows it plainly in the Response Headers, and this returns null:
const res = await fetch('https://api.example.com/data');
res.headers.get('content-type'); // "application/json" β
safelisted
res.headers.get('x-request-id'); // null β not exposed
Reading response headers cross-origin has its own gate. By default a script can only see the CORS-safelisted response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Everything else β your rate-limit counters, pagination totals, request IDs, Location on a 201 β is invisible to JavaScript until the server names it:
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining, X-Total-Count
In the cors package that's the exposedHeaders array from the recipe above; in raw Express it's one more res.setHeader. The wildcard Access-Control-Expose-Headers: * exists and exposes everything, but β same rule as always β it's ignored for credentialed requests, where you must list each header by name. Worth deciding when you design the API rather than after: if pagination metadata lives in headers and the browser is a first-class client, exposing them is part of the contract.
The five errors, decoded
Nearly every CORS failure is one of these. The console message tells you which β read it before changing anything.
1. "No 'Access-Control-Allow-Origin' header is present." The server sent nothing: either CORS isn't configured, or something between browser and app (CDN, proxy, error page) stripped it. Check the actual response headers, not your config β our CORS header checker shows what an external client really receives, preflight included.
2. "β¦must not be the wildcard '*' when the request's credentials mode is 'include'." The wildcard/credentials clash from the previous section. Echo the exact origin.
3. "Response to preflight request doesn't pass access control check." The OPTIONS request failed β 401 from auth middleware, 404 because no OPTIONS route exists, or a redirect (a httpβhttps or trailing-slash one is enough; preflights don't follow them). Chrome usually appends the reason, and "It does not have HTTP ok status" means the OPTIONS response wasn't a 2xx.
4. "Request header field x-custom-header is not allowed by Access-Control-Allow-Headers." Your Allow-Headers list doesn't cover a header the app sends. Authorization is the perennial omission β teams add JWT auth, forget the preflight contract, and the * wildcard doesn't cover it. Its silent mirror image is the previous section: a header you read needs Access-Control-Expose-Headers, or headers.get() hands you null while DevTools shows it sitting right there.
5. It works everywhere except production, intermittently. Cache poisoning from a missing Vary: Origin, or a CDN/gateway with its own CORS opinions overriding yours β the tell for that one is Chrome's "contains multiple values β¦ but only one is allowed", two layers both adding the header, refused even when the values agree. Diagnose from outside: compare responses via the header checker, or rebuild the exact preflight with our curl command generator so you're testing what the browser tested.
Debugging a CORS error: the method
The mistake is to start editing server config straight from the error message. Ten minutes of looking β in this order β tells you which of the five it is, and whether the problem is even in your application at all.
Step 1 β Read the whole console message
Open DevTools with F12 (Cmd+Option+J on a Mac) and go to the Console tab. Read past the words "blocked by CORS policy" β everything useful is after the colon:
Access to fetch at 'https://api.example.com/data' from origin 'https://app.example.com'
has been blocked by CORS policy: Response to preflight request doesn't pass access
control check: It does not have HTTP ok status.
That's error 3, and it already tells you the OPTIONS response wasn't a 2xx β look at the preflight, not at the PUT. If the message is truncated, Chrome files the same failure in the Issues tab (the β icon beside Console) with a plain-English reason.
Step 2 β Find the OPTIONS request in the Network tab
Switch to the Network tab, click the All filter, and reload. The filter matters: Chrome types preflights as preflight, not xhr, so the popular Fetch/XHR filter hides the exact row you need. Then look for:
- Two rows for the same URL, the first with method
OPTIONSβ that confirms the request was preflighted. - The Status column. A red
CORS erroron the OPTIONS row means the browser stopped there and your real request was never sent; a401,301, or404names the culprit outright. - Click the failing row β Headers pane β Response Headers. That's ground truth: what the server actually sent after every proxy in the path had a turn. Your config is a hypothesis; this is evidence.
No OPTIONS row at all means the request was simple (GET/HEAD/POST with a safelisted Content-Type), the server really did run it, and the missing Access-Control-Allow-Origin is on the real response.
Step 3 β Reproduce the preflight with curl
Now leave the browser. This command sends exactly what the browser sent, with nothing else in the way:
curl -i -X OPTIONS 'https://api.example.com/data' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization, content-type'
What the flags do: -i prints the response headers along with the body (without it you'd see nothing at all, because a preflight has no body), -X OPTIONS sets the method, and each -H adds one request header. The three headers aren't decoration β Origin is what makes this a CORS request at all, and a server that keys off Access-Control-Request-Method answers differently without it.
A healthy server answers like this:
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: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Four things to check there, in order: the status is 2xx (204 or 200); Access-Control-Allow-Origin is your origin exactly (or * if you're not using credentials); Access-Control-Allow-Methods contains the method you asked for; and Access-Control-Allow-Headers contains every header you asked for. Miss one and the browser blocks β silently, as far as your server logs are concerned. Anything else maps to a cause:
| What curl shows | What it means |
|---|---|
204 with all the Access-Control-* headers | Preflight is fine β the bug is on the real response |
200/204 with no Access-Control-* headers at all | No CORS config on this host or route |
401 or 403 | Auth middleware runs before CORS middleware |
301/302 plus a Location: header | A redirect on a preflight; browsers don't follow those |
404 or 405 | No OPTIONS route (common with hand-rolled routers) |
Access-Control-Allow-Origin: * while you send cookies | The wildcard/credentials clash |
Two Access-Control-Allow-Origin lines | Two layers both adding it β app and proxy |
A passing preflight only unlocks the real request, so test that too β -d sends a body, and the grep trims the output to just the CORS headers:
curl -i -X PUT 'https://api.example.com/data' \
-H 'Origin: https://app.example.com' \
-H 'Content-Type: application/json' \
-d '{"name":"test"}' | grep -i '^access-control'
If that prints nothing while the OPTIONS check printed a full set, you've found the classic split-brain config: preflights answered by middleware that returns early, real responses served by a route that never got the headers.
Step 4 β Compare the origins, character by character
If the headers are present but the browser still complains, the strings don't match. Print the failing page's origin with the new URL(window.location.href).origin snippet from earlier, then compare it to your allowlist entry one field at a time: scheme, host, port. The four that catch people, roughly by frequency: a trailing slash in the config, www versus the apex domain, http versus https (a staging box on plain HTTP does this), and a port that changed when you switched dev servers β Vite's 5173 versus CRA's 3000. For a long URL, the URL parser splits it into fields so you're diffing parts rather than squinting at a string.
While you're here, re-run the Step 3 command with -H 'Origin: https://evil.example'. If the server echoes that back, your allowlist isn't checking anything β and with Allow-Credentials: true, any site on the internet can read authenticated responses through your users' browsers.
Step 5 β Verify from outside your network
This is the step people skip, and it's the one that catches production-only failures. Run the same curl against the deployed URL, not localhost, ideally from a machine that isn't yours β a VPS, a CI job, or an external checker. Our CORS header checker issues the preflight from outside your network and shows what a real browser receives, CDN edits included. If the external and local results disagree, something in the middle is rewriting; bypass the CDN and ask the origin server directly:
curl -i -X OPTIONS 'https://api.example.com/data' \
--resolve api.example.com:443:203.0.113.10 \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT'
--resolve host:port:ip forces curl to connect to that IP while still sending the real hostname and doing normal TLS, so you're testing the origin server with the CDN out of the path. Headers that show up here but not through the public URL mean the CDN is stripping them. Headers that are correct in both mean you're done.
When you don't own the server: dev proxies and backend proxies
Everything so far assumes you can change the server. Sometimes you can't β it's Stripe, it's a partner's API, or it's your own backend but you're three sprints from being allowed near it. Two honest answers exist, and both work by making the request stop being cross-origin.
In development, proxy through your dev server. Every modern dev server can forward a path prefix to another host, so the browser only ever talks to localhost and CORS never enters the picture. Vite:
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true, // rewrite the Host header to the target
rewrite: (path) => path.replace(/^\/api/, ''), // /api/data β /data
},
},
},
});
Then call fetch('/api/data') instead of the absolute URL. The browser sees a same-origin request to http://localhost:5173/api/data; Vite's Node process β not a browser, so not bound by CORS β fetches the real URL and pipes the response back. Create React App does the same with one line in package.json:
{
"proxy": "https://api.example.com"
}
CRA only proxies requests the dev server can't serve itself, and only in development β which is the catch for all of these. Production builds have no dev server, so a dev proxy is a local convenience, never a deployment strategy. Next.js rewrites() carries the same caveat plus one more: it doesn't exist under output: 'export', because a static export has no server to do the rewriting.
One related gotcha while you're pointed at localhost: Chromium has been phasing in Private Network Access checks, where a page on a public origin calling localhost or a private IP gets an extra preflight carrying Access-Control-Request-Private-Network: true, which the local server must answer with Access-Control-Allow-Private-Network: true. If a deployed staging site suddenly can't reach a developer's machine, that's the mechanism.
In production, proxy through your own backend. Same trick, permanent version: the browser calls your origin, and your server makes the third-party call, where CORS simply doesn't apply because there's no browser involved.
// Express β a minimal backend-for-frontend endpoint
app.get('/api/weather', async (req, res) => {
const upstream = await fetch(
`https://api.thirdparty.com/v1/weather?city=${encodeURIComponent(req.query.city)}`,
{ headers: { Authorization: `Bearer ${process.env.THIRDPARTY_KEY}` } },
);
res.status(upstream.status).json(await upstream.json());
});
The frontend calls /api/weather?city=Vienna β same origin, no preflight, no CORS headers needed anywhere. Note what else that bought you: the API key lives in process.env instead of in a bundle any visitor can read, and you can cache and rate-limit the upstream call. That's the backend-for-frontend pattern, usually the right architecture rather than a workaround; the API architecture guide covers where to draw that line. Honest cost: one more hop of latency and a service you now have to keep running.
What CORS is not
Worth stating plainly, because both misconceptions cause real incidents:
CORS does not protect your API. Anyone can call your endpoints with curl or a script β no browser, no CORS. Locking Access-Control-Allow-Origin to your domain does not make endpoints private; authentication and rate limiting do. We've seen "internal" APIs left unauthenticated because "CORS blocks other sites" β it blocks other sites' browser JavaScript from reading responses, nothing more. Remember also that simple requests execute server-side before the browser blocks anything: a cross-origin POST that transfers money will transfer the money and then hide the response.
CORS is not your CSRF defense. CSRF is an attacker triggering a state-changing request with the victim's cookies β and simple requests (form posts) sail through without any preflight. SameSite cookies and CSRF tokens address that; CORS configuration doesn't. If you're testing how your endpoints behave when browsers hit them cross-origin, the API tester and webhook tester make quick work of it.
And the fix is never "disable CORS." Browser flags and unblock-CORS extensions turn the protection off for every site in that browser β fine for five minutes of local debugging, dangerous as a habit, and useless for your users, who won't have the extension. Public "CORS proxy" services are worse: every request through them, tokens included, is readable by whoever runs the proxy. Use one of the two proxies from the previous section instead.
The CORS gotcha checklist
The failures in this article, compressed for future greps:
- Trailing slash in the allowlist entry β
https://app.example.com/never matches; theOriginheader has no path. - Wildcard with credentials β
*is rejected outright for credentialed requests; echo the exact origin. - Auth middleware before CORS middleware β the anonymous preflight gets a 401, reported as a CORS error.
- A redirect on the preflight β
httpβhttpsor trailing-slash; preflights don't follow redirects. - Missing
Vary: Originβ a shared cache serves origin A's header to origin B. Production-only, intermittent. - CORS headers on 2xx but not on errors β nginx
add_headerwithoutalways, or an error handler above the CORS middleware; the real 500 goes invisible. Authorizationmissing fromAllow-Headersβ the*wildcard does not cover it, by spec.- Header visible in DevTools but
nullin JavaScript β it needsAccess-Control-Expose-Headers. - Two
Access-Control-Allow-Originheaders β app and proxy both adding it; browsers reject duplicates even when the values match. add_headerinside an nginxlocationβ drops every header inherited from theserverblock, HSTS and CSP included.- Reflecting
Originunconditionally with credentials on β that's not an allowlist, that's an open door. - Treating CORS as a security control β it protects users' browser sessions, not your API, and it is not CSRF defense.
The short version
The browser forbids your script from reading another origin's responses unless that origin's server explicitly opts in, so every CORS error is one sentence: the server didn't say your site is allowed to read this. Preflights are the browser asking first when the request is unusual β anything beyond a plain form post β and they're a separate response that needs its own headers. Credentials tighten every rule: exact origin, no wildcards, Vary: Origin, and CORS middleware mounted above your auth. When it breaks, don't reason from your config; read the OPTIONS row in the Network tab, then reproduce it with curl -i -X OPTIONS and see what the wire actually carries. Then confirm the same thing from outside your network with the CORS header checker, because production proxies have opinions your framework knows nothing about β and if you'd rather not hand-assemble the preflight, the curl command generator builds it for you.
Frequently Asked Questions
Why does my request work in Postman and curl but fail in the browser with a CORS error?
Because CORS is enforced by browsers, and only by browsers. Postman, curl, and your backend code don't implement the same-origin policy β they send the request and show you the response, no questions asked. The browser sends the request too (in most cases), but it refuses to let your JavaScript read the response unless the server opts in via Access-Control-Allow-Origin. This is also why 'the API works fine' and 'the browser blocks it' are both true at once: the server genuinely responded; your script just isn't allowed to see it. If curl works and the browser doesn't, the fix is always on the server: it needs to send the right CORS headers.
Is Access-Control-Allow-Origin: * safe to use?
For a public, read-only API that serves the same data to everyone and uses no cookies or authentication β yes, and it's the simplest correct choice. The wildcard becomes a problem in two situations. First, it's flatly incompatible with credentials: browsers reject any credentialed response carrying ACAO: *, so cookie-based auth can never use it. Second, if the API returns user-specific or otherwise private data based on something other than standard credentials (an IP allowlist, a VPN, network position), the wildcard lets any website on the internet read that data through a visitor's browser. Rule of thumb: * for genuinely public data, an explicit echoed origin plus Vary: Origin for everything else.
Can I fix a CORS error without access to the server?
Not directly β the entire mechanism is the server declaring who may read its responses, so a fix that doesn't involve the server isn't a fix, it's a bypass. The legitimate version of that bypass is a proxy you control: your frontend calls your own backend (same origin, no CORS involved), and your backend calls the third-party API server-to-server, where CORS doesn't apply. That's ten lines in most frameworks and also a better architecture, since API keys move out of browser-visible code. What you should not do is ship browser extensions that disable CORS to your users or route production traffic through public 'CORS proxy' services you don't control β every request through them is readable by whoever runs the proxy.
What is a CORS preflight and when does the browser send one?
A preflight is an OPTIONS request the browser sends before your actual request, asking the server for permission. It happens whenever the request could not have been produced by a plain HTML form or resource tag: any method beyond GET, HEAD, or POST; any custom header (Authorization and X-Requested-With are the classic triggers); or a Content-Type other than form-urlencoded, multipart/form-data, or text/plain β which is why sending application/json triggers one. The server must answer with Access-Control-Allow-Methods and Access-Control-Allow-Headers that cover what you asked for, or the real request is never sent at all. You can cut repeat preflights with Access-Control-Max-Age, which lets the browser cache the permission.
Does CORS protect my API from attackers?
No, and this misconception causes real vulnerabilities. CORS is a browser mechanism that protects users β it stops a malicious website from reading responses from another site through a visitor's browser session. It does nothing to your API's actual exposure: anyone can call your endpoints directly with curl, a script, or their own server, and no CORS header will stop them. Authentication, authorization, and rate limiting protect APIs. The related but distinct browser-side threat is CSRF β an attacker triggering state-changing requests with the victim's cookies β which is mitigated by SameSite cookies and CSRF tokens, not by CORS configuration.
Why does my app work on localhost but throw CORS errors in production?
Almost always because the origin changed and the server's allowlist didn't. An origin is the exact scheme + host + port triple, so http://localhost:3000, https://app.example.com, and https://www.example.com are three different origins β an allowlist containing the first two still rejects the third, which bites teams the day the www variant goes live. The second common cause is infrastructure between browser and app: a production CDN, load balancer, or nginx layer that strips or overwrites CORS headers your framework sets, or serves error pages (which have no CORS headers) for failed preflights. Check the actual response headers in production with a header checker rather than trusting your app config.
Why is the OPTIONS preflight failing with a 401 or a redirect?
Because something in front of your handler is treating the preflight like a normal request. Preflights are sent without credentials β no cookies, no Authorization header β by design. If your auth middleware runs before your CORS middleware, it sees an anonymous OPTIONS request and returns 401, and the browser reports it as a CORS failure. The fix is ordering: handle OPTIONS/CORS before authentication. Redirects are the same family of bug β a preflight that hits a http-to-https or trailing-slash redirect fails, because redirects on preflights are not followed. Make the preflighted URL respond 200/204 directly, with the CORS headers on that exact response.
What does Access-Control-Allow-Credentials actually do?
It's the server's opt-in for credentialed cross-origin requests. By default, cross-origin requests carry no cookies. If the frontend sets credentials: 'include' (fetch) or withCredentials = true (XHR), the browser will attach cookies β but it will only expose the response if the server replies with Access-Control-Allow-Credentials: true and an Access-Control-Allow-Origin that names the exact origin. The wildcard is forbidden in this mode, and so are wildcards in Allow-Headers and Allow-Methods. In practice that means echoing the request's Origin header back (after checking it against your allowlist) and adding Vary: Origin so caches don't serve one origin's response to another.