Why Wildcard and Credentials Cannot Combine
The request goes out, the server answers, and the browser throws the answer away:
Access to XMLHttpRequest at 'https://api.orbitpay.dev/v2/balance' from origin
'https://console.orbitpay.dev' has been blocked by CORS policy: The value of the
'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
when the request's credentials mode is 'include'.
Nothing is misconfigured in the ordinary sense. Access-Control-Allow-Origin: * is valid, credentials: "include" is valid, and each works perfectly on its own. The browser is refusing the pair, and understanding why saves you from the far worse fix people usually reach for.
Root Cause
The two settings make claims about the same response that cannot both be true.
A wildcard is a statement about the resource: this body is identical for every caller and contains nothing that depends on who is asking, which is why it is safe to hand to any origin. Credentials mode include is a statement about the caller: attach this user’s cookies and authorization to the request, which means the response almost certainly does depend on who is asking. The Fetch Standard resolves the contradiction in the browser’s CORS check: when credentials mode is include, a wildcard Access-Control-Allow-Origin is not accepted, and the response is withheld from script.
The rule also protects against a server that is simply unaware. Cookies are ambient authority: the browser attaches them because of the destination, not because the calling page proved anything. A backend that sets * in a static configuration block has no way of knowing that a particular request arrived with a session cookie, so the specification does not ask it to know — it makes the wildcard and credentials mutually exclusive at the browser, where both facts are visible. This page belongs to Credential Sharing & Security Boundaries in CORS, which covers the wider credential model; here we stay on the wildcard conflict and the ways out of it.
The prohibition is broader than the one header the console names. Under credentials mode the asterisk stops functioning as a wildcard anywhere in the CORS response headers and is compared as an ordinary literal string.
This is why a team that fixes only Access-Control-Allow-Origin often lands on a second, more cryptic failure: the preflight now passes the origin gate and fails the header gate, because Access-Control-Allow-Headers: * no longer covers Authorization. Every list has to be enumerated.
Prerequisite State
- A browser page on
https://console.orbitpay.devcallinghttps://api.orbitpay.devwithcredentials: "include"orwithCredentials = true. - The API currently returns
Access-Control-Allow-Origin: *, from the application, a proxy, or both. - A known, finite set of origins that must reach the API. If you cannot enumerate them, the third path in the decision below applies instead.
- Session cookies already configured with
SameSite=None; Secure, without which the browser will not attach them cross-origin at all — see SameSite=None vs CORS Credentials: The Tradeoffs.
Step-by-Step Fix
Step 1 — Establish which side actually wants credentials
Before changing any server config, check whether the credentialed mode is deliberate. A single shared HTTP client often turns it on globally for a call that never needed a cookie.
// Explicit and intentional: this call needs the session cookie
const res = await fetch("https://api.orbitpay.dev/v2/balance", {
credentials: "include",
});
// Accidental: an axios default applied to every call in the app
axios.defaults.withCredentials = true;
If the endpoint returns public data, delete the credential flag on that one call and the wildcard becomes legal again — the fastest fix available, and the only one that requires no server change at all.
Step 2 — Replace the wildcard with a checked echo
When credentials genuinely are required, the wildcard has to go. Echo a value only after it survives an exact-match test against a fixed set. This Go middleware does both, and short-circuits the preflight with a 204:
package main
import (
"net/http"
)
var allowedOrigins = map[string]bool{
"https://console.orbitpay.dev": true,
"https://ops.orbitpay.dev": true,
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Always advertise that the response varies by origin, hit or miss
w.Header().Add("Vary", "Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Idempotency-Key")
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id, X-Rate-Limit-Remaining")
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/v2/balance", balanceHandler)
http.ListenAndServe(":8080", withCORS(mux))
}
Three properties of this middleware matter more than the syntax. The map lookup is an exact string comparison, so no prefix or suffix trick lets console.orbitpay.dev.attacker.test through. Vary: Origin is added on every path, including rejections, so a shared cache can never serve one origin’s headers to another. And the four list-valued headers are enumerated by name, because the asterisk would now be read literally. Broader guidance on the matching logic itself lives in Dynamic Origin Validation Patterns.
Step 3 — Apply the same policy at the edge
If a reverse proxy or CDN in front of the service adds its own headers, the application-level fix can be undone downstream — two Access-Control-Allow-Origin values, or a wildcard re-added on top of your echo. Keep exactly one layer authoritative. A Caddy front end doing it at the edge looks like this:
api.orbitpay.dev {
@allowed header_regexp origin Origin ^https://(console|ops)\.orbitpay\.dev$
handle @allowed {
header Access-Control-Allow-Origin "{http.request.header.Origin}"
header Access-Control-Allow-Credentials "true"
header Access-Control-Allow-Headers "Authorization, Content-Type, X-Idempotency-Key"
header Access-Control-Allow-Methods "GET, POST, PATCH, DELETE, OPTIONS"
header Vary "Origin"
@preflight method OPTIONS
respond @preflight 204
reverse_proxy 127.0.0.1:8080
}
handle {
reverse_proxy 127.0.0.1:8080
}
}
The regular expression is anchored at both ends, which is the whole security of the arrangement — an unanchored pattern would match https://console.orbitpay.dev.attacker.test and reintroduce the vulnerability the allowlist exists to prevent. If the application also emits CORS headers, strip them here or disable them there; duplicates are their own failure mode, covered in Fixing Duplicate Access-Control-Allow-Origin Headers.
Step 4 — Choose the right path when the allowlist will not hold
Not every API can enumerate its callers. If yours is a public developer platform with thousands of customer domains, an allowlist is unmaintainable and reflecting every origin is unacceptable. The decision has three endpoints, not two.
The third path removes CORS from the picture entirely: route https://console.orbitpay.dev/api/* through the same origin as the page, and proxy it to the API internally. The browser sees a same-origin request, cookies flow with no SameSite=None requirement, and no allowlist has to be maintained. The cost is an extra network hop and a proxy to operate.
Verification
Two probes, one for each half of the rule. First, confirm the wildcard is gone and the echo is exact:
curl -sD - -o /dev/null https://api.orbitpay.dev/v2/balance \
-H 'Origin: https://console.orbitpay.dev' \
| grep -Ei 'access-control-allow-(origin|credentials)|vary'
Expect access-control-allow-origin: https://console.orbitpay.dev, access-control-allow-credentials: true and vary: Origin — never an asterisk. Second, confirm an unlisted origin gets nothing:
curl -sD - -o /dev/null https://api.orbitpay.dev/v2/balance \
-H 'Origin: https://console.orbitpay.dev.attacker.test' \
| grep -i 'access-control-allow-origin'
Expect no output at all. Then finish in the browser:
Security Boundary Note
The dangerous move at this point is to satisfy the rule by reflecting whatever Origin arrives. Access-Control-Allow-Origin: <echoed> plus Access-Control-Allow-Credentials: true with no allowlist is strictly worse than the wildcard you started with: the wildcard at least could not be combined with credentials, while unconditional reflection grants every website on the internet authenticated read access to your API. Any page a logged-in user visits can then read their balance. Reflect only after an exact comparison against a fixed set, keep Vary: Origin on every response so no shared cache blurs the boundary, and treat the allowlist as security-relevant configuration with the review process to match. The trade-offs between the two approaches are laid out in Wildcard vs Dynamic Origin Reflection: When to Use Each.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Replacing the wildcard with unconditional reflection of Origin |
Every origin on the internet gains credentialed read access to authenticated responses | Echo only a value that matched an entry in a fixed allowlist exactly |
Fixing Access-Control-Allow-Origin but leaving Access-Control-Allow-Headers: * |
The preflight now fails at the header gate instead, because the asterisk is compared literally under credentials mode | Enumerate every allowed header, method and exposed header by name |
Echoing an origin without Vary: Origin |
A shared cache stores one origin’s response and serves it to another, leaking or blocking at random | Add Vary: Origin on every response, including the ones with no allow header |
Setting Access-Control-Allow-Credentials: true on the preflight only |
The preflight passes and the real response is still blocked, because the credential flag is checked on both | Emit both credential headers on the preflight and the actual response |
FAQ
Did the server still process my request even though the response was blocked?
Yes. The check happens in the browser when the response arrives, long after the request was sent and handled. Any write, charge or state change the endpoint performs has already happened; only the reading of the response is prevented. That is why a blocked cross-origin call can still create a duplicate record, and why idempotency keys matter on endpoints you expect to fail this way during development.
Can I echo the request Origin back for every caller to satisfy the rule?
Technically yes, and it is far more dangerous than the wildcard you started with. Reflecting any origin plus Access-Control-Allow-Credentials: true grants every website on the internet authenticated read access to your API, which is exactly what the wildcard rule exists to prevent. Only echo a value that has passed an exact-match check against a fixed allowlist, and add Vary: Origin so no cache can hand one origin’s response to another.
Why is Access-Control-Allow-Headers with an asterisk also affected?
Because under credentials mode the specification stops treating the asterisk as a wildcard anywhere in the CORS response headers and compares it as an ordinary literal string. Allow-Headers with an asterisk then matches only a request header actually named with an asterisk, so a perfectly normal Authorization header fails the preflight header check. Enumerate every header, method and exposed header by name once credentials are in play.