What is CORS and how does it work?
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that uses HTTP headers to let servers control which origins can read their responses.
Theory
TL;DR
- Browsers block cross-origin reads by default (same-origin policy). CORS lets servers opt in via response headers.
- The key header is
Access-Control-Allow-Origin. If it matches the requesting origin, the browser releases the response to JavaScript. - For complex requests (custom headers or non-standard methods), the browser sends a preflight
OPTIONSrequest first. - Credentials (cookies, auth tokens) require
Access-Control-Allow-Credentials: trueplus a specific origin, never*. - Node.js does not enforce CORS. Only browsers do.
Quick example
// Browser on http://localhost:3000 fetches from a different origin
fetch('https://api.example.com/data')
.then(r => r.json())
.catch(e => console.error(e));
// Error: No 'Access-Control-Allow-Origin' header is present
// Works when server responds with:
// Access-Control-Allow-Origin: http://localhost:3000The browser blocks the response, not the request itself. The request reaches the server, but the browser holds the response until it checks the headers.
Same-origin policy
Two URLs share an origin only if their scheme, host, and port all match. http://localhost:3000 and http://localhost:3001 are different origins even though both run on localhost.
CORS does not replace same-origin policy. It adds an opt-in layer on top. The server says "yes, this origin is allowed," and the browser respects that.
Preflight requests
Not every cross-origin request goes straight through. If a request uses PUT, DELETE, or PATCH, or sends a non-standard header like Authorization or Content-Type: application/json, the browser sends an OPTIONS request first.
That preflight asks: "I am about to send this kind of request from this origin. Is that allowed?" The server responds with Access-Control-Allow-Methods and Access-Control-Allow-Headers. If those match, the real request follows.
Simple requests (GET, POST, HEAD with standard headers and no JSON body) skip preflight entirely.
When to use
- Public API open to everyone:
Access-Control-Allow-Origin: * - App talking to its own API:
Access-Control-Allow-Origin: https://app.yourcompany.com - Requests that send cookies or auth tokens:
Access-Control-Allow-Credentials: trueplus the exact origin, not* - Non-simple requests: handle the
OPTIONSpreflight manually or use thecorsnpm package
Common mistakes
Using * with credentials
// Wrong - the browser rejects this combination
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Credentials', 'true'); // FAIL
// Fix - use the exact origin
res.header('Access-Control-Allow-Origin', req.get('Origin'));
res.header('Access-Control-Allow-Credentials', 'true');The browser throws: "Credential is not supported if the CORS header is *". This is the most common CORS error in production and a classic interview question.
Missing OPTIONS handler
// Wrong - PUT triggers preflight, but there is no OPTIONS route
app.put('/data', handler);
// Fix
app.options('*', cors()); // handle preflight for all routes
app.put('/data', cors(), handler);The preflight request hangs and the client sees a 4xx error. Easy to miss in development when using a proxy.
Assuming GET is always simple
Adding any custom header like X-Request-ID turns a GET into a non-simple request, triggering preflight. The request still works, just slower. Where custom headers are not needed, do not add them.
Dev proxy that hides the real problem
Create React App's "proxy" setting in package.json bypasses CORS in development. The app deploys to Vercel or Netlify, and everything breaks. Configure server CORS from day one.
Real-world usage
- Express:
app.use(cors({ origin: 'https://myapp.com' }))in REST API servers - Next.js: set headers via
NextResponseor middleware - AWS API Gateway: enable CORS per endpoint in the console
- Cloudflare Workers:
response.headers.set('Access-Control-Allow-Origin', '*') - Create React App (dev only):
"proxy": "http://localhost:3001"inpackage.json
Follow-up questions
Q: What is the same-origin policy?
A: A browser rule that blocks cross-origin reads when scheme, host, or port differ. Tags like <img> and <script> can load from any origin, but fetch and XMLHttpRequest cannot read cross-origin responses without CORS headers.
Q: What is the difference between a simple request and a preflight request?
A: Simple requests use GET, POST, or HEAD with standard headers and no application/json Content-Type. Everything else triggers an OPTIONS preflight before the actual request.
Q: Why does CORS not apply to Node.js scripts?
A: CORS is enforced only by browsers. Node.js has no same-origin policy, so it ignores CORS headers entirely. Any server-to-server HTTP request skips CORS.
Q: Can you configure CORS without a library?
A: Yes. Set res.header('Access-Control-Allow-Origin', origin) in middleware and handle OPTIONS separately. The cors npm package just automates this pattern.
Q: Why can mode: 'no-cors' still cause issues in a service worker?
A: It returns an opaque response with no readable status or body. Caching opaque responses in a service worker can store error responses alongside successful ones with no way to tell them apart, which breaks offline behavior.
Examples
Express server with CORS (development setup)
// server.js - Express on port 3001
const express = require('express');
const app = express();
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
app.get('/api/user', (req, res) => res.json({ name: 'Alex' }));
app.listen(3001);// App.js - React on localhost:3000
import { useEffect, useState } from 'react';
function App() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('http://localhost:3001/api/user')
.then(r => r.json())
.then(setUser);
}, []);
return <div>{user?.name || 'Loading...'}</div>;
// Output: Alex
}The server explicitly allows http://localhost:3000. Remove that header and the browser holds the response, JavaScript sees nothing, and the console shows a CORS error.
Credentials and exact origin
When a request includes cookies or an Authorization header, * stops working.
// server.js - credentials require an exact origin
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000'); // NOT *
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});// Client
fetch('http://localhost:3001/api/private', {
credentials: 'include', // sends cookies
})
.then(r => r.json())
.then(console.log);
// Works with exact origin + credentials: true
// Fails with *: "The value of the 'Access-Control-Allow-Origin' header
// must not be the wildcard '*' when the request's credentials mode is 'include'"I have seen this break production deploys more than once. Someone enables credentials: include on the client and forgets to update the server from * to the actual origin.
Preflight in detail
Here is what the browser actually sends before a POST with JSON:
// This fetch triggers a preflight automatically:
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 1 }),
});
// Preflight request the browser generates:
// OPTIONS /data HTTP/1.1
// Origin: https://yourapp.com
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: Content-Type
// Required server response:
// Access-Control-Allow-Origin: https://yourapp.com
// Access-Control-Allow-Methods: POST
// Access-Control-Allow-Headers: Content-Type
// Access-Control-Max-Age: 86400 <- cache the preflight result for 24 hoursAccess-Control-Max-Age is worth knowing for interviews. It tells the browser to cache the preflight result for N seconds, so it does not fire an OPTIONS request on every call.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.