Skip to main content

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 OPTIONS request first.
  • Credentials (cookies, auth tokens) require Access-Control-Allow-Credentials: true plus a specific origin, never *.
  • Node.js does not enforce CORS. Only browsers do.

Quick example

js
// 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:3000

The 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: true plus the exact origin, not *
  • Non-simple requests: handle the OPTIONS preflight manually or use the cors npm package

Common mistakes

Using * with credentials

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

js
// 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 NextResponse or 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" in package.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)

js
// 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);
jsx
// 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.

js
// 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(); });
js
// 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:

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

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

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

Finished reading?