CORS Misconfiguration and Credentialed Requests
Cross-origin resource sharing is a relaxation of the same-origin policy, and every configuration decision is a decision about how much of that policy to give up. The failure that matters is specific and common: reflecting the request’s own Origin header while allowing credentials. That combination tells the browser that any site the user visits may read authenticated responses from your API — which is the same-origin policy switched off, delivered by a header that looks like configuration.
This guide covers exact allowlisting, correct preflight handling, the traps around the null origin and subdomain matching, and tests that prove the refusals actually happen. It is part of the Secure HTTP Header Configuration guide within Vulnerability Patterns & Web Mitigation Strategies, and it pairs with CSRF defense, which handles the requests this policy does not stop.
Prerequisites
- A list of the client origins that genuinely need cross-origin access, with scheme, host and port
- Control over the response headers, in the application or at the edge — but in exactly one of the two
- A session or token mechanism whose behaviour under cross-origin requests you understand
- A test client able to send arbitrary origins, which any HTTP tool can do
Expected Outcomes
- An exact-match allowlist, with no reflection and no pattern matching
- Credentials enabled only for origins that genuinely need them
- Preflight responses naming specific methods and headers, with a bounded cache
- The
nullorigin refused, and tests proving each refusal
Step 1: Compare Exactly, Echo From the List
// Configuration, not code: reviewable, environment-specific, greppable.
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
'https://partner.example.net', // enumerated deliberately, reviewed quarterly
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
// Exact membership test. No suffix match, no regular expression, no reflection.
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // a value FROM the list
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // or caches will mix responses
}
// No else branch: an unknown origin simply gets no sharing headers, and the browser refuses.
next();
});
The Vary header is not optional. Without it, a shared cache can serve a response carrying one origin’s sharing header to a different origin, which reintroduces the very problem the allowlist prevents — and it does so intermittently, which makes it painful to diagnose.
Step 2: Answer Preflight Deliberately
app.options('*', (req, res) => {
const origin = req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.has(origin)) return res.status(403).end();
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE'); // named, not reflected
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token'); // named, not reflected
res.setHeader('Access-Control-Max-Age', '600'); // 10 minutes, not a day
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers');
res.status(204).end();
});
Reflecting the requested headers is the same mistake as reflecting the origin, one level down: it lets a caller declare which headers are permitted. Name them. Keep the cache duration modest as well — a long-lived preflight cache means a tightened policy takes effect only after browsers forget the old answer, which is exactly the wrong behaviour during an incident.
Step 3: Refuse the null Origin and Resist Pattern Matching
// Two rules that catch the remaining common mistakes.
if (origin === 'null') return next(); // never allowlist null — sandboxed docs and file URLs present it
// Anti-pattern: suffix matching.
// if (origin.endsWith('example.com')) { … } // also matches https://notexample.com
// and https://example.com.attacker.net
// Anti-pattern: a regular expression with an unescaped dot.
// if (/^https:\/\/.*\.example\.com$/.test(origin)) { … } // matches evil.example.com if a
// subdomain is ever compromised
Suffix and pattern matching fail in two directions at once: they match hostnames an attacker can register, and they hand access to every subdomain including the marketing site running an aging content system. Enumerate the origins you mean. If the list becomes unmanageable, that is a fact about your architecture worth confronting rather than a reason to loosen the comparison.
Verification
API=https://api.example.com/v1/me
# 1. An allowlisted origin is echoed, with credentials permitted.
curl -sI -H 'Origin: https://app.example.com' "$API" | grep -i 'access-control-allow-'
# Expect: allow-origin: https://app.example.com, allow-credentials: true, and Vary: Origin
# 2. An arbitrary origin gets nothing back.
curl -sI -H 'Origin: https://evil.example' "$API" | grep -i 'access-control-allow-origin' \
&& echo "REFLECTED — fix this" || echo "no sharing header, as expected"
# 3. Near-miss hostnames are refused.
for O in https://example.com.attacker.net https://notexample.com https://app.example.com.evil.net null; do
echo -n "$O → "
curl -sI -H "Origin: $O" "$API" | grep -ci 'access-control-allow-origin'
done
# Expect 0 for every one.
# 4. Preflight names methods and headers rather than reflecting them.
curl -sI -X OPTIONS -H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: DELETE' \
-H 'Access-Control-Request-Headers: X-Made-Up-Header' "$API" | grep -i 'allow-headers'
# Expect the configured list, not X-Made-Up-Header.
Test three is the one worth automating, because reflection is usually introduced by a well-meaning change during a debugging session and nothing else will notice.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests work in one environment, fail in another | Origin list not environment-specific | Keep the list in configuration per environment, not in code |
| Intermittent cross-origin failures behind a cache | Vary: Origin missing |
Add it to every response carrying a sharing header |
| Preflight succeeds but the request fails | The actual request’s headers or method are outside the allowed list | Align the preflight response with what the client actually sends |
| Credentials not attached by the client | Client not configured to include them | Set the credentials mode on the client; the server header alone is not enough |
| Duplicate sharing headers | Both the edge and the application set them | Choose one layer and remove the other entirely |
| Policy tightened but browsers still use the old one | Long preflight cache duration | Reduce the maximum age, and expect a delay equal to the previous value |
Common Implementation Mistakes
Frequently Asked Questions
Why is reflecting the Origin header so dangerous?
Because with credentials enabled it grants every website the user visits read access to your authenticated responses. The browser attaches the user’s cookies, your server echoes back whatever origin asked, and the attacker’s page reads the reply as if it were your own client. That is the same-origin policy switched off for your API. Reflection is only tolerable when credentials are disabled and every response is genuinely public — and in that case a wildcard states the same thing more clearly.
Does CORS protect my API from being called?
No, and this confusion causes real bugs. The policy governs whether a browser permits a script to read a response; it does not prevent the request from being sent, and it means nothing at all to a server-side client, a mobile app, or a command-line tool. Never treat it as authorization. A state-changing request must be authorized on its own merits with anti-forgery controls, whatever the sharing policy says.
How should subdomains be handled?
Enumerate the ones you actually mean. Suffix matching is the single most common misconfiguration in this area: it admits hosts an attacker can register that merely end with your domain string, and it hands API access to every subdomain including the marketing site nobody has patched this year. If the resulting list feels unmanageably long, that is useful information about your architecture rather than an argument for a looser comparison.
Related
- Secure HTTP Header Configuration — the parent guide covering the full header set
- Cross-Site Request Forgery (CSRF) Defense — protecting the requests this policy does not stop
- Rolling Out CSP in Report-Only Mode — the same discipline applied to script sources
- Access Control & IDOR Prevention — the authorization that must hold regardless of origin