SecurityArticle
AI assistants keep reflecting the Origin header with credentials on β the one pattern that turns an authenticated API into an open door.
Ask Cursor to fix a CORS error and there's a good chance it hands you a critical vulnerability with a green checkmark. Not a wildcard *
β that one's mostly harmless because browsers refuse to pair it with credentials. The dangerous pattern is subtler: middleware that reads the incoming Origin
header and echoes it straight back into Access-Control-Allow-Origin
, alongside Access-Control-Allow-Credentials: true
. It works flawlessly in local dev. It ships. And it turns your authenticated API into something any website on the internet can read on a logged-in user's behalf.
Why the model reaches for reflection #
CORS errors are among the most common friction points in web development, and they share a frustrating property: the browser tells you what it wants but the fix is never one obvious line. You're running the frontend on localhost:3000
, the API on localhost:8000
, a preview build on some Vercel URL, maybe Postman or a mobile simulator hitting the same backend. Every one of those is a distinct origin. An allowlist has to enumerate all of them and stay current. Reflection doesn't β it says yes to whoever asks.
res.header('Access-Control-Allow-Origin', req.headers.origin);
res.header('Access-Control-Allow-Credentials', 'true');
From a language model's perspective this is the global maximum. It resolves the error for every dev port, every tool, every teammate, on the first try, with no follow-up questions. The training corpus is saturated with Stack Overflow answers that do exactly this because they, too, were optimizing for "make the red text go away." As one writeup on the pattern put it, the fastest way to make a CORS error disappear is to stop checking who's asking. The model learned that lesson well.
The tell is that req.headers.origin
is attacker-controlled. A malicious page sets Origin: https://your-api.example
β no, it sets whatever it likes, Origin: https://evil.test
, and the server dutifully reflects evil.test
back. The browser sees the response origin match the request origin, sees credentials allowed, and releases the victim's session cookie along with the response body. Same-origin policy, the thing that's supposed to stop cross-site reads, has been explicitly waived by your own server. This is CWE-942, permissive cross-domain policy, and it reads as critical on any scanner because it is one.
This isn't a Cursor bug #
It's tempting to frame this as a Cursor problem, but that's too narrow. The behavior shows up across Bolt, Lovable, and plain Copilot completions, because they draw on the same corpus and optimize for the same signal: does the code run. Veracode's 2025 GenAI Code Security Report, which ran 80 curated tasks across more than 100 models, found that when a task offered both a secure and an insecure way to write something, the models picked the insecure path 45% of the time. CORS is a textbook instance of that split: the secure version costs an extra function and a config array, the insecure version is shorter and passes the same functional test.
That framing matters because it tells you where the fix has to live. You cannot prompt your way to safety reliably β "write secure CORS" nudges the odds but doesn't close the gap, because the model's notion of "works" and your notion of "safe" diverge precisely here. The check has to be external to the generation step.
What the correct version actually costs #
The secure pattern is not exotic. It's an allowlist and a callback:
const allowedOrigins = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
app.use(cors({
origin(origin, cb) {
// no Origin header = same-origin or a non-browser client (curl, server-to-server)
if (!origin || allowedOrigins.has(origin)) return cb(null, true);
return cb(new Error('Not allowed by CORS'));
},
credentials: true,
}));
The cors middleware does the right thing here: it only emits
Access-Control-Allow-Origin
when the origin is on the list, and it never falls back to a wildcard. Flask's flask-cors
and most framework equivalents take the same explicit-list shape. The cost is real but small β you maintain a list, and you plumb your preview and staging URLs into it, usually from an environment variable so the list differs per deployment.One caveat worth stating plainly, because a lot of the "just use an allowlist" advice glosses it: locking down CORS is not CSRF protection. CORS governs whether a cross-origin script can read your response. It does nothing to stop a cross-site request from being sent and having side effects. If your API mutates state on cookie auth, you still want SameSite=Lax
or Strict
cookies and, for anything sensitive, CSRF tokens. Reflection-plus-credentials is worse than a missing allowlist because it defeats both layers at once, but fixing the read side doesn't absolve you of the write side.
The workflow change this demands #
If you're shipping AI-generated backends, treat CORS config as a review chokepoint, not a line you skim. Concretely:
- Grep your codebase for
Access-Control-Allow-Origin
set from a request header, and fororigin: true
in thecors
middleware β that's the reflect-everything switch in disguise. - Add a CI check. It's a one-line static rule: flag any response that sets ACAO from
req.headers.origin
or pairs a reflected/wildcard origin withcredentials: true
. This catches the regression the model reintroduces every time someone asks it to fix a new preview URL. - Test it adversarially, not functionally. Fire a request with
Origin: https://not-your-app.test
and assert the response doesnotecho it back. Your normal test suite won't catch this because your tests come from allowed origins.
The broader lesson is that AI assistants have quietly shifted where security bugs enter. They don't write novel, exotic flaws β they industrialize the well-known ones, reproducing the most common insecure Stack Overflow answer at the speed of autocomplete and with the confidence of a passing local test. CORS reflection is the current poster child because it's invisible until a scanner or a pentester flags it, and by then it's in production behind a login. The defense isn't better prompts. It's assuming the generated config is wrong about trust boundaries until a check you control proves otherwise.
Sources & further reading #
Why Cursor Keeps Generating Wildcard CORS Headers in Your APIβ dev.to - 2025 GenAI Code Security Reportβ veracode.com - CORS Misconfiguration in Vibe-Coded Apps: Wildcard Origins Explainedβ vibedoctor.io - CWE-942: Permissive Cross-domain Policy with Untrusted Domainβ cwe.mitre.org
Ji-ho ChoiΒ· Security & Cloud Editor
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0 #
No comments yet
Be the first to weigh in.