caduh

Understanding CORS — a tiny explainer for stuck web devs

4 min read

A fast primer on Cross-Origin Resource Sharing—what “origin” means, how simple vs. preflighted requests work, when to send credentials, and the exact headers that fix the dreaded CORS error.

TL;DR

  • Browsers enforce the same-origin policy. Servers must explicitly opt-in via Access-Control-* headers.
  • Simple requests (GET/HEAD/POST with “simple” headers & content types) skip preflight. Anything else triggers an OPTIONS preflight.
  • With credentials (cookies/HTTP auth) you must: set Access-Control-Allow-Credentials: true, use a specific Access-Control-Allow-Origin (no *), and set cookies with SameSite=None; Secure.
  • Cache preflights with Access-Control-Max-Age and avoid unnecessary custom headers to reduce preflight chatter.
  • Add Vary: Origin on responses that change by request origin.
  • For third-party APIs you can’t change: call them from your server (proxy) instead of the browser.

Why does CORS exist?

CORS is a protocol that safely relaxes the browser’s same-origin policy (which blocks reading responses across origins) by letting the target server say “this origin may read me.” It protects users while enabling legitimate cross-site API calls.


The actors (mental map)

Your Frontend (https://app.example.com) ── fetch ──► Browser
                                                     │ adds Origin: https://app.example.com
                                                     ▼
Target API (https://api.example.net) ── responds with Access-Control-* headers
  • Browser: Adds the Origin request header, runs the CORS checks, may send an OPTIONS preflight first.
  • API/Origin server: Decides which origins/methods/headers are allowed and returns the CORS response headers.
  • CDN/Proxy: Must pass through and correctly cache CORS headers. Add Vary: Origin when responses differ by origin.

Core concepts in 60 seconds

Simple vs. preflighted

A request is simple if all are true:

  • Method is GET, HEAD, or POST.
  • Headers are only “simple” ones (Accept, Accept-Language, Content-Language, Content-Type with text/plain, application/x-www-form-urlencoded, or multipart/form-data).
  • No fetch mode: 'no-cors' hacks (that yields opaque responses you can’t read).

Otherwise the browser first sends OPTIONS with:

  • Access-Control-Request-Method
  • Access-Control-Request-Headers
    Your server must answer with matching allow headers.

Credentials

If you send cookies or HTTP auth (fetch(..., { credentials: 'include' })):

  • Response must include Access-Control-Allow-Credentials: true.
  • Cannot use Access-Control-Allow-Origin: *; you must echo a specific origin.
  • Cookies set by the API must use SameSite=None; Secure.

Caching preflights

Use Access-Control-Max-Age: 600 (or similar) on the preflight response to cache the allowlist and cut OPTIONS traffic.


What to return (cheat sheet)

Preflight (OPTIONS) response

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

Actual response

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Total-Count, Link
Vary: Origin

Core strategies

1) If you control the API, set the headers correctly

Express (Node.js)

import express from "express";
const app = express();

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

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
    res.setHeader("Access-Control-Max-Age", "600");
  }
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});

app.get("/api/data", (req, res) => res.json({ ok: true }));
app.listen(3000);

FastAPI (Python)

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
origins = ["https://app.example.com", "https://admin.example.com"]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    max_age=600
)

@app.get("/api/data")
def data():
    return {"ok": True}

Nginx (reverse proxy)

map $http_origin $cors_origin {
  default "";
  "~^https://(app|admin)\.example\.com$" $http_origin;
}

server {
  # ...
  if ($request_method = OPTIONS) {
    add_header Access-Control-Allow-Origin $cors_origin always;
    add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
    add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Max-Age 600 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;
}

2) Reduce preflights when possible

  • Avoid unnecessary custom headers (each one forces preflight).
  • Batch calls; prefer GET where it fits; align Content-Type with allowed “simple” types if you truly don’t need JSON.
  • Use Access-Control-Max-Age to cache preflights.

3) Handle credentials correctly

  • Frontend: fetch(url, { credentials: 'include' }).
  • Backend: Access-Control-Allow-Credentials: true + explicit Access-Control-Allow-Origin.
  • Cookies: Set-Cookie: foo=bar; SameSite=None; Secure; Path=/.

4) Make CDNs/proxies CORS-aware

  • Pass through and cache CORS headers.
  • Add Vary: Origin to avoid serving one origin’s allowlist to another.
  • Don’t strip the Origin request header.

5) For third-party APIs you can’t configure

  • Call them from your server (backend-for-frontend) and return results to the browser.
  • Or use the provider’s CORS-enabled endpoint if they offer one.

6) Local dev sanity

  • Same hostname/port avoids CORS entirely.
  • Otherwise use a dev proxy (Vite/Next/webpack) to route /api/* to your backend.
  • Don’t sprinkle mode: 'no-cors'—that creates opaque responses you can’t read.

Client examples

Plain fetch (no credentials)

const res = await fetch("https://api.example.net/widgets");
const data = await res.json();

Fetch with credentials (cookies)

const res = await fetch("https://api.example.net/session", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username, password })
});

Common errors & fast fixes

| Symptom (Console) | Likely cause | Fix | |---|---|---| | “No Access-Control-Allow-Origin header” | Server didn’t set it | Add Access-Control-Allow-Origin (specific origin) | | “The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the request’s credentials mode is include” | Using * with cookies | Echo the exact origin + Allow-Credentials: true | | “Preflight response is not successful” | OPTIONS not handled or missing allow headers | Return 204 with the right Allow-* + Max-Age | | Works in Postman but not browser | Postman ignores CORS | Test in a real browser; fix server headers | | Cookie not sent | Missing SameSite=None; Secure | Set cookie flags; use HTTPS; credentials: 'include' | | Wrong data served across sites | Missing Vary: Origin | Add Vary: Origin wherever Allow-Origin varies |