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 null origin 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.

Why a Missing Vary Header Reintroduces the Problem A shared cache keyed without the origin can serve a response carrying one origin allowance to a different origin. The effect is intermittent and depends on cache state, which makes it painful to reproduce and easy to dismiss as a client bug — while it is functionally the same disclosure the allowlist was written to prevent. Cache stores one response for all origins the sharing header inside it names whichever origin populated the cache A different origin receives that cached response with an allowance it was never meant to have The symptom is intermittent depends on cache state, so it looks like a flaky client rather than a policy bug Vary: Origin on every response carrying a sharing header one line, and the cache keys correctly Three Configurations, Three Very Different Meanings A wildcard without credentials says the data is public and no cookies are sent, which is honest for genuinely public endpoints. A reflected origin with credentials says any site may read authenticated responses, which disables the same-origin policy for the API. An exact allowlist with credentials says these named clients may read authenticated responses, which is what most teams intend. Wildcard, credentials off — "this data is public" no cookies are sent, so the response is the same for everyone; honest and correct for public endpoints Browsers refuse to combine a wildcard with credentials, which is the specification protecting you. Reflected origin, credentials on — "any site may read your users' data" the browser sends the user's cookies, the server echoes whatever origin asked, the attacker reads the reply This is the same-origin policy disabled for your API, expressed as two lines of configuration. Exact allowlist, credentials on — "these named clients may read authenticated responses" what almost every team actually means; costs one configuration list and a quarterly review

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.

What a Pattern Match Lets In Against an intended origin of app.example.com, a suffix match also admits a host registered as example.com.attacker.net and a host called notexample.com. A subdomain regular expression admits any subdomain, including a compromised marketing site. An exact allowlist admits only the enumerated entries. Intended: https://app.example.com — and nothing else Suffix match also admits: https://example.com.attacker.net — registered by anyone, in minutes Suffix match also admits: https://notexample.com — the string ends the same way Subdomain pattern admits: https://blog.example.com — your least-patched host, with API access And "null", if allowlisted, admits sandboxed iframes and local files — never allowlist it

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.