Skip to main content

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: http vs https, myapp.com vs api.myapp.com, :3000 vs :4000.
  • Before complex requests, the browser sends an automatic OPTIONS preflight to check what the server allows.
  • origin: '*' with credentials: true is rejected by browsers. Use one exact origin when cookies are involved.
  • In production: set origin to your frontend URL, credentials: true for cookies, and list Authorization in allowedHeaders.

Quick example

js
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: Authorization

If 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: true with 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.

js
// 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.

js
// 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.

js
// 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.

js
origin: ['http://localhost:3000', 'http://127.0.0.1:3000']

Real-world usage

  • Create React App: proxy in package.json handles CORS in dev; the Express config covers production.
  • Next.js: res.setHeader('Access-Control-Allow-Origin', ...) in API routes, or a shared corsOptions object across handlers.
  • NestJS: app.enableCors({ origin: process.env.ALLOWED_ORIGINS }) in main.ts.
  • Strapi: config/middlewares.js with 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

js
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.

js
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

js
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?