What the browser actually blocked
Last quarter I burned an afternoon on a dashboard that worked in Postman and failed in Chrome with a blocked CORS policy on https://api.example.com/me from origin http://localhost:5173. Curl returned 200. The API was fine. The browser was enforcing the same-origin policy on a cross-origin fetch.
CORS is not authentication. It is a browser gate: the server must opt in to being readable by a specific origin. If you only test with curl, Insomnia, or server-to-server calls, you will never see the failure. I now reproduce every failing request in DevTools Network with Preserve log on before I touch middleware.
One more habit that saved me: click the failing request and read the Console message carefully. Chrome often says the response to the preflight request does not pass access control, which means you are debugging OPTIONS, not GET. I paste the exact Origin header into a note so I do not fix the wrong host after a port change from 5173 to 5174.
Simple requests vs preflight
A GET or POST with Content-Type application/x-www-form-urlencoded, multipart/form-data, or text/plain, and no custom headers, can be a simple request. Everything else triggers a preflight: an OPTIONS request before the real method.
In my apps, JSON APIs almost always preflight because we send Content-Type application/json and often Authorization. If OPTIONS is not handled, Chrome shows a CORS error even though the real route would have returned 200. I have fixed this three times by adding an OPTIONS handler or enabling CORS middleware that answers preflight.
Watch for frameworks that only register GET/POST and return 404 for OPTIONS. NestJS, Express routers with strict method lists, and API gateways that block unknown methods all produce the same symptom. In one staging env, our ALB returned 405 for OPTIONS while Node never saw the request — we enabled OPTIONS at the load balancer, not only in Express.
# Express: answer preflight explicitly if you are not using cors()
app.options('/api/*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5173');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
res.sendStatus(204);
});
The credentials trap
When the frontend needs cookies with credentials include in fetch, or withCredentials true in Axios, the server cannot respond with Access-Control-Allow-Origin as a wildcard. It must echo a specific origin and set Access-Control-Allow-Credentials true.
I once shipped origin true in the cors package and forgot credentials true on the client. Cookies never arrived. Another time I set both sides correctly but left SameSite=Strict on a cross-site cookie — CORS looked fixed, session still empty. Check Set-Cookie attributes in the response headers, not only ACAO.
Numbers from a real incident: about 70 percent of login-then-logout reports on a SPA were cookie attribute mismatches, not bad JWT secrets. DevTools showed a SameSite warning. We switched to SameSite=None Secure for the cross-site API host and those tickets dropped overnight.
// Client
fetch('https://api.example.com/me', {
method: 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
// Server (cors package)
import cors from 'cors';
app.use(cors({
origin: ['http://localhost:5173', 'https://app.example.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Local fix: Vite proxy instead of fighting CORS
For local development I prefer same-origin requests through a Vite or Next proxy. The browser talks to http://localhost:5173/api/... and Vite forwards to the real API. No CORS headers required locally because the page origin matches the request origin.
I use this pattern on every greenfield SPA now. Production still needs real CORS or a gateway on the same domain. The proxy is a developer ergonomics tool, not a production security model.
One footgun: if your API already prefixes routes with /api and Vite also mounts at /api, you may double-prefix after a rewrite. I log the proxied URL once at startup. Also set changeOrigin true when the upstream checks Host headers; without it some frameworks reject the request before CORS middleware runs.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
});
Allowlists that do not bite you in staging
Hardcoding http://localhost:5173 works until someone uses 5174, a preview deploy URL, or 127.0.0.1 instead of localhost. Browsers treat those as different origins. I keep an env list and reject missing Origin rather than reflecting whatever the client sends.
Reflecting arbitrary Origin values with credentials enabled lets any website read authenticated responses. Our allowlist lives in CORS_ORIGINS and we fail closed in production. In development we allow a shortlist of localhost ports so preview branches still work.
function corsOrigin(req, callback) {
const allowed = (process.env.CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const origin = req.header('Origin');
if (!origin) return callback(null, false);
if (allowed.includes(origin)) return callback(null, true);
return callback(new Error(`Origin ${origin} not allowed`), false);
}
app.use(cors({ origin: corsOrigin, credentials: true }));
My 10-minute diagnosis checklist
Open the failed request in Network. If you see a red OPTIONS with 404 or 405, fix preflight first. If OPTIONS is 204 but the real request fails CORS, compare Origin with Access-Control-Allow-Origin character-for-character — trailing slashes and http versus https matter.
Confirm you did not set a wildcard with credentials. Confirm custom headers are listed in Access-Control-Allow-Headers. If you use a CDN or API gateway, check that it forwards OPTIONS and does not strip ACA headers. In one production incident Cloudflare cached a preflight without credentials for 24 hours and every cookie-auth user looked broken until we purged.
- Reproduce in the browser Network tab, not only curl
- Fix OPTIONS before debugging the real method
- Never combine a wildcard ACAO with credentials
- Prefer a local proxy for SPA plus separate API in development
- Verify gateway or CDN does not strip or cache bad CORS responses
- Diff Origin versus ACAO including scheme and port
Production pattern I settle on
Allowlist exact frontend origins from env. Reject unknown origins. Keep credentials only if you truly need cookie sessions; Bearer tokens in Authorization avoid cookie plus CORS complexity for many SPAs.
For mobile WebViews and multiple subdomains, I sometimes put API and app under a shared parent domain and terminate TLS at a reverse proxy so browsers see same-site requests. That removed an entire class of CORS tickets for one client — about 40 percent of their API-is-down Slack threads were actually CORS misconfig.
I also document the matrix in the README: which env, which origin, cookie or bearer, proxy or direct. The next person debugging at 11pm should not rediscover these rules from stack traces alone.
Key takeaways
- CORS failures are browser-only; curl success proves nothing about the frontend.
- JSON plus custom headers means OPTIONS preflight must succeed first.
- Credentials require a concrete ACAO origin, never a wildcard.
- Use a Vite or Next proxy locally; configure real CORS for production.
- Gateways can strip or cache CORS headers — inspect the final response.
- Allowlist origins from env; never reflect arbitrary Origin with credentials.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.