"Works on my machine, breaks in production" usually means an environment difference somewhere. For a CORS error specifically, it almost always means something about the browser's cross-origin security model that local development quietly sidesteps. This is the actual debugging session for one of those: a fetch request that worked flawlessly on localhost and failed with a CORS error the moment it hit the production domain, with the real preflight request and response headers that explained why.
The error message, and why it's a poor starting point
The browser console showed the usual unhelpful summary:
Access to fetch at 'https://api.example.com/user/preferences' from origin
'https://app.example.com' 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'.
Unlike most CORS errors, this one actually named the exact problem in the message itself — which is unusual and worth appreciating, because most CORS failures just say "blocked by CORS policy" with no further detail, forcing you into the network tab to find out why. Even with a specific message, it's worth verifying by actually looking at the real request and response rather than trusting the summary and guessing at a fix.
Why localhost never showed this
Locally, the frontend ran on localhost:3000 and the API on localhost:8000 — technically two different origins, so CORS was technically in play locally too. The difference: the local API's CORS config had credentials: 'include' handling disabled during local development (nobody was testing authenticated requests against the local API directly, everything used a mocked auth token), so the specific combination of wildcard origin plus credentials mode never actually got exercised until the real, authenticated production frontend made a real, authenticated request against the real API.
Reading the actual preflight request in the network tab
Before touching any code, the network tab's OPTIONS request — the preflight browsers send automatically before certain cross-origin requests — showed exactly what the browser asked for and what the server answered:
OPTIONS /user/preferences HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
The preflight itself succeeded — a 204 with permissive-looking headers. The actual failure happens one step later: the browser sends the real GET request with credentials attached, and per the CORS spec, a response can never combine a wildcard Access-Control-Allow-Origin: * with a request that has credentials included, full stop, regardless of what the preflight response said. This is a deliberate browser security rule, not a bug — allowing a wildcard origin to also receive cookies or auth headers would defeat the entire point of the same-origin policy for any authenticated endpoint.
The actual server config causing it
class CorsMiddleware
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
return $response
->header('Access-Control-Allow-Origin', '*')
->header('Access-Control-Allow-Credentials', 'true')
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
->header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
}
Two lines here directly contradict each other from the spec's point of view: Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true together are never valid for a credentialed request, and different browsers handle the contradiction slightly differently — some fail the request outright with the clear message seen above, which is actually the more helpful behavior, since a silent failure would have been far harder to diagnose.
The fix: echo back a specific, validated origin instead of a wildcard
class CorsMiddleware
{
private const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://staging-app.example.com',
];
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$origin = $request->header('Origin');
if (in_array($origin, self::ALLOWED_ORIGINS, true)) {
$response->header('Access-Control-Allow-Origin', $origin);
$response->header('Access-Control-Allow-Credentials', 'true');
}
return $response
->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
->header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
}
Rather than a wildcard, the server now checks the incoming request's Origin header against an explicit allowlist and echoes that exact origin back — which is valid alongside Access-Control-Allow-Credentials: true, because the response is now scoped to one specific, verified origin rather than claiming to allow every origin in the world. This is the standard fix for this exact error, and it's also more secure than the wildcard version was, not just spec-compliant — a wildcard origin on a credentialed endpoint (even setting aside that browsers reject it) is a meaningfully looser security posture than an explicit allowlist.
Why the browser enforces this instead of just warning about it
It's worth understanding why this is a hard browser rule rather than a configurable warning, because it clarifies why there's no "just allow it anyway" flag to reach for. A wildcard origin combined with credentials would mean any website on the internet — not just your own frontend — could make a credentialed request to your API and have the browser automatically attach the visitor's cookies or stored auth headers for your domain, then read the response back, because the wildcard told the browser every origin was acceptable. That's exactly the cross-site request forgery and data-leak scenario the same-origin policy exists to prevent in the first place, and allowing it for credentialed requests specifically — the requests that carry a user's actual identity — would defeat the point of the restriction entirely. The browser isn't being overly cautious here; it's refusing a configuration that has no safe interpretation.
A second, quieter issue the fix also exposed
Testing the fix against staging surfaced a second problem: staging's frontend runs on a dynamically generated preview URL per branch (something like pr-482.preview.example.com), which an exact-match allowlist can't account for without listing every possible preview URL in advance. The fix there was a pattern match against a known suffix rather than an exact list entry:
private function isAllowedOrigin(?string $origin): bool
{
if ($origin === null) {
return false;
}
if (in_array($origin, self::ALLOWED_ORIGINS, true)) {
return true;
}
// Allow any *.preview.example.com subdomain for PR previews
return (bool) preg_match('/^https:\/\/[a-z0-9\-]+\.preview\.example\.com$/', $origin);
}
This is worth calling out because it's a common next question after fixing the exact-match version: an allowlist is the right instinct, but real deployment setups with dynamic preview environments need a pattern, not just a fixed list, and it's worth deciding that upfront rather than discovering it the first time a PR preview breaks in exactly the same way the original bug did.
Adding a regression test so this can't come back silently
Once fixed, a manual test alone doesn't protect against a future regression — a well-meaning refactor of the CORS middleware, months from now, could easily reintroduce a wildcard "just to simplify things" without anyone remembering why the allowlist approach mattered. A feature test asserting the actual header behavior against both an allowed and a disallowed origin closes that gap:
public function test_allowed_origin_gets_echoed_back_with_credentials(): void
{
$response = $this->withHeaders(['Origin' => 'https://app.example.com'])
->get('/user/preferences');
$response->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com');
$response->assertHeader('Access-Control-Allow-Credentials', 'true');
}
public function test_disallowed_origin_gets_no_cors_headers(): void
{
$response = $this->withHeaders(['Origin' => 'https://evil-example.com'])
->get('/user/preferences');
$response->assertHeaderMissing('Access-Control-Allow-Origin');
}
Neither test needs a real browser or a real preflight request to be meaningful — they're asserting on the exact response headers a browser would actually check, which is enough to catch a regression in the allowlist logic itself without needing full end-to-end browser automation for what's fundamentally a server-side header decision.
Checking your own CORS headers before shipping a fix
Before trusting that a CORS fix actually works, it's worth constructing the exact request headers a real browser would send and verifying the response by hand — a mismatched character or unexpected encoding in an Origin header value is a real, if rare, source of allowlist mismatches, and running suspicious header values through a proper URL encoder/decoder rules out an encoding mismatch before assuming the allowlist logic itself is wrong. It's a five-second check that's saved real debugging time on more than one CORS issue that initially looked like a logic bug and turned out to be a stray character in a copy-pasted origin value.
Why "it works locally" is worth distrusting by default for this class of bug
The broader pattern here shows up well beyond CORS specifically: any bug rooted in a security boundary — CORS, cookie SameSite behavior, mixed-content blocking, credentialed fetch handling — tends to depend on conditions local development quietly doesn't replicate, whether that's a different origin setup, HTTP versus HTTPS, or (as here) whether credentials are actually being exercised in a real authenticated flow rather than a mocked one. The fix for that gap isn't remembering to test every security-related edge case by hand — it's making local development mirror production's actual origin and auth setup as closely as practical, so these bugs surface before a real deploy rather than after one. Debugging skills transfer well between server-side and browser-side issues, too — the same "read the actual data at the point of failure instead of guessing" instinct that found the pagination bug in a real Xdebug session is exactly what the network tab did here, just for a browser-side security boundary instead of server-side application logic.
The general lesson
- Wildcard origins and credentials never mix, per spec, in every standards-compliant browser — this isn't a server bug to work around, it's a hard rule to design for from the start.
- Local development often doesn't exercise the exact cross-origin, credentialed request path production does, so a CORS bug specifically tied to credentials can hide indefinitely in local testing.
- Read the actual preflight and real request in the network tab before guessing at a fix — the specific combination of headers involved usually points directly at the actual rule being violated.
- An origin allowlist beats a wildcard for any endpoint that also needs credentials, and it's a genuine security improvement, not just a workaround for a browser restriction.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.