CORS = * WITH CREDENTIALS = TRUE
If your CORS policy says any origin can call your API and your cookies say any origin can carry them, you have built a CSRF vending machine. AI generators reproduce this combination because the simplest CORS recipe online is the one that contains the bug.
The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.
The two-line bug
import cors from 'cors'
app.use(cors({ origin: true, credentials: true }))
origin: true in the cors package means “reflect whatever origin the request came from.” credentials: true means “include cookies.” The combination, when paired with cookies that don’t have SameSite=Strict, means any website the user visits can fire authenticated requests at your API and read the responses.
This is not theoretical. Visit attacker-site.example. The page contains JavaScript that does fetch('https://your-api.example/api/me', { credentials: 'include' }). Your server’s response includes Access-Control-Allow-Origin: https://attacker-site.example and Access-Control-Allow-Credentials: true. The browser allows the read. The attacker’s JavaScript now has the response. Same trick with POST and the attacker can perform arbitrary writes on your user’s behalf.
The CORS spec was specifically designed to prevent this. Reflecting the origin and including credentials is the configuration that re-enables exactly what CORS was designed to prevent.
Why this AI codegen pattern survives review
The line cors({ origin: true, credentials: true }) doesn’t look bad. It looks like “we configured CORS.” It looks like more security than cors() with no options. The developer added options, which feels like care. The options are wrong, but the intent looks right.
The AI didn’t write a malicious config. It wrote the config that resolves the CORS errors the developer was getting in the browser console. CORS errors in dev mean cookies don’t propagate, which means auth-protected endpoints break in dev, which means the developer asks the AI to “fix CORS.” The AI’s fix is “be permissive.” The bug ships.
The cookie companion
CORS is half of the picture. The other half is cookie attributes. A cookie marked SameSite=Strict won’t be sent on cross-origin requests at all, which kills most cross-origin attacks regardless of CORS configuration. A cookie marked SameSite=Lax (the modern browser default) is sent on top-level navigations but not on background fetches or POSTs. A cookie marked SameSite=None is sent on every cross-origin request — which is almost never what you want unless you have a deliberate cross-origin auth setup.
We see two cookie misconfigurations frequently:
SameSite=Noneset without thinking, because the dev framework’s example showed it. This pairs with the CORS misconfig to maximize damage.SameSitenot set at all, which used to mean “no protection” but now defaults to Lax in modern browsers. This is the safer default, but the overbroad-domainDomain=.example.comsetting can still leak the cookie to subdomains the developer doesn’t control.
Live demo of the missing-CSRF surface: https://gapbench.vibe-eval.com/site/csrf-missing/. The CORS variant: https://gapbench.vibe-eval.com/site/cors-misconfig/. The cookie-scope-leak variant: https://gapbench.vibe-eval.com/site/cookie-scope-leak/.
A specific incident
Anonymized. A SaaS team noticed odd activity in their analytics — a small but consistent stream of requests from attacker-typo.example to their API, all returning 200, all from inside a single user’s browser. Investigation showed the user had visited attacker-typo.example (a phishing copy of an unrelated SaaS), and the page had silently fired requests at every API the user happened to be logged into. Theirs was one of them. CORS reflected the origin, credentials were on, the user’s cookies got sent, the API responded.
The cleanup: the team tightened CORS to an explicit allowlist and added SameSite=Strict to the auth cookie. Both fixes were small. The puzzle was understanding why their CORS was permissive in the first place — none of the team had set it that way deliberately. Git blame led to a Cursor session three months earlier where someone asked “the cookies aren’t propagating to the API in dev, can you fix CORS?” Cursor’s fix: cors({ origin: true, credentials: true }). It made dev work. It also broke the security model.
The takeaway: any time you ask AI to “fix CORS errors,” the AI’s path of least resistance is to make CORS more permissive. Audit that diff specifically. The right fix is almost always to add the dev origin to an allowlist, not to flip to origin: true.
What the right CORS config looks like at scale
Most production apps have a small, fixed set of trusted origins. The configuration should be similarly small and fixed:
const allowedOrigins = [
'https://app.example.com', // production frontend
'https://staging.example.com', // staging frontend
...(process.env.NODE_ENV === 'development'
? ['http://localhost:3000', 'http://localhost:5173']
: []),
]
app.use(cors({
origin: (origin, cb) => {
// Same-origin requests have no Origin header — allow
if (!origin) return cb(null, true)
if (allowedOrigins.includes(origin)) return cb(null, true)
cb(new Error(`CORS blocked: ${origin}`))
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Request-Id'],
maxAge: 600,
}))
The pattern that doesn’t work: a regex match against *.example.com. Subdomain takeover (covered in /patterns/s3-and-subdomain-takeover/) gives an attacker a subdomain that matches your regex. Wildcard matches CORS-allow are a known anti-pattern; if you absolutely need them, audit your DNS quarterly.
CSRF in 2026 — what’s still required
The browser default for SameSite is now Lax, which kills most CSRF on its own. But there are gaps:
- Cookies that need to cross sites for OAuth or analytics still get set with
SameSite=None, which restores the old CSRF surface. - Top-level GETs that mutate state (still a thing in some apps — e.g.,
/unsubscribe?token=...) are vulnerable to CSRF even withSameSite=Laxbecause Lax allows top-level navigations. - Subdomain-scoped cookies can be set by any subdomain, which becomes a CSRF vector if you have a less-trusted subdomain.
The right defaults today: HttpOnly + Secure + SameSite=Strict for auth cookies. Reserve SameSite=None for cookies you specifically need cross-site (OAuth dance, embedded widgets), and tighten anything else.
For state-changing operations specifically: don’t accept GETs that mutate. Use POST + a CSRF token (double-submit or sync token) for any non-idempotent action. Modern frameworks make this easy; AI codegen sometimes skips it because the boilerplate works without it in dev.
Cross-stack notes
- Express + cors: The default of
cors()with no options setsAccess-Control-Allow-Origin: *and disables credentials — safe for public APIs but breaks cookie-based auth. The “fix it” path leads toorigin: true, credentials: true, which is the bug. - Next.js (App Router): Per-route handlers set headers manually. AI codegen frequently writes
headers().set('access-control-allow-origin', request.headers.get('origin'))— origin reflection, the unsafe pattern. - Django + django-cors-headers:
CORS_ALLOWED_ORIGINSis the safe pattern.CORS_ALLOW_ALL_ORIGINS = TruewithCORS_ALLOW_CREDENTIALS = Trueis the unsafe combination — Django warns in dev, not always loudly enough. - Rails + rack-cors: Same shape. The
origins '*'withcredentials: truecombination is what to grep for. - Go (gorilla/handlers, rs/cors):
AllowedOrigins([]string{"*"})withAllowCredentials()is the unsafe combo.
How we detect it
This is one of the cheapest detections we run. We send an OPTIONS preflight to your API with Origin: https://attacker.example. We read the response headers. If Access-Control-Allow-Origin echoes our hostile origin and Access-Control-Allow-Credentials: true is set, the bug is real.
We also fetch your homepage and read the Set-Cookie headers. If the auth cookie has SameSite=None or no SameSite attribute and an overbroad Domain value, that’s a complementary finding.
The security-headers-checker does both of these and a few more in one pass.
Fix
For CORS:
import cors from 'cors'
const allowedOrigins = ['https://app.your-site.com', 'https://your-site.com']
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) return cb(null, true)
cb(new Error('Not allowed by CORS'))
},
credentials: true,
}))
If you don’t need cookies on cross-origin requests at all, set credentials: false. The wildcard origin with credentials false is safe — it just means cookies don’t carry, which is fine for public APIs.
For cookies:
HttpOnlyalways.Securealways.SameSite=Strictfor auth cookies.Laxif you genuinely need top-level cross-site nav (post-login redirect from an OAuth provider, for instance).Domainset to the most specific value that works for you. Don’t use.your-site.comunless you actively share the cookie across subdomains.
For CSRF on top: if your auth uses cookies (not Authorization headers), add a CSRF token. Either double-submit (token in cookie + token in header, server compares them) or sync-token (server-rendered token in HTML, returned in header). Modern frameworks make this a one-line config; the AI sometimes skips it because the boilerplate happens to work without it in dev.
CWE / OWASP
- CWE-942 — Permissive Cross-domain Policy
- CWE-352 — Cross-Site Request Forgery
- CWE-346 — Origin Validation Error
- OWASP Top 10 — A05:2021 Security Misconfiguration, A07:2021 Identification and Authentication Failures
Reproduce it yourself
- CORS misconfig: https://gapbench.vibe-eval.com/site/cors-misconfig/
- CSRF missing: https://gapbench.vibe-eval.com/site/csrf-missing/
- Cookie scope leak: https://gapbench.vibe-eval.com/site/cookie-scope-leak/
- CSP missing: https://gapbench.vibe-eval.com/site/csp-missing/
Related reading
- Pattern: SSRF, open redirects, and OAuth redirect_uri
- Pattern: JWT alg=none is not dead
- Tool: security-headers-checker
COMMON QUESTIONS
TEST YOUR CORS AND CSRF POSTURE
We probe the response headers and the cookie attributes and tell you which combinations leave you exposed.