caduh

CORS Explained Without Cargo Culting

9 min read

A practical guide to CORS for builders: origins, preflights, credentials, common browser errors, and the server configs that actually fix them without opening your API to the world.

CORS Explained Without Cargo Culting

Preflights, credentials, allowed origins, and the mistakes that keep shipping to prod

Goal: help you configure cross-origin browser access correctly without turning CORS into accidental public access, broken cookies, or a week of “works in Postman” debugging.


TL;DR

  • CORS is a browser access-control protocol, not an authentication or authorization system.
  • CORS matters only when a browser tries to let JavaScript read a cross-origin response.
  • Postman, curl, and server-to-server traffic do not enforce CORS.
  • The safest default is: do not enable CORS unless a browser frontend on another origin actually needs it.
  • If you do need it, allow specific origins, methods, and headers. Avoid * unless the resource is truly public.
  • If you send cookies or HTTP auth, you must return Access-Control-Allow-Credentials: true and cannot use Access-Control-Allow-Origin: *.
  • Preflight is just the browser asking permission before a non-simple request.
  • CORS is not CSRF protection. Bad CORS can make a cookie-backed app easier to abuse.

1) What CORS Actually Is

CORS stands for Cross-Origin Resource Sharing.

It exists because browsers enforce the same-origin policy. By default, JavaScript running on https://app.example.com cannot freely read responses from https://api.example.net.

CORS is how the server tells the browser:

“This other origin is allowed to read my response.”

That means CORS is about browser-controlled reads, not about whether a request can physically reach your server.

Very important consequence

This request can still hit your server even if CORS is wrong:

curl https://api.example.com/users

So if someone says:

“We’re safe because CORS blocks it”

the answer is:

“Only in browsers, and only for scripts reading responses.”

Your backend must still do real authentication, authorization, and input validation.


2) Origin vs Site vs URL

People mix these up constantly.

An origin is:

  • scheme (https)
  • host (app.example.com)
  • port (443)

These are different origins:

  • https://app.example.com
  • http://app.example.com
  • https://api.example.com
  • https://app.example.com:8443

CORS works at the origin level.

That means a frontend on:

  • https://www.example.com

calling an API on:

  • https://api.example.com

is cross-origin, even though both belong to the same company and may be the same “site” for cookie purposes.


3) When the Browser Sends a Preflight

A preflight is an OPTIONS request the browser sends before the real request when the real request is not “simple”.

Typical triggers for preflight

  • method is not GET, HEAD, or POST
  • request includes non-safelisted headers like Authorization or X-CSRF-Token
  • Content-Type is not one of the safelisted values like form-encoded, multipart form data, or plain text
  • request uses certain advanced features the browser treats as non-simple

Example

Frontend:

await fetch("https://api.example.com/orders", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "authorization": `Bearer ${token}`,
  },
  body: JSON.stringify({ sku: "abc", qty: 1 }),
});

Before the real request, the browser may send:

OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

Your server must answer with something like:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: Origin

If that response is wrong, the browser blocks the real request from being exposed to JavaScript.


4) The Core Headers That Matter

Access-Control-Allow-Origin

Which origin may read the response.

Examples:

Access-Control-Allow-Origin: https://app.example.com

or for truly public resources:

Access-Control-Allow-Origin: *

Access-Control-Allow-Credentials

Whether the browser may include credentials such as cookies or HTTP auth in a cross-origin request.

Access-Control-Allow-Credentials: true

There is no useful false value. Omit the header when credentials are not allowed.

Access-Control-Allow-Methods

Which methods may be used for the actual request after preflight.

Access-Control-Allow-Methods: GET, POST, PUT, DELETE

Access-Control-Allow-Headers

Which non-safelisted request headers are allowed.

Access-Control-Allow-Headers: content-type, authorization, x-csrf-token

Access-Control-Max-Age

How long the browser may cache the preflight result.

Access-Control-Max-Age: 600

Access-Control-Expose-Headers

Which response headers frontend JavaScript is allowed to read beyond the safelisted ones.

Access-Control-Expose-Headers: x-request-id, x-rate-limit-remaining

5) The Credentials Rule That Breaks Everyone Once

If the browser sends credentials, you need all of this:

  • frontend must opt in with credentials
  • server must return Access-Control-Allow-Credentials: true
  • server must return a specific origin, not *

Frontend example

await fetch("https://api.example.com/me", {
  credentials: "include",
});

Correct server response

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

Incorrect

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

That combination does not work in browsers.

Also important

Preflight requests themselves should not carry application credentials. They are permission checks, not authenticated business operations.


6) Public API vs Browser API: Two Different CORS Postures

Case A: Truly public, read-only resource

Example: public docs JSON, public image metadata, version manifest.

A permissive config can be fine:

Access-Control-Allow-Origin: *

Maybe no credentials, no sensitive data, no account-specific responses.

Case B: Browser app on another origin calling your API

Example:

  • frontend: https://app.example.com
  • API: https://api.example.com

Use an allowlist:

const ALLOWED = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

Reflect the origin only if it is trusted.

Case C: Internal API consumed only server-to-server

Do not add CORS just because curl works and the framework made it easy.

No browser frontend on another origin means you probably do not need CORS at all.


7) The Most Common Bad Ideas

7.1 “Just set Access-Control-Allow-Origin: *

Fine for public static resources. Bad for anything user-specific, authenticated, or sensitive.

7.2 “CORS secures the API”

No. CORS tells browsers which origins may read responses. Attackers can still call your API from their own servers.

7.3 “CORS errors mean the request never reached us”

Often false. The browser may have sent the preflight, or even the main request in some cases, and only blocked frontend access to the response. Check server logs.

7.4 “Allow all headers and methods forever”

Convenient, but you lose clarity and increase exposure. Keep the policy narrow.

7.5 “CORS and CSRF are the same problem”

They are not.

  • CORS controls which origins may read responses.
  • CSRF is about unwanted state-changing requests when the browser auto-sends credentials.

A cookie-backed app can have perfect CORS and still be vulnerable to CSRF. A bad CORS config can also make a cookie-backed API easier to call from trusted-but-too-broad frontend origins.


8) A Good Mental Model for Request Types

| Request type | Preflight? | Needs CORS? | Typical use | |---|---|---:|---| | Same-origin browser request | No | No | Frontend and API on same origin | | Cross-origin browser GET with simple headers | Usually no | Yes | Public read APIs | | Cross-origin browser POST JSON | Usually yes | Yes | SPA calling API | | Cross-origin browser with cookies | Often yes | Yes, with credentials rules | BFF / session-backed APIs | | curl / Postman / backend-to-backend | No browser CORS enforcement | No | Integration testing, service-to-service |

If you remember one thing, remember this:

CORS is a browser contract, not a perimeter firewall.


9) Practical Server Configs

Express / Node

import express from "express";
import cors from "cors";

const app = express();

const ALLOWED = new Set([
  "https://app.example.com",
  "https://admin.example.com",
]);

app.use(cors({
  origin(origin, cb) {
    // Allow non-browser or same-origin requests with no Origin header if needed.
    if (!origin) return cb(null, true);
    if (ALLOWED.has(origin)) return cb(null, true);
    return cb(new Error("Origin not allowed by CORS"));
  },
  credentials: true,
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
  allowedHeaders: ["content-type", "authorization", "x-csrf-token"],
  exposedHeaders: ["x-request-id"],
  maxAge: 600,
}));

Nginx (specific origin, credentials)

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"   $http_origin;
    "https://admin.example.com" $http_origin;
}

server {
    location /api/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Credentials true always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "content-type, authorization, x-csrf-token" always;
            add_header Access-Control-Max-Age 600 always;
            add_header Vary Origin always;
            return 204;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Vary Origin always;

        proxy_pass http://app_backend;
    }
}

Important note

If you dynamically echo the allowed origin, add:

Vary: Origin

Otherwise shared caches may serve the wrong CORS response to the wrong caller.


10) Cookies, SPAs, and Why SameSite Still Matters

If your browser app uses cookies across origins or subdomains, CORS is only part of the picture.

You also need to think about:

  • cookie SameSite
  • Secure
  • HttpOnly
  • CSRF protection on state-changing endpoints
  • whether you actually need cross-origin cookies at all

Safer default

Prefer:

  • same-origin frontend + API when possible
  • or a BFF pattern where the browser talks to one origin

This removes a lot of CORS pain and usually reduces auth complexity.


11) Debugging CORS Without Losing a Day

Step 1: open the browser network tab

Do not start with screenshots of the console error alone. Inspect:

  • the Origin request header
  • whether there was an OPTIONS preflight
  • the response headers on both preflight and actual request

Step 2: answer these questions in order

  1. Is the request actually cross-origin?
  2. Did the browser send a preflight?
  3. Does the preflight response include the needed Allow-* headers?
  4. Are credentials involved?
  5. Is the server mistakenly sending * with credentials?
  6. Is a proxy/CDN stripping or caching headers?
  7. Did the request fail for a real backend reason and the frontend is blaming CORS?

Step 3: reproduce the preflight deliberately

curl -i -X OPTIONS 'https://api.example.com/orders' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization'

This does not reproduce the browser perfectly, but it is great for checking your edge, gateway, or app response.


12) “Works in Postman” Is Not Evidence

Postman, curl, mobile apps, backend workers, and cron jobs do not enforce browser CORS policy.

So this situation is normal:

  • API works in Postman
  • browser app fails with CORS

That does not mean the API is fine for web usage. It means your browser-facing cross-origin policy is incomplete.


13) Sharp Edges You’ll Hit in Production

  • Redirects on preflight can fail in surprising ways across browsers and proxies.
  • 401/403 on OPTIONS often means auth middleware is running before CORS handling.
  • CDN caching can serve stale or wrong Access-Control-Allow-Origin values if Vary: Origin is missing.
  • Case mismatches in Access-Control-Request-Headers vs your allowlist can confuse frameworks and gateways.
  • Wildcard subdomains sound convenient but easily become broader trust than intended.
  • Third-party frontend previews like Vercel preview URLs and localhost ports can break allowlists unless you plan for them.
  • Exposed headers are often forgotten, so frontend code cannot read useful response metadata even though the request succeeded.

14) Recommended Defaults

For public read-only assets

Access-Control-Allow-Origin: *

No credentials.

For production browser apps on a separate origin

  • explicit allowlist of trusted origins
  • explicit methods
  • explicit request headers
  • Access-Control-Allow-Credentials: true only if needed
  • Vary: Origin
  • short but useful Access-Control-Max-Age

For private APIs with no browser cross-origin use case

  • no CORS at all

15) 2 AM Runbook

Symptom: frontend shows “blocked by CORS policy”.

  1. Open DevTools Network, not just Console.
  2. Check whether the request is actually cross-origin.
  3. Find the OPTIONS request if there is one.
  4. Confirm the response has the right:
    • Access-Control-Allow-Origin
    • Access-Control-Allow-Methods
    • Access-Control-Allow-Headers
    • Access-Control-Allow-Credentials if needed
  5. If credentials are involved, verify you are not using * for origin.
  6. Confirm OPTIONS is not blocked by auth middleware, WAF, or gateway rules.
  7. Check CDN/proxy behavior and add Vary: Origin for dynamic origin reflection.
  8. Reproduce with curl using an explicit Origin header.
  9. If the response headers look right, inspect whether the actual backend request is failing for a normal reason like 401, 403, or 500.
  10. Narrow the policy after the incident. Do not leave * in place “until later”.

16) The Practical Bottom Line

Use CORS only where it solves a real browser problem.

When you need it:

  • keep the policy specific
  • understand preflights
  • handle credentials carefully
  • add Vary: Origin when reflecting origins
  • remember that auth and CSRF are separate concerns

The cleanest CORS config is often the one you never needed because your browser talks to one origin.