CORS Explained for the Dev Who Just Wants It to Stop Blocking Them

Technical PM + Software Engineer
You hit the console and see "Access to fetch at '...' from origin '...' has been blocked by CORS policy." If you're just trying to get your frontend and backend to talk, CORS is understandably infuriating. This article strips CORS down to the essentials you actually need: why it exists, what a preflight request is, the concrete Express setups that work for development and production, common mistakes that keep showing up, and a secure production checklist. No theory-only lecture — practical steps and copy-ready snippets you can use immediately.
1) Why CORS exists (short answer)
CORS (Cross-Origin Resource Sharing) is a browser-enforced security model that stops a web page loaded from origin A from making certain requests to origin B unless B explicitly allows it. The browser enforces CORS to protect users from cross-site request forgery and data leakage when a site makes requests to other origins. Important: CORS is a browser feature. Server-to-server HTTP calls are unaffected.
Think of origins as the triple (scheme, host, port). http://example.com and https://example.com are different origins. By default, scripts running in one origin can't read responses from another origin unless that origin includes the right CORS headers.
- Browser-enforced: servers configure access, browsers enforce it.
- Protects user data and prevents some cross-site attacks.
- Applies to cross-origin read access; simple HTML navigation isn't blocked.
2) When the browser does a preflight and what it looks like
A preflight is an OPTIONS request the browser sends before the actual request when it considers the subsequent request "non-simple." Preflights check whether the server is willing to accept the real request. You will see preflight behavior for requests that:
The response to OPTIONS must include the CORS response headers that permit the actual request; otherwise the browser blocks the real request. The common headers involved are Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and optionally Access-Control-Allow-Credentials and Access-Control-Max-Age.
- A request triggers preflight if it uses methods other than GET, POST, or HEAD (e.g., PUT, DELETE) OR
- It sets custom request headers (anything outside the simple list like Accept, Content-Type with certain values) OR
- Content-Type is not one of: application/x-www-form-urlencoded, multipart/form-data, text/plain
- Preflight is an OPTIONS request with Origin and Access-Control-Request-Method/Headers.
3) The most common developer mistakes
Many developers try quick fixes that either don't work with credentials or open security holes in production. The most frequent issues are: using Access-Control-Allow-Origin: * with credentials, not responding to OPTIONS at all, not matching origin exactly when credentials are used, and forgetting Vary: Origin.
A few specifics to watch for:
- Setting Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true — browsers block this combination. When credentials are allowed, you must echo the specific Origin value.
- Not handling OPTIONS requests server-side — preflights get no CORS headers and fail.
- Allowing arbitrary headers or methods without thinking about impact — expose only what's necessary.
- Caching preflight responses incorrectly — don't assume long Max-Age for security-sensitive endpoints.
- Ignoring Vary: Origin — caches (CDNs) must store per-origin responses otherwise one user's cached response could be served to another origin.
4) The right CORS config in Express (dev and production patterns)
Use the cors package for most cases. Below are two practical patterns: a permissive config for local development, and a secure dynamic-origin config for production. Copy-paste and adapt.
Development (quick, local only): allow everything from localhost origins but keep credentials restricted off unless necessary.
- Dev example (app.js): const express = require('express'); const cors = require('cors'); const app = express(); app.use(cors({ origin: ['http://localhost:3000','http://127.0.0.1:3000'], methods: ['GET','POST','PUT','DELETE','OPTIONS'] })); // your routes here
- Production example: allow only whitelisted origins and support credentials with dynamic origin echo: const whitelist = ['https://app.example.com','https://admin.example.com']; app.use(cors({ origin: function(origin, cb) { if (!origin) return cb(null, true); // allow non-browser requests if (whitelist.indexOf(origin) !== -1) { cb(null, true); } else { cb(new Error('Not allowed by CORS')); } }, credentials: true, methods: ['GET','POST','PUT','DELETE'], allowedHeaders: ['Content-Type','Authorization'], maxAge: 600 }) );
- If you need to support preflight caching, Access-Control-Max-Age is set via the cors options maxAge (seconds).
5) Production-safe setup and security checklist
CORS configuration is about declaring intent. Use the principle of least privilege: permit only the origins, methods, and headers required. Also ensure your cookie and authentication settings line up with your CORS choices.
Checklist items to apply before shipping:
- Whitelist explicit origins — do not use '*' in production, especially with credentials.
- When credentials are needed (cookies, HTTP auth), set credentials: true and echo the exact Origin (no wildcard). Ensure cookies use SameSite=None; Secure for cross-site cookies.
- Set allowedHeaders to the minimal required set (e.g., ['Content-Type','Authorization']).
- Set allowedMethods to only the methods you actually use.
- Handle OPTIONS globally to return appropriate headers (200) and short-circuit middleware that would otherwise attempt to authenticate or modify state.
- Add Vary: Origin header to responses so caches (browser/CDN) handle different origins correctly.
- Limit Access-Control-Max-Age — choose a reasonable window (e.g., 300–600 seconds) to balance performance and flexibility.
- Log and monitor rejected origins to detect misconfigurations or malicious scanning.
6) Debugging tips and quick tests
When something goes wrong, the browser console and network tab are your friends. The key is to observe both the OPTIONS preflight and the follow-up request. If the OPTIONS fails or lacks the right headers, the browser will block the main request even if the main response would have been fine.
Use quick server tests from the terminal to simulate a browser Origin header and inspect responses.
- In Chrome/Firefox devtools Network tab, filter for OPTIONS to see preflight and check response headers.
- Test with curl: curl -i -X OPTIONS 'https://api.example.com/endpoint' -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: Content-Type,Authorization'. Inspect Access-Control-* response headers.
- If credentials are failing, check Set-Cookie is present on the response and that SameSite and Secure attributes match your environment (SameSite=None; Secure for cross-site cookies over HTTPS).
- If you see a cached response without the right headers, ensure Vary: Origin is set and your CDN is configured to forward Origin header to the origin server.
Conclusion
CORS is a browser guardrail, not a mysterious backend quirk. Fix CORS by: understanding when preflight happens, responding correctly to OPTIONS, using dynamic origin checking when credentials are involved, and restricting origins, methods, and headers in production. Use the express cors middleware for most cases and follow the production checklist to avoid accidental open endpoints. Once you control the headers your server emits, the browser will stop blocking you.
Action Checklist
- Add a global OPTIONS handler to your Express app that returns CORS headers and HTTP 204/200 before other middleware.
- Implement the dynamic-origin production config shown above and replace the whitelist entries with your real domains.
- If you use cookies for auth, ensure Set-Cookie uses SameSite=None and Secure, and set credentials: true on your frontend fetch/XHR requests.
- Monitor logs for CORS rejections and set up a dashboard to track suspicious origins or misconfigurations.
- Practice reproducing CORS issues locally with curl and browser devtools so you can diagnose future problems faster.