What is CORS and how to configure it in Express.js?
CORS (Cross-Origin Resource Sharing) is a browser security policy that blocks JavaScript from making HTTP requests to a different origin unless the server explicitly permits it via response headers.
Theory
TL;DR
- CORS is enforced by the browser, not the server. Postman and curl ignore it entirely.
- A different origin means any mismatch in protocol, domain, or port:
httpvshttps,myapp.comvsapi.myapp.com,:3000vs:4000. - Before complex requests, the browser sends an automatic OPTIONS preflight to check what the server allows.
origin: '*'withcredentials: trueis rejected by browsers. Use one exact origin when cookies are involved.- In production: set
originto your frontend URL,credentials: truefor cookies, and listAuthorizationinallowedHeaders.
Quick example
const express = require('express');
const cors = require('cors');
const app = express();
// Dev: allow all origins
app.use(cors());
// Production: restrict to your frontend
app.use(cors({
origin: 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // required for cookies and auth headers
maxAge: 86400 // cache preflight response for 24h
}));
app.get('/api/data', (req, res) => res.json({ ok: true }));
app.listen(3000);cors() with no options sets Access-Control-Allow-Origin: * and handles OPTIONS preflights automatically. With options, only the listed origin gets the header back.
What counts as a different origin
Origin is protocol + domain + port. All three must match. https://myapp.com and http://myapp.com are different origins. So are myapp.com:3000 and myapp.com:4000. Even localhost:3000 and 127.0.0.1:3000 are separate origins in the browser's view, which catches a lot of developers off guard during local development.
How preflight works
When JavaScript sends a non-simple request (anything with an Authorization header, or methods like PUT and DELETE), the browser does not fire the actual request first. It sends a preflight: an automatic OPTIONS request listing the intended method and headers.
Browser → OPTIONS /api/login
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization
Server → 200 OK
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: AuthorizationIf those response headers are missing or wrong, the real request never fires. The cors package handles preflights automatically. Writing manual middleware means you need an explicit req.method === 'OPTIONS' check.
When to use what
- Local dev, quick iteration:
app.use(cors())with no options. - Single frontend:
origin: 'https://yourapp.com'. - Multiple frontends: pass an array or a validation callback.
- Cookies or auth tokens:
credentials: truewith an exact origin, not'*'. - Public API with no auth:
origin: true(reflects each request origin back), skip credentials.
Common mistakes
origin: '*' combined with credentials: true
Browsers block this combination. The spec forbids wildcard origins when credentials are involved.
// Wrong - browser throws a CORS error at runtime
cors({ origin: '*', credentials: true });
// Correct
cors({ origin: 'https://myapp.com', credentials: true });app.use(cors()) placed after routes
Express runs middleware in order. Routes registered before cors() respond without the CORS headers set.
// Wrong
app.get('/api/data', handler);
app.use(cors());
// Correct
app.use(cors());
app.get('/api/data', handler);Missing allowedHeaders when sending Authorization
The Authorization header is not simple. It triggers a preflight, and the server must list it in Access-Control-Allow-Headers. Without it, the preflight fails and the real request never goes through. This is the most searched CORS issue on Stack Overflow.
// Preflight fails - Authorization not declared
cors({ origin: 'https://myapp.com' });
// Correct
cors({
origin: 'https://myapp.com',
allowedHeaders: ['Content-Type', 'Authorization']
});localhost vs 127.0.0.1 in development
They resolve to the same machine but the browser treats them as separate origins. Allow both explicitly.
origin: ['http://localhost:3000', 'http://127.0.0.1:3000']Real-world usage
- Create React App:
proxyinpackage.jsonhandles CORS in dev; the Express config covers production. - Next.js:
res.setHeader('Access-Control-Allow-Origin', ...)in API routes, or a sharedcorsOptionsobject across handlers. - NestJS:
app.enableCors({ origin: process.env.ALLOWED_ORIGINS })inmain.ts. - Strapi:
config/middlewares.jswith the cors plugin config.
Follow-up questions
Q: What is a preflight request?
A: An automatic OPTIONS request the browser sends before non-simple requests. It checks whether the server allows the method and headers before the actual request fires.
Q: Why does Postman work but the browser fails?
A: Postman does not enforce the Same-Origin Policy. It sends requests directly, without preflight checks. CORS is a browser concept only.
Q: How do you debug CORS in Chrome?
A: Open DevTools, go to Network, look for a failed OPTIONS request (status 0 or 403). The Console will show "No 'Access-Control-Allow-Origin' header". Clear cache while testing to avoid stale preflight responses.
Q: Can credentials: true work with multiple origins?
A: Not with a static string. Browsers require one exact origin in the response header when credentials are sent. Use a callback that validates the incoming origin and returns it: origin: (o, cb) => cb(null, allowed.includes(o) ? o : false).
Q: What is an opaque response and when does it appear?
A: When you fetch with mode: 'no-cors', the browser returns an opaque response: no visible body, no status, no headers. Works for loading images or scripts from external CDNs, but useless for JSON APIs. Service Workers can cache opaque responses with some size calculation restrictions.
Examples
Basic dev setup
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors()); // sets Access-Control-Allow-Origin: * on all routes
app.get('/api/status', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000);cors() with no arguments is fine for a local API you are testing. Replace it with explicit options before deploying anywhere.
Production config with cookie-based auth
const express = require('express');
const cors = require('cors');
const app = express();
const corsOptions = {
origin: process.env.FRONTEND_URL || 'https://myapp.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // lets browser send cookies and auth headers
maxAge: 86400 // browser caches the preflight for 24h, fewer OPTIONS roundtrips
};
app.use(cors(corsOptions));
app.use(express.json());
app.post('/api/login', (req, res) => {
res.cookie('token', 'jwt-here', { httpOnly: true, secure: true });
res.json({ user: 'logged-in' });
});
app.listen(3000);On the React side: fetch('/api/login', { method: 'POST', credentials: 'include' }). Both sides need to opt in: credentials: true on the server and credentials: 'include' on the client. Miss one, cookies do not travel.
Dynamic multi-origin validation
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
'http://localhost:3000',
'http://127.0.0.1:3000', // separate origin in the browser's view
];
app.use(cors({
origin: (origin, callback) => {
// allow server-to-server requests and Postman (no Origin header)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed`));
}
},
credentials: true,
}));The callback returns one exact origin per request, which is what browsers require when credentials: true. I have seen projects skip the !origin guard and then wonder why their CI health checks break. Those requests arrive with no Origin header and get blocked by the callback.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.