Skip to content
·6 min read

CORS, The Mental Model They Never Taught You

CORS is the most copy-pasted topic in web dev. Everyone adds Access-Control-Allow-Origin: * until it works. Here's what's actually happening under the hood.

CORSWeb DevSecurityBackend

CORS is the most copy-pasted topic in web dev. Your frontend hits an API, you see "CORS error" in the console, you Google it, you add Access-Control-Allow-Origin: *, it works, you move on. You never actually learned what CORS does.

This is the article that explains it. Read it once and you'll debug CORS in two minutes instead of two hours.

The mental model: the browser is the enforcer

The biggest misconception: people think CORS is a server feature. It's not. It's a browser feature.

Your React app at app.com
    |
    | JavaScript calls fetch('https://api.com/data')
    |
    v
The browser intercepts the request
    |
    v
The browser checks the RESPONSE headers from api.com
    |
    v
If api.com says "app.com is allowed" → show the data to JavaScript
If not → block the response from JavaScript

The server sent the data. The browser just won't let your code read it.

Proof: hit the same endpoint from Postman, curl, or a Node script. No CORS error. CORS only exists in browsers.

Why CORS exists

Without CORS, any website you visit could use your logged-in session on any other site.

Example: you're logged into your bank. You visit a malicious site. That site's JavaScript quietly calls https://bank.com/api/transfer?to=attacker&amount=10000. Your browser sends the request with your bank cookie. The bank thinks it's you.

The Same-Origin Policy prevents this. JavaScript on app.com cannot read responses from bank.com unless bank.com explicitly says it's OK.

CORS is the controlled relaxation of that policy. The server opt-ins via headers.

Simple requests vs preflights

There are two kinds of cross-origin requests. Knowing which one you're making is half the debug.

Simple requests

A request is "simple" (no preflight) if:

  • Method is GET, HEAD, or POST
  • POST content type is application/x-www-form-urlencoded, multipart/form-data, or text/plain
  • No custom headers (no Authorization, no X-Requested-With, etc.)

The browser sends it directly and checks the response headers.

Preflighted requests

Anything else triggers an OPTIONS preflight:

OPTIONS /api/data HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

The browser is asking: "Hey server, am I allowed to send a DELETE with these headers?"

The server responds:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

If the actual request fits within what the server allowed, the browser sends it. Otherwise, the request never leaves the browser.

The credentials trap

This is the gotcha that wastes everyone's afternoon.

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

This combination is rejected by the browser. It will not let your JavaScript read the response. The browser is protecting you: with credentials (cookies, Authorization headers), the server must specify the exact origin.

Correct setup for credentialed requests:

Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Credentials: true

If you support multiple origins, you have to read the Origin request header and echo it back in the response:

// Node.js example
const allowedOrigins = ['https://app.com', 'https://staging.app.com']
 
app.use((req, res, next) => {
  const origin = req.headers.origin
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin)
    res.setHeader('Vary', 'Origin')  // Important for caching
    res.setHeader('Access-Control-Allow-Credentials', 'true')
  }
  next()
})

The Vary: Origin header is critical. Without it, a CDN might cache the response for app.com and serve it to staging.app.com, which then gets rejected.

The Next.js setup that works

For Next.js API routes or route handlers:

// app/api/_middleware.ts or middleware.ts
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') ?? []
 
export function corsHeaders(origin: string | null) {
  if (!origin || !allowedOrigins.includes(origin)) {
    return {}
  }
  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Authorization, Content-Type',
    'Access-Control-Allow-Credentials': 'true',
    'Access-Control-Max-Age': '86400',
    'Vary': 'Origin',
  }
}

For preflight:

export async function OPTIONS(req: Request) {
  const origin = req.headers.get('origin')
  return new Response(null, {
    status: 204,
    headers: corsHeaders(origin),
  })
}

Add corsHeaders(req.headers.get('origin')) to every response in your route handlers. That's the whole setup.

Common CORS errors decoded

"No 'Access-Control-Allow-Origin' header is present" The server didn't send any CORS headers at all. Fix the server.

"The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'" You're using credentials (cookies, Authorization) but the server sent *. Switch to a specific origin.

"Request header field Authorization is not allowed by Access-Control-Allow-Headers" Your client sends a custom header the server didn't whitelist. Add it to Access-Control-Allow-Headers.

CORS preflight fails on a DELETE or PUT Same as above, the server needs to allow that method in Access-Control-Allow-Methods.

Works locally, fails in production You probably hardcoded localhost:3000 as the allowed origin. Use environment variables.

The CORS rules I ship

  1. The browser enforces CORS. Postman, curl, and Node don't. If your API works in Postman but fails in the browser, it's CORS.
  2. Whitelist specific origins in production. Never * with credentials.
  3. Set Access-Control-Max-Age: 86400. Caches preflights for 24 hours.
  4. Always set Vary: Origin. Prevents CDN cache poisoning.
  5. If something breaks, check preflight responses first. 80% of CORS bugs are missing OPTIONS handlers.

CORS is not a server feature. It's the browser enforcing a contract between two origins. Understand the contract and the headers write themselves.

Want a backend that doesn't fight your frontend?

I build backends with CORS done right, specific origins, proper preflight handling, and no surprise errors in production. Let's talk.