CORS: Same-Origin Policy, Preflight Requests, and the Wildcard-Plus-Credentials Trap
Understand the same-origin policy CORS exists to safely relax, walk through exactly when and why a browser sends an OPTIONS preflight request, configure Access-Control-Allow-Origin/Methods/Headers/Credentials correctly in PHP for a real API, and understand precisely why Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is forbidden by browsers -- and why the naive workarounds developers reach for are dangerous.
The same-origin policy is the browser's default, restrictive answer to a dangerous question: if a user is logged into their bank in one tab, should a completely unrelated malicious site open in another tab be able to read the bank's data using the user's own authenticated session? By default, no -- a script running on one origin cannot read the response of a request made to a different origin, full stop. CORS (Cross-Origin Resource Sharing) is the controlled, opt-in mechanism a server uses to say 'actually, I specifically permit THIS other origin to read my responses.' Getting it right means understanding what the browser checks and when; getting it wrong -- especially the wildcard-plus-credentials combination -- is one of the most common ways APIs end up more exposed than intended.
Same-Origin Policy: What CORS Is Relaxing
Two URLs share an origin only when their scheme, host, AND port all match exactly -- https://api.example.com and https://app.example.com are DIFFERENT origins despite sharing a parent domain, and https://example.com and http://example.com differ by scheme alone. By default, JavaScript running on one origin cannot read the response body of a fetch/XHR request it makes to a different origin, even though the browser DOES actually send the request (and a server without any auth check might well process it) -- the restriction is specifically about the calling script being able to READ the response, not about whether the request itself reaches the server.
This is an important nuance: same-origin policy is not primarily about stopping the request from happening (a plain <form> POST or an <img> tag has always been able to trigger a cross-origin request, which is exactly the vector CSRF protection separately exists for) -- it's about stopping malicious JavaScript on attacker.example from reading YOUR bank's API response and exfiltrating its contents, using the victim's own ambient authenticated session. CORS headers are how a server that WANTS to allow that cross-origin read explicitly opts in, for specific origins it trusts.
• Origin = scheme + host + port, all three must match exactly for two URLs to be same-origin. • Same-origin policy blocks a script from READING a cross-origin response -- it does not, by itself, block the request from being sent at all. • CORS headers are the server's explicit, opt-in way to grant a specific other origin permission to read its responses.
The Preflight Request: When and Why Browsers Send OPTIONS First
For a subset of cross-origin requests the browser considers 'non-simple' -- using a method other than GET/HEAD/POST, or a POST with a Content-Type other than the small allowlist (application/x-www-form-urlencoded, multipart/form-data, text/plain), or adding custom headers like Authorization or a custom X-... header -- the browser does not send the actual request first at all. It sends a PREFLIGHT request instead: an OPTIONS request to the same URL, carrying Access-Control-Request-Method and Access-Control-Request-Headers describing what the REAL request intends to do, and waits for the server's answer before ever sending the real request.
The server's OPTIONS response must answer with Access-Control-Allow-Origin (confirming this calling origin is permitted), Access-Control-Allow-Methods (confirming the intended method is allowed), and Access-Control-Allow-Headers (confirming the intended custom headers are allowed) -- only if all three check out does the browser proceed to send the actual request. This exists specifically so that a server can be asked 'would you allow this?' before any potentially state-changing request (a POST, a DELETE) is actually attempted cross-origin, giving the server a chance to say no before the real request's side effects could occur.
A common source of confusion: the preflight OPTIONS request typically carries no cookies/auth and expects a fast, cacheable, side-effect-free response -- your API route for OPTIONS should just answer the CORS question and return, not run any business logic or database queries. Access-Control-Max-Age lets the browser cache a preflight's answer for a given number of seconds, avoiding a repeated OPTIONS round-trip on every single subsequent request to the same endpoint.
• Preflight fires for: non-GET/HEAD/POST methods, POST with a non-simple Content-Type, or custom request headers (like Authorization). • The preflight is an OPTIONS request carrying Access-Control-Request-Method/Headers; the real request only fires if the server's answer permits it. • OPTIONS handlers should be fast and side-effect-free -- just answer the CORS question, never run real business logic there. • Access-Control-Max-Age caches the preflight's answer client-side, avoiding a repeated OPTIONS round-trip per request.
Configuring Origins, Credentials, Methods, and Headers Correctly
Access-Control-Allow-Origin must name the calling origin -- either a literal, exact origin (https://app.example.com) or, for genuinely public APIs with no cookie-based auth, the wildcard * meaning 'any origin may read this'. When multiple specific origins need to be allowed (a staging domain and a production domain, say), the correct pattern is NOT to send a comma-separated list in one header (the header only accepts a single value) -- it's to check the incoming Origin request header server-side against an explicit allowlist, and echo back exactly that one origin if it matches, varying the response per request.
Access-Control-Allow-Credentials: true tells the browser that cross-origin requests to this endpoint may include cookies/HTTP auth, AND that the calling script is allowed to read a response that resulted from such a credentialed request. This header must be paired with fetch()'s own credentials: 'include' option on the CALLING side too -- both the server opting in and the client explicitly requesting credentialed mode are required, which is itself a deliberate double opt-in against accidentally leaking authenticated cross-origin data.
Access-Control-Allow-Methods and Access-Control-Allow-Headers should be scoped as tightly as the API actually needs -- listing exactly the HTTP methods (GET, POST, PUT, DELETE) and exactly the custom headers (Authorization, X-Requested-With, Content-Type) the API genuinely uses, rather than reflexively allowing everything. A narrowly scoped allowlist here is defense in depth: it doesn't stop a determined attacker on its own, but it does mean a future accidental new endpoint doesn't inherit overly broad cross-origin permissions by default.
• Multiple allowed origins: check the incoming Origin header against a server-side allowlist and echo back the exact match -- never comma-separate multiple origins in one header value. • Access-Control-Allow-Credentials: true requires the CLIENT to also opt in with credentials: 'include' -- it's a deliberate double opt-in, not automatic. • Scope Access-Control-Allow-Methods/Headers to exactly what the API uses, not a blanket allow-everything. • Always send Vary: Origin alongside a per-origin echoed Access-Control-Allow-Origin, so caches don't serve one origin's CORS response to a different origin.
The Wildcard-Plus-Credentials Trap
Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is a combination browsers explicitly FORBID -- per the Fetch/CORS specification, a response with the wildcard origin can never also carry Allow-Credentials: true, and browsers will refuse to expose the response to the calling script if a server sends both, even though the server technically sent both headers. The reasoning is direct: '*' means 'I trust literally any origin to read this', and combining that with 'and you may send the user's cookies along too' would mean ANY malicious site on the internet could make a credentialed request to this API using the victim's own session and read the response -- exactly the cross-origin credential leak the same-origin policy exists to prevent, opted into by accident.
The failure developers actually hit in practice: they set Access-Control-Allow-Origin: * because it's the fastest way to 'just make CORS errors go away' during development, then later add cookie-based auth to the same API and start getting a browser console error that the credentialed request was blocked -- NOT because their server config is subtly wrong, but because the browser is correctly refusing an explicitly forbidden combination. The dangerous version of 'fixing' this is reaching for a workaround that reflects the requesting Origin back verbatim for every single request (effectively Access-Control-Allow-Origin: <whatever Origin the browser sent>, unconditionally, with no allowlist check at all) purely to make the credentials + wildcard-like behavior 'work' -- which technically satisfies the spec's letter (a specific origin, not literally *) while defeating its entire purpose, since it now grants every origin on the internet exactly the access true wildcard-plus-credentials would have, just without tripping the spec's explicit ban.
The correct fix is the one described in the previous section: maintain an explicit, real allowlist of origins your application actually trusts, echo back the Origin header's value ONLY when it matches an entry in that allowlist (rejecting/omitting the header entirely otherwise), and only set Access-Control-Allow-Credentials: true on responses to origins that have earned that trust through the allowlist check -- never unconditionally.
• Browsers explicitly forbid Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true -- the response is blocked client-side even if the server sends both. • The dangerous workaround: unconditionally echoing back whatever Origin header the browser sent, with no allowlist check -- this technically isn't '*' but grants the same effective access to every origin. • The correct fix: a real server-side allowlist, echoing the origin back ONLY on an actual match, with Allow-Credentials: true set only for allowlisted origins. • If an API genuinely has no cookie-based auth (e.g. a public, unauthenticated API using only an API key in a header, not a cookie), Access-Control-Allow-Origin: * alone (with no credentials) remains a legitimate, safe choice.
A Correct Origin-Allowlist CORS Handler in PHP
The Origin header is only echoed back when it matches the explicit allowlist -- an unrecognized origin gets no CORS headers at all, and the browser blocks the read. The OPTIONS branch exits immediately after answering -- it never reaches real business logic or a database query. Access-Control-Allow-Credentials is only ever set inside the allowlist-match branch, never unconditionally.
$allowedOrigins = ['https://app.example.com', 'https://staging.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header("Access-Control-Allow-Origin: {$origin}"); // exact match, never '*'
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin'); // don't let a cache serve this to a different origin
}
// Handle the preflight itself: answer fast, do no real work, then stop.
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
header('Access-Control-Max-Age: 600'); // cache this answer for 10 minutes
http_response_code(204);
exit;
}
// ... normal route handling continues here for the actual request ...Code Examples
Even though the server sent a perfectly well-formed pair of headers, the Fetch/CORS specification forbids this exact combination, and every modern browser enforces that ban client-side -- a fetch() with credentials: 'include' against this endpoint fails in the browser console with a CORS error, not a network error, because the browser deliberately refuses to hand the response to the page's JavaScript.
Common mistake: Seeing this browser error and 'fixing' it by echoing back the request's Origin header for every caller instead of using a real allowlist -- that satisfies the letter of the ban (no literal '*') while granting the same unrestricted access the ban exists to prevent.
This endpoint returns the same, non-user-specific data to every caller and never sets Access-Control-Allow-Credentials, so a wildcard origin is a legitimate, safe choice -- there is no session or cookie riding along for a malicious origin to exploit, because there is no ambient credential to include in the first place. The moment this API adds ANY per-user data gated by a cookie-based session, the wildcard must be replaced with a real allowlist -- the safety of '*' here depends entirely on the response containing no user-specific, credential-gated data.
<?php
// -- ILLUSTRATIVE, BROKEN PATTERN -- DO NOT USE --
// Server sends BOTH headers below. Browsers forbid this combination and
// will refuse to expose the response to the calling script, regardless.
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Credentials: true');
echo json_encode(['balance' => 4200.00]); // never reaches the calling script<?php
// A public weather API: no cookies, no session, auth (if any) via an
// API key header the CLIENT includes explicitly -- not ambient credentials.
header('Access-Control-Allow-Origin: *'); // safe: no Allow-Credentials header at all
header('Access-Control-Allow-Methods: GET');
header('Access-Control-Allow-Headers: X-Api-Key');
$city = $_GET['city'] ?? 'unknown';
echo json_encode(['city' => $city, 'tempC' => 21]);
// No user-specific or session-bound data in this response at all.A Credentialed Cross-Origin Request, Preflight Included
The real DELETE only fires after the browser is satisfied by the preflight's answer -- and the response is only readable because the server's allowlist, not a wildcard, matched this exact origin.
app.example.com (JS) api.example.com (PHP)
--------------------- ----------------------
fetch('https://api.example.com/orders',
{ method: 'DELETE', credentials: 'include',
headers: { Authorization: 'Bearer ...' } })
│
│ non-simple method + custom header
│ ⇒ browser sends PREFLIGHT first
▼
OPTIONS /orders ─────────────────────▶ checks Origin against allowlist
Origin: https://app.example.com responds:
Access-Control-Request-Method: DELETE Access-Control-Allow-Origin: https://app.example.com
Access-Control-Request-Headers: Access-Control-Allow-Methods: DELETE
Authorization Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Credentials: true
│ browser checks the answer, all clear
▼
DELETE /orders ─────────────────────▶ (cookies attached, since credentials: 'include'
(actual request now sent) and the server allowlisted this origin)
◀─────────────────────────────── real response, readable by the calling scriptImportant Rules
Never send Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true -- browsers forbid it, and the workaround of echoing any Origin unconditionally defeats the purpose.
Maintain a real server-side allowlist of trusted origins; echo the Origin header back only on an exact match, and send Vary: Origin alongside it.
Keep OPTIONS (preflight) handlers fast and side-effect-free -- answer the CORS question only, never run business logic there.
Scope Access-Control-Allow-Methods/Headers to exactly what the API needs, not a blanket allow-everything.
Wildcard origin with no credentials is fine for genuinely public, non-user-specific data -- but becomes unsafe the moment cookie-based auth is added.
Why This Trips Up Teams With Working Code
A very common real-world sequence: a team builds an internal API, adds Access-Control-Allow-Origin: * early because it makes local development friction-free and the API has no auth yet, ships it, and only later adds cookie-based session auth to the same endpoints for a new authenticated dashboard. At that point browsers start rejecting the credentialed calls, and under deadline pressure the fix that 'makes the error go away' fastest is often the unconditional origin-echo workaround rather than the small amount of extra work to build a real allowlist -- which is precisely how APIs end up more exposed in production than anyone intended, discovered later in a security review rather than caught at the point the shortcut was taken.
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Credentials: true');
Check Origin against a server-side allowlist; only echo back a matching origin and only then set Access-Control-Allow-Credentials: true.
Browsers forbid the wildcard-plus-credentials combination outright and block the response client-side; a real allowlist is the only way to serve credentialed cross-origin responses safely.
header('Access-Control-Allow-Origin: '. $_SERVER['HTTP_ORIGIN']); // no allowlist check at all if (in_array($_SERVER['HTTP_ORIGIN'] ?? '', $allowedOrigins, true)) { header('Access-Control-Allow-Origin: '. $_SERVER['HTTP_ORIGIN']); }
Echoing the Origin header back unconditionally grants every origin on the internet the exact access wildcard-plus-credentials would have, while superficially avoiding the literal '*' the browser bans -- it is not a fix, it's the same vulnerability without the visible red flag.
What does the same-origin policy primarily restrict? A. It prevents a cross-origin request from ever being sent by the browser at all B. It prevents a script from reading the response of a cross-origin request, even though the request may still be sent C. It prevents a server from ever receiving requests from other domains D. It only applies to requests using cookies, never to any other request
B. It prevents a script from reading the response of a cross-origin request, even though the request may still be sent — The browser can still send a cross-origin request (a form POST or an <img> tag always could), but by default a script cannot read that response -- CORS headers are how a server opts into allowing a specific origin to read it.
Which of these triggers a CORS preflight (OPTIONS) request? A. A simple GET request with no custom headers B. A cross-origin PUT request that includes a custom Authorization header C. A same-origin POST request D. Loading an image via an <img> tag
B. A cross-origin PUT request that includes a custom Authorization header — A non-simple method (PUT) combined with a custom header (Authorization) is exactly the kind of request the browser preflights with an OPTIONS request before sending the real one.
Why do browsers forbid Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true? A. Because the two headers use conflicting HTTP versions B. Because it would let any origin on the internet make a credentialed (cookie-bearing) request on the victim's behalf and read the response C. Because wildcard origins are deprecated in HTTP/2 D. It's not actually forbidden -- it works fine in all browsers
B. Because it would let any origin on the internet make a credentialed (cookie-bearing) request on the victim's behalf and read the response — Wildcard origin means 'trust anyone'; pairing that with credentials would mean any malicious site could ride on the victim's authenticated session and read the response -- exactly what same-origin policy exists to prevent, so browsers block the combination outright.
An endpoint returns identical, non-user-specific public data to everyone and never sets Access-Control-Allow-Credentials. Is Access-Control-Allow-Origin: * acceptable here? A. No, wildcard is never acceptable under any circumstances B. Yes, because with no credentials involved there is no ambient session or cookie for another origin to exploit C. Only if the API also sends X-Frame-Options: DENY D. Only for GET requests, never for POST
B. Yes, because with no credentials involved there is no ambient session or cookie for another origin to exploit — Wildcard origin is safe specifically when the response carries no credential-gated, user-specific data -- the risk wildcard introduces is entirely about leaking authenticated data to untrusted origins, which doesn't apply when there's no ambient auth involved at all.
An API needs to support exactly two trusted frontends -- https://app.example.com and the Electron desktop app, which makes requests with an Origin of file:// -- both using cookie-based auth. Write the PHP allowlist check that handles this correctly, and explain whether Access-Control-Allow-Origin: * would ever be an acceptable shortcut here.
$allowedOrigins = ['https://app.example.com', 'file://']; followed by the same in_array($origin, $allowedOrigins, true) check and conditional header-setting shown in the syntax block. Wildcard is never acceptable here: the API uses cookie-based auth (Access-Control-Allow-Credentials: true is required), and browsers forbid combining that with '*' outright -- an explicit two-entry allowlist is both required by the spec and is the only version of this that's actually safe.
file:// is just another value the Origin header can carry from the browser's perspective -- treat it as one more entry in your allowlist, not a special case requiring different logic.