Back to Blog
Nov 15, 20254 min readOnuzulike Anthony

CORS Headers: What Actually Happens and Where Developers Go Wrong

Preflight requests, credentials, wildcards, and the mistakes that silently break your API for legitimate clients while doing nothing to stop attackers.

SecuritySecurityCORSWeb

CORS is one of the most misunderstood browser security mechanisms. Most developers learn about it when it breaks something, then copy a Stack Overflow snippet that fixes the symptom without understanding what they enabled. Here's what's actually happening.

The Same-Origin Policy

Browsers enforce that scripts on https://app.com can only make fetch requests to https://app.com. Any request to a different origin (different scheme, host, or port) is subject to CORS.

CORS is entirely a browser enforcement. The server receives the request regardless. The browser decides whether to expose the response to the calling script. This is why CORS headers don't protect against server-side attacks — curl ignores them completely.

Simple vs Preflighted Requests

"Simple" requests (GET/POST with standard content types) are sent directly. The browser checks the response's Access-Control-Allow-Origin header before exposing the response.

"Preflighted" requests — anything with a custom header, PUT/PATCH/DELETE, or Content-Type: application/json — trigger an OPTIONS request first. The browser asks the server: "Will you accept this request?" Only if the preflight succeeds does the real request proceed.

OPTIONS /api/users HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization

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

Access-Control-Max-Age caches the preflight result. Without it, every request with a custom header fires a preflight — which doubles your request count and adds latency.

Credentials

By default, cross-origin requests do not include cookies or HTTP auth headers. If your API relies on cookies (session auth, CSRF tokens), you need both:

  • Server: Access-Control-Allow-Credentials: true
  • Client: fetch(url, { credentials: 'include' })

Critical constraint: When credentials are enabled, Access-Control-Allow-Origin must be an explicit origin — not *. A wildcard with credentials is rejected by the browser. This is intentional.

ts
// Wrong: this will fail with credentials
res.setHeader('Access-Control-Allow-Origin', '*');
 
// Correct
const allowed = ['https://app.com', 'https://staging.app.com'];
const origin = req.headers.origin;
if (allowed.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
  res.setHeader('Vary', 'Origin'); // important for CDN caching
}

The Vary: Origin Header

When you dynamically set Access-Control-Allow-Origin based on the request's Origin, you must also set Vary: Origin. Without it, a CDN might cache a response with Access-Control-Allow-Origin: https://app.com and serve it to a request from https://evil.com — which then gets the same ACAO header and thinks it's allowed.

Common Mistakes

Wildcard origin in production. Access-Control-Allow-Origin: * means any origin can read the response. For public read-only APIs (public data, CDN assets), this is fine. For authenticated APIs, it should never appear.

Reflecting any origin without validation. Some frameworks echo back whatever Origin header the request includes. This is equivalent to a wildcard for any attacker who knows to include an Origin header.

Missing OPTIONS handler. Preflighted requests fail with 405 if your server doesn't explicitly handle OPTIONS. Always add a catch-all OPTIONS route that returns 204 with the appropriate CORS headers.

Over-broad Access-Control-Allow-Headers: *. Wildcards in Allow-Headers are not supported in all browsers. Be explicit: list the headers your API actually uses.

CORS for NodWatch

NodWatch's API accepts cross-origin requests from localhost:8081 and 10.0.2.2:8081 to support the Expo mobile client. The trusted_origins list in Better-Auth handles this, which automatically sets Access-Control-Allow-Origin from the list. The streaming iframe embeds don't involve CORS — those are same-origin requests from the server-side proxy route.

What CORS Doesn't Do

It doesn't protect your API from server-side callers. It doesn't prevent CSRF (that's SameSite cookies and CSRF tokens). It doesn't authenticate anyone. It only controls whether a browser will expose a cross-origin response to the script that initiated the request.

Understanding this distinction makes CORS debugging much faster — if your API is returning a 200 but the browser is blocking the response, the problem is always in the response headers, never in the request itself.