CORS Misconfiguration in Node.js and Next.js: Why Reflecting the Origin Breaks Your API [2026]
CORS Misconfiguration Is Invisible in Development
Most API security bugs break something. CORS misconfiguration breaks nothing you can see. The frontend calls the API, the browser is happy, your integration tests pass, and the vulnerability sits there quietly: arbitrary third-party websites can read your users' authenticated API responses — profile data, invoices, tokens, private messages — using the session cookies the browser attaches automatically.
It never shows up in your own testing because your own origin is always allowed. It shows up in a bug bounty report, or in a log line you never read, when someone points an evil origin at your API with the victim's browser as the proxy.
This article covers the five patterns that produce it, the two facts about CORS that most teams get wrong, and the exact-match allowlist that closes the class.
What CORS Actually Protects — and What It Does Not
Three things to internalize before the code:
- CORS is enforced by the browser, and only the browser.
curl, a Python script, a mobile app, and your competitors' servers ignore it completely. A permissive CORS policy is not access control — it is an instruction to browsers about which sites may read responses. Access-Control-Allow-Originis a read boundary, not an execution boundary. When a request crosses the wire, your server already processed it and performed its side effects. CORS only decides whether the calling page's JavaScript is allowed to see the response. Blocking the read never un-does a state change.- Simple requests skip the preflight. A
GET, or aPOSTwithContent-Type: text/plain,application/x-www-form-urlencoded, ormultipart/form-dataand no custom headers, is sent straight to your server with the user's cookies. NoOPTIONSrequest, no CORS check before the write happens.
That third fact is why CORS is not CSRF protection, and the second is why CORS is not authentication. Keep both in mind while reading the misconfigurations below.
The Five Patterns That Cause It
1. Reflecting any origin while sending credentials
This is the catastrophic one, and it is one line:
import cors from 'cors';
import express from 'express';
const app = express();
// origin: true reflects whatever Origin the caller sent; credentials: true
// tells the browser to include cookies and to hand the response body to the page.
app.use(cors({ origin: true, credentials: true }));
Now https://evil.com sends a fetch to your API from a logged-in victim's browser, gets Access-Control-Allow-Origin: https://evil.com back with Access-Control-Allow-Credentials: true, and reads the full response. Note that browsers reject Access-Control-Allow-Origin: * together with credentials — developers often "fix" that rejection by switching to reflection, which is strictly worse because it turns a blocked request into a successful data leak.
2. Substring and unanchored allowlists
Treating the origin as a string contains a substring check is the second most common finding:
const ALLOWED = ['https://app.example.com'];
app.use(cors({
origin(origin, callback) {
// 'https://app.example.com' — but also every one of these:
// https://app.example.com.evil.com (startsWith passes)
// notexample.com (endsWith('.example.com') passes)
// https://evil.com/?x=.example.com (includes() passes)
if (!origin || ALLOWED.some((a) => origin.startsWith(a))) {
return callback(null, true);
}
callback(new Error('Not allowed'));
},
credentials: true,
}));
The same bug appears with regex that is not anchored or not escaped: /example\.com/ matches https://example.com.attacker.net, and a regex against user text is also a ReDoS surface. Never validate an origin with startsWith, includes, endsWith, split, or an unanchored regex.
3. Trusting the null origin
Sandboxed iframes, file:// pages, and some redirect flows send Origin: null. If null is on your allowlist — usually added to "make local testing work" — an attacker only has to render a sandboxed iframe on any page they control, plus sandbox="allow-scripts", and the browser will send Origin: null with the victim's cookies and let the attack page read the response. null must never be allowed in production.
4. Wildcard subdomains with credentials
// Every subdomain is now a fully privileged origin for your API.
const SUBDOMAIN = /^https:\/\/[a-z0-9-]+\.example\.com$/;
app.use(cors({ origin: (o, cb) => cb(null, !!o && SUBDOMAIN.test(o)), credentials: true }));
This looks tidy and collapses your security model to "the weakest subdomain." A stored XSS on a marketing site, a preview deploy host, a stale staging subdomain, or a dangling DNS record pointing at a deprovisioned SaaS (subdomain takeover) becomes an authenticated cross-origin read of your API. Allowlist exact origins, not origin shapes, unless you genuinely control and monitor every subdomain — in which case you still need an inventory and takeover monitoring.
5. Shipping the development config to production
http://localhost:3000 and http://localhost:5173 left in the production allowlist, * combined with credentials: true, or an allowlist driven by a .env variable that is empty in prod — and an empty allowlist that means "allow everything" instead of "allow nothing." Deny-by-default has to be the code path, not the fallback.
The Correct Pattern: Exact Match, Deny by Default, Vary
import type { RequestHandler } from 'express';
const ALLOWED_ORIGINS = new Set(
(process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
);
export const corsMiddleware: RequestHandler = (req, res, next) => {
const origin = req.headers.origin;
// The response body/headers differ per origin: caches must key on it.
res.setHeader('Vary', 'Origin');
// Exact string match on the full origin (scheme + host + port). No substring,
// no regex, no case-insensitive comparison.
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Max-Age', '600');
}
// Unknown origin: emit no CORS headers at all. The browser blocks the read,
// the server still processed the request, and nothing was widened.
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
next();
};
Five details that matter:
Set.hason the verbatim origin. An origin isscheme://host:port. Compare it exactly, from configuration, and make the configuration exhaustive rather than clever.- Deny by default means silence. For an unknown origin, emit no
Access-Control-Allow-Originheader. Do not reflect it, do not fall back to*, and do not throw a 500 from the CORS callback — some libraries do, which converts a blocked read into an error fingerprint of your stack. - Never echo
Access-Control-Request-HeadersorAccess-Control-Request-Methodback. Hardcode the lists you actually support; echoing them is a reflection sink with the same shape as origin reflection. Vary: Originis mandatory the moment the ACAO header is dynamic and anything between the user and your app caches (CDN, reverse proxy, edge runtime). Without it a cache can store the permissive header computed for one origin and hand it to a different one — CORS policy poisoning, delivered by your own infrastructure.Access-Control-Max-Agetrades correctness risk for latency. A long max-age means a browser keeps honoring an origin you later removed from the allowlist. Ten minutes is a reasonable default; a day is not.
CORS Is Not CSRF Protection
The most expensive misconception: "our API has CORS configured, so we are protected against cross-site requests." You are not. Because simple requests are sent without a preflight (fact three above), a malicious page can POST a form-encoded body to your cookie-authenticated endpoint and the state change happens — the attacker just cannot read the response. For write endpoints that is often all that matters.
So keep the real defenses on cookie-authenticated, state-changing routes: SameSite=Lax (or Strict) on session cookies, plus CSRF tokens (double-submit or server-side, bound to the session) on anything that mutates state. CORS narrows the read surface; it does not authenticate the writer. See our authentication and session management guide for the cookie flags and token patterns that go with this.
And never make server-side authorization depend on CORS. If your "internal API" is only protected by a restrictive CORS policy, every non-browser caller — a partner's backend, a leaked script, curl — already has full access. Authenticate server-side callers with API keys or OAuth client credentials, and authorize them server-side, exactly as you do for browser users.
The Next.js Specifics
next.config.jsheaders()is static. It cannot compute a per-request origin allowlist. If you need a dynamic allowlist, do it inmiddleware.tsor in the route handler — not in static config.- Middleware covers route handlers and API routes. A dynamic allowlist in
middleware.tsis a legitimate single choke point, as long as it runs before the handler and applies to every/api/*path. - Do not set
Access-Control-Allow-Origin: *on/api/*in a NextAuth/session-cookie app. That is the wildcard-with-cookies bug with a framework label on it. - Server Components and Server Actions are not CORS-relevant — they run on the server and are invoked by Next.js internals, not by cross-origin browser JS. Their protection is authentication and CSRF/origin checks for actions, not ACAO headers.
Verify It in Five Minutes
# A rogue origin must NOT receive an Access-Control-Allow-Origin header.
curl -sI -H 'Origin: https://evil.com' https://api.example.com/api/me \
| grep -i '^access-control'
# Your real frontend must receive exactly its own origin back.
curl -sI -H 'Origin: https://app.example.com' https://api.example.com/api/me \
| grep -i '^access-control'
Then make it permanent: a test in CI that asserts a rogue origin gets no ACAO header, alongside a test for the allowed origin. CORS regressions are silent, so the assertion is the only thing that catches them.
The Startup Checklist
- [ ] No
origin: true, no origin reflection, and no*combined withcredentials: trueanywhere in the codebase. - [ ] The origin allowlist is an exact match on scheme + host + port, loaded from configuration, never built from user input.
- [ ] Unknown origin → no CORS headers emitted at all (deny by default), and never a 500 from the CORS callback.
- [ ]
nullorigin is not allowed;localhostand dev origins are stripped from production config. - [ ]
Vary: Originis set on every response whose ACAO header is dynamic; CDN/edge cache keys includeOrigin. - [ ] No wildcard-subdomain allowlists with credentials; subdomains are inventoried and dangling DNS records are monitored for takeover.
- [ ] Cookie-authenticated state-changing routes have
SameSitecookies plus CSRF tokens; CORS is not treated as CSRF protection. - [ ] Server-side authorization does not depend on CORS; API keys or OAuth protect non-browser callers.
- [ ]
Access-Control-Request-Headersand-Methodare never echoed; supported headers and methods are hardcoded. - [ ] A CI test asserts that a rogue origin receives no
Access-Control-Allow-Originheader.
What to Do This Week
Grep the codebase for cors(, origin:, Access-Control-Allow-Origin, credentials: true, and any allowlist built with startsWith/includes/endsWith or an unanchored regex. Every hit is a candidate finding. Then run the two curl commands above against production with a rogue origin — the second time you do it, wire it into CI. CORS misconfiguration never breaks your own application; it only exposes your users, which is precisely why it survives so long in production.
Want a professional review of your API's CORS policy, cookie flags, and CSRF defenses? Schedule a security audit — we test your endpoints from an attacker's origin, exactly the way a bug bounty hunter would.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.