CORS misconfigurations in vibe-coded apps: how attackers steal authenticated data.

2026-05-09 · vibecheck team · 9 min read · Headers · CORS

Quick answer The "echo the Origin header" CORS pattern ships in most vibe-coded backends because it's what every "fix CORS" StackOverflow answer recommends. Combined with Access-Control-Allow-Credentials: true — also commonly enabled because the dev needed cookies to work cross-origin during prototyping — it produces a one-step path to cross-origin theft of authenticated user data. Five patterns vibecheck flags: ACAO: * with credentials, reflected arbitrary origin, reflected origin with credentials, accepted Origin: null, and unanchored regex allowlists. Each has a specific middleware fix; none require swapping frameworks.

CORS is one of the few security boundaries the browser enforces directly — and it's the one developers feel most pressure to relax. The default state is "no cross-origin reads"; the dev's app doesn't work; they reach for the cors middleware; the middleware's most permissive config makes everything work; the app ships. The attack surface that creates is not obvious from "everything works."

This post covers the five CORS patterns vibecheck flags, the attack each one enables, and the exact middleware configurations that fix them.

The mechanic, briefly

When a webpage at https://attacker.example tries to fetch('https://your-app.com/api/me'), the browser sends the request — but doesn't expose the response to the page unless your server says it's OK. Your server signals OK via the Access-Control-Allow-Origin response header. If the value matches the requesting origin (or is *), the page can read the response.

Access-Control-Allow-Credentials: true is what tells the browser to include cookies and Authorization headers on the cross-origin request, and to allow the response to be read despite containing them. Without it, even an open CORS policy can't read authenticated responses. With it, the policy is effectively the auth boundary.

The interaction is what makes CORS bugs lethal. ACAO: * alone is recoverable. Allow-Credentials: true alone is fine. The two together — or origin reflection paired with credentials — is the bug.

1. Access-Control-Allow-Origin: * with Allow-Credentials: true

The combination is forbidden by the spec — browsers refuse to send credentials when ACAO is *. But the existence of this combo in your response is a strong signal that the developer wanted both "anyone can call this" AND "include credentials" — they got "neither works as expected" and probably didn't notice because some other code path covers their actual usage.

The risk: non-browser clients (proxies, server-to-server tools, headless scripts) may honor it and the credentials leak in those paths. The bigger risk: the configuration intent is unclear, which means the next person editing it will reach for origin: req.headers.origin to "fix" it — and that's the next finding.

Test from outside:

curl -i https://your-app.com/api/anything

# Look for both headers in the response. If both appear:
#   Access-Control-Allow-Origin: *
#   Access-Control-Allow-Credentials: true
# the policy is broken.

Fix. Pick one:

Dedicated fix-guide: /fix/cors_acao_wildcard_with_credentials.

2. Reflected arbitrary origin (without credentials)

The most common CORS pattern in vibe-coded apps. The cors middleware is configured to echo whatever Origin came in:

// THE BUG — looks fine, dev environments work, ships.
app.use(cors({ origin: true }));         // Express
app.use("/*", cors({ origin: "*" }));    // Hono

// Or hand-rolled:
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);

vibecheck detects this by sending a real request with Origin: https://attacker.example and checking whether the server reflects that exact value back. If it does, the allowlist isn't filtering anything.

Without credentials, this isn't an authenticated-data leak — but it is:

Test from outside:

curl -i -H "Origin: https://attacker.example" https://your-app.com/api/anything

# Response should NOT include:
#   Access-Control-Allow-Origin: https://attacker.example

Fix. Replace echo-the-Origin with an allowlist.

Express + cors:

import cors from "cors";

const ALLOWED_ORIGINS = [
  "https://your-app.com",
  "https://www.your-app.com",
  "https://app.your-app.com",
];

app.use(cors({
  origin: (origin, cb) => {
    if (!origin) return cb(null, true);            // same-origin / no-Origin requests
    if (ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
    return cb(new Error("Origin not allowed"));
  },
  credentials: true,
}));

Hono / Cloudflare Workers:

import { cors } from "hono/cors";

app.use("/*", cors({
  origin: ALLOWED_ORIGINS,                          // explicit list
  credentials: true,
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}));

Next.js Route Handlers (no middleware library — set headers explicitly):

const ALLOWED_ORIGINS = new Set([
  "https://your-app.com", "https://www.your-app.com",
]);

export async function GET(req: Request) {
  const origin = req.headers.get("origin");
  const allow = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;

  const res = NextResponse.json({ data });
  if (allow) {
    res.headers.set("Access-Control-Allow-Origin", allow);
    res.headers.set("Access-Control-Allow-Credentials", "true");
    res.headers.set("Vary", "Origin");
  }
  return res;
}

The Vary: Origin header is important. Without it, your CDN can cache a response with one origin's ACAO and serve it to a request from a different origin. Always set Vary: Origin when ACAO depends on the request.

Dedicated fix-guide: /fix/cors_origin_reflected.

3. Reflected origin WITH credentials (the lethal combo)

Same shape as #2, but the response also includes Access-Control-Allow-Credentials: true. This is the configuration that produces real authenticated-data theft.

The attack:

  1. Victim is logged into your-app.com — a session cookie is set.
  2. Victim visits attacker.example (phishing email, malicious ad, compromised forum, etc.).
  3. attacker.example serves a page with fetch('https://your-app.com/api/me', { credentials: 'include' }).
  4. The browser includes the victim's session cookie in the request.
  5. Your server validates the session and returns the user's data.
  6. Your server's CORS headers say attacker.example may read this response.
  7. attacker.example reads the JSON body and exfiltrates it.

This is functionally equivalent to having no auth boundary on your API for cross-origin reads. CSRF tokens don't help — those rely on the browser blocking the read. Same-Site cookie hardening doesn't help unless every authenticated cookie is SameSite=Strict (which breaks legitimate cross-subdomain flows).

Test from outside:

curl -i -H "Origin: https://attacker.example" \
     https://your-app.com/api/me

# If response contains BOTH:
#   Access-Control-Allow-Origin: https://attacker.example
#   Access-Control-Allow-Credentials: true
# you have the lethal CORS bug.

Fix. Same allowlist code as #2 — that's all you need; the cors middleware will refuse to set credentials when the origin doesn't pass the allowlist. After deploying, audit logs for the exposure window: any cross-origin authenticated request with an Origin that doesn't match your domains is a potential exfiltration. Rotate sensitive sessions if you can identify exposed accounts.

Dedicated fix-guide: /fix/cors_origin_reflected_with_credentials.

4. Accepted Origin: null

The most subtle one. Browsers send Origin: null in three cases:

Lots of platforms allow embedding sandboxed iframes with arbitrary content — Notion embeds, Confluence widgets, ad networks, embeddable forms, even some email clients. If your CORS policy reflects or accepts null, an attacker who can host an embed gets cross-origin access to your API from any victim that's logged in.

The pattern usually shows up because someone added 'null' to the allowlist as a debugging convenience during development (so they could test from a file:// URL) and the entry never got removed.

Test from outside:

curl -i -H "Origin: null" https://your-app.com/api/me

# Response should NOT include:
#   Access-Control-Allow-Origin: null

Fix. Reject null explicitly:

app.use(cors({
  origin: (origin, cb) => {
    if (origin === "null") return cb(new Error("null origin not allowed"));
    if (!origin) return cb(null, true);
    if (ALLOWED_ORIGINS.includes(origin)) return cb(null, true);
    return cb(new Error("Origin not allowed"));
  },
  credentials: true,
}));

For local dev from file:// URLs: serve a real local HTTP server (python3 -m http.server, npx serve, vite), then allowlist http://localhost:<port> for dev only.

Dedicated fix-guide: /fix/cors_null_origin_allowed.

5. Unanchored regex allowlists

Not currently a vibecheck-flagged finding (regexes aren't visible from outside), but worth covering because it's a frequent footgun in the source code that produces the externally-visible bugs above.

If your allowlist is a regex like:

// THE BUG — no anchors.
const ORIGIN_RE = /\.your-app\.com$/;
if (ORIGIN_RE.test(origin)) cb(null, true);

...the regex matches https://attacker.example/.your-app.com as well as https://app.your-app.com. The trailing-anchor regex looks correct (it ends with $) but it's missing the leading anchor and the protocol — so any string ending with .your-app.com matches.

Fix: always anchor both ends and pin the protocol:

const ORIGIN_RE = /^https:\/\/(?:[a-z0-9-]+\.)?your-app\.com$/;

Or even better: skip regex entirely and use an explicit array. Allowlists with three or four entries don't need patterns.

How vibecheck fits in

vibecheck's headers detector sends an active CORS probe with Origin: https://attacker.example and a separate probe with Origin: null. The four flagged outcomes:

Run a scan and look at the Headers section. CORS findings appear there.

Inspect your app's CORS configuration