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 wildcard and the credentials flag assert opposite things The left panel lists what a wildcard Access-Control-Allow-Origin asserts about a response: identical for everyone, nothing session-bound, safe for any origin. The right panel lists what credentials mode asserts: cookies attached, body tied to a session, only named origins may read. Both feed a collision strip explaining the rejection. The two settings make contradictory claims about one and the same response Allow-Origin: * asserts this body is the same for every caller nothing in it is tied to a session so any origin may safely read it a claim about the resource credentials: include asserts attach this user's cookies and auth the body will be shaped by that session so only named origins may read it a claim about the caller One response cannot carry both claims, so the browser refuses the pair and blocks the read The request still reached the server, and any side effect it caused has already happened

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.

What an asterisk means in each header, with and without credentials Four response headers are listed against two request modes. Without credentials the asterisk behaves as a wildcard in all four. With credentials included it is compared literally, so Allow-Origin is rejected outright and the other three match only a field actually named with an asterisk. Response header carrying an asterisk request without credentials (omit or same-origin) request with credentials (include) Access-Control-Allow-Origin any origin may read the body the browser rejects the pair; an exact origin is required Access-Control-Allow-Headers every asked header is allowed matches only a header whose name is literally an asterisk Access-Control-Allow-Methods every method is allowed matches only a method whose name is literally an asterisk Access-Control-Expose-Headers all response headers exposed exposes only a header whose name is literally an asterisk Under credentials mode the asterisk stops being a wildcard everywhere, not only in Allow-Origin

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

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.

Choosing a way out of the wildcard and credentials conflict A root question asks whether the endpoint needs the caller's cookies. The no branch keeps the wildcard and removes the credential flag. The yes branch echoes one allowlisted origin with credentials allowed, and if the origin list cannot be enumerated it leads to serving the API under the same origin instead. Does this endpoint need the caller's cookies or auth header? no yes Keep Allow-Origin as a wildcard, drop credentials from the client and serve public data only Echo one allowlisted origin, set Allow-Credentials to true and always send Vary: Origin If the list cannot be enumerated, serve the API under the same origin behind a path prefix Neither branch is a compromise. A wildcard is not unsafe by itself; it only conflicts with credentials. The single combination the browser will never accept is a wildcard together with credentials

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.