The new React dashboard on app.example.com calls the Laravel API on api.example.com. In local dev everything went through a Vite proxy, so it worked. In staging the console fills with red: "has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present". Someone pastes a Stack Overflow answer that sets Access-Control-Allow-Origin: *, then the login request fails with a different CORS error because it sends cookies. Someone else "fixes" that by echoing back whatever Origin the request had. Now it works, and now every website on the internet can read your users' data with their own cookies.
CORS is one of those topics people configure by trial and error until the error goes away. It is worth fifteen minutes to understand what the browser is actually doing, because the "make the error go away" fixes are exactly the ones that open a hole.
Same-Origin Policy First
An origin is the triple scheme + host + port. https://app.example.com and https://api.example.com are different origins; so are http:// and https:// of the same host, and :443 vs :8443.
The same-origin policy is a browser rule: JavaScript running on one origin may send many kinds of requests to another origin, but it may not read the response unless that other origin explicitly allows it. It protects the user, not the server. Without it, any page you visit could call fetch('https://your-bank.com/api/accounts', {credentials: 'include'}) and read the result with your session cookie attached.
CORS (Cross-Origin Resource Sharing) is the opt-out: a set of response headers with which a server says "these origins may read my responses, with these methods and headers, with or without credentials". Two consequences people often miss:
- CORS is enforced by the browser. curl, Postman, server-to-server calls and mobile apps ignore it completely. CORS is not authentication and not an API firewall.
- CORS is a relaxation. A server that sends no CORS headers at all is the most locked-down configuration. Every header you add loosens something.
Simple Requests vs Preflight
Browsers split cross-origin requests into two groups.
A simple request is one an HTML form could already send before CORS existed: method GET, HEAD or POST, only safelisted headers (such as Accept, Accept-Language, Content-Language), and a Content-Type of application/x-www-form-urlencoded, multipart/form-data or text/plain. The browser sends it immediately with an Origin header, then decides whether JavaScript may see the response.
Anything else (a PUT or DELETE, a JSON Content-Type, an Authorization header, any custom X- header) triggers a preflight: an OPTIONS request that asks permission first. Only if the answer allows the method, the headers and the origin does the browser send the real request.
A real preflight exchange for a JSON POST with a bearer token:
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
Two important details. First, a simple request does reach your server and run, even when the browser later hides the response. CORS does not protect a state-changing POST with a form content type; that is CSRF territory and needs CSRF tokens or SameSite cookies. Second, Access-Control-Max-Age lets the browser cache the preflight answer. Chromium caps it at 7200 seconds and Firefox at 86400, so larger values are silently reduced.
Credentials: Cookies Change the Rules
By default fetch() does not send cookies cross-origin. To send them you set credentials: 'include' (or withCredentials: true in axios):
// Runs on https://app.example.com
const res = await fetch('https://api.example.com/api/orders', {
method: 'POST',
credentials: 'include', // send cookies; requires Allow-Credentials and an explicit origin
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrfToken },
body: JSON.stringify({ sku: 'LAP-14', qty: 1 }),
});
Once credentials are involved, the server's response must satisfy stricter rules, or the browser refuses to expose it:
Access-Control-Allow-Credentials: truemust be present.Access-Control-Allow-Originmust be the exact origin, never*.- For credentialed requests,
*inAllow-Headers,Allow-MethodsandExpose-Headersis treated as a literal string, not a wildcard. List them explicitly.
Whenever the Allow-Origin value depends on the request, add Vary: Origin. Otherwise a CDN or shared cache can store the response for origin A and serve it to origin B, which either breaks B or, worse, grants it access.
Laravel 13: config/cors.php
Laravel ships the HandleCors middleware in the global stack, powered by fruitcake/php-cors. It only acts on paths listed in config/cors.php, which you publish with php artisan config:publish cors. A production config for a first-party SPA that uses cookie auth looks like this:
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => array_filter(explode(',', (string) env('CORS_ALLOWED_ORIGINS', ''))),
// Anchored on both ends: preview deploys like https://pr-123.preview.example.com
'allowed_origins_patterns' => ['#^https://pr-\d+\.preview\.example\.com$#'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
'exposed_headers' => ['X-RateLimit-Remaining'],
'max_age' => 7200,
'supports_credentials' => true,
];
What each choice buys you:
pathslimits CORS to the API and the Sanctum CSRF cookie endpoint. Web routes and the admin panel get no CORS headers at all.allowed_originscomes from an env var (CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com), so staging and production differ without code changes.allowed_origins_patternsare full regexes. Anchor them with^and$and escape the dots, orexample.com.evil.iowill match.exposed_headerscontrols which response headers JavaScript can read beyond the safelisted ones. Without it,res.headers.get('X-RateLimit-Remaining')returnsnulleven though the header is in the network tab.supports_credentialsmust betruefor cookie-based auth (Sanctum SPA mode). With Sanctum, also setSANCTUM_STATEFUL_DOMAINSand aSESSION_DOMAINlike.example.comso the session cookie is shared across subdomains.
Watch out for one default: with allowed_origins => ['*'] and supports_credentials => true, the library does not send a literal * (browsers would reject it). It echoes back the request's origin instead. That makes the browser happy and makes your API readable, with cookies, from any site. Never combine the two.
Doing It by Hand in Express
When there is no framework helper, the logic fits in one small middleware. The important parts are the allowlist lookup, Vary: Origin, and answering preflights without hitting your route handlers:
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const METHODS = 'GET, POST, PUT, PATCH, DELETE';
const HEADERS = 'content-type, authorization, x-xsrf-token';
function cors(req, res, next) {
const origin = req.get('Origin');
res.vary('Origin'); // caches must not mix responses for different origins
if (origin && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS' && req.get('Access-Control-Request-Method')) {
if (!origin || !ALLOWED.has(origin)) {
return res.sendStatus(403);
}
res.set('Access-Control-Allow-Methods', METHODS);
res.set('Access-Control-Allow-Headers', HEADERS);
res.set('Access-Control-Max-Age', '7200');
return res.sendStatus(204);
}
next();
}
module.exports = { cors };
And here are the patterns to grep for in code review, because each one has shipped in real products:
// All of these are real-world bugs. Do not ship them.
res.set('Access-Control-Allow-Origin', req.get('Origin')); // reflects ANY origin
if (origin.endsWith('example.com')) allow(origin); // matches evil-example.com
if (/https:\/\/.*\.example\.com/.test(origin)) allow(origin); // unanchored: example.com.evil.io
if (origin === 'null' || ALLOWED.has(origin)) allow(origin); // sandboxed iframes send null
Testing Your CORS Setup
CORS bugs show up in browsers, but you can pin down the server side with ordinary feature tests. Assert both the happy path and that hostile origins get nothing:
it('answers a preflight from an allowed origin', function () {
config(['cors.allowed_origins' => ['https://app.example.com']]);
$this->withHeaders([
'Origin' => 'https://app.example.com',
'Access-Control-Request-Method' => 'POST',
'Access-Control-Request-Headers' => 'content-type, authorization',
])->options('/api/orders')
->assertHeader('Access-Control-Allow-Origin', 'https://app.example.com')
->assertHeader('Access-Control-Allow-Credentials', 'true')
->assertHeader('Access-Control-Max-Age', '7200');
});
it('gives no CORS headers to an unknown origin', function () {
config(['cors.allowed_origins' => ['https://app.example.com']]);
$this->withHeaders(['Origin' => 'https://app.example.com.evil.io'])
->getJson('/api/health')
->assertHeaderMissing('Access-Control-Allow-Origin');
});
it('never combines a wildcard with credentials', function () {
$origins = config('cors.allowed_origins');
expect(config('cors.supports_credentials') && in_array('*', $origins, true))->toBeFalse();
});
The third test is a cheap guard rail: it fails the build the moment someone sets the dangerous wildcard plus credentials combination. For an end-to-end check, run your SPA's Playwright suite against a staging API on its real domain, not through a dev proxy that hides CORS entirely.
Common Pitfalls
- Reflecting the Origin header. Equivalent to
*with credentials. Always check against an allowlist. - Suffix and unanchored regex matching.
endsWith('example.com')acceptsevil-example.com. Match exact origins, or anchored patterns. - Allowing
null. Sandboxed iframes,file://pages and some redirects sendOrigin: null. An attacker can produce it on purpose. - Forgetting
Vary: Origin. Leads to cached responses with the wrong origin, which looks like random CORS failures behind a CDN. - CORS errors that are really 401, 413 or 502. If Nginx, a load balancer or a crashed PHP worker answers without CORS headers, the browser reports a CORS error and hides the real status. Check the network tab or server logs before touching CORS config.
- Treating CORS as access control. It stops browsers from reading responses. It does not stop curl, does not stop a simple POST from executing, and does not replace authentication or CSRF protection.
- Preflight hitting auth middleware.
OPTIONSrequests carry noAuthorizationheader. If your auth layer runs before CORS and returns 401, the preflight fails. In Laravel,HandleCorsis global and runs first, so keep it there.
When to Use CORS (and When Not To)
- Use it when browser JavaScript on one origin legitimately needs another origin's API: an SPA on
app.callingapi., a public read-only API used by third-party sites (here*without credentials is fine), or a widget embedded on customer domains with an explicit allowlist. - Avoid it when you can serve the frontend and API from the same origin. Putting the API under
https://example.com/apibehind the same reverse proxy (or using Next.js rewrites) removes CORS, preflight latency and cookie domain headaches entirely. - Do not reach for it for server-to-server calls, mobile apps or CLI tools. They do not enforce it, so it neither helps nor protects.
- Keep it narrow: specific paths, explicit origins, explicit headers, and credentials only where you really use cookies.
If you remember one rule: CORS is the server telling the browser whom it trusts. Make that list short, exact and boring.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) in PHP and JavaScript. It returns the CORS response headers as an associative array / plain object. config has allowed_origins (list of exact origins, may contain '*'), allowed_methods, allowed_headers (may contain '*'), supports_credentials (bool) and max_age (int). Rules: if origin is null, or it is neither an exact match in allowed_origins nor allowed by '*', return an empty result. Access-Control-Allow-Origin is '*' only when allowed_origins contains '*' and supports_credentials is false; otherwise it is the request origin and you also add Vary: Origin. If supports_credentials is true add Access-Control-Allow-Credentials: 'true'. A request is a preflight when method is 'OPTIONS' and requestMethod is not null: uppercase requestMethod and return an empty result unless it is in allowed_methods (case-insensitive) or allowed_methods contains '*'; lowercase and trim every requested header and return an empty result if any is missing from allowed_headers (case-insensitive) unless allowed_headers contains '*'. For an accepted preflight add Access-Control-Allow-Methods (the uppercased requestMethod), Access-Control-Allow-Headers (the lowercased requested headers joined with ', ', only when there are any) and Access-Control-Max-Age (max_age as a string).
Starter code
function resolveCors(array $config, string $method, ?string $origin, ?string $requestMethod = null, array $requestHeaders = []): array
{
// TODO: no Origin, or an origin not allowed (exact match or '*') -> []
// TODO: Allow-Origin is '*' only for a wildcard WITHOUT credentials,
// otherwise echo the origin and add Vary: Origin
// TODO: credentials -> Access-Control-Allow-Credentials: true
// TODO: preflight (OPTIONS + requestMethod): check method and headers,
// then add Allow-Methods, Allow-Headers and Max-Age
return ['TODO' => 'TODO'];
}
function resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) {
// TODO: no Origin, or an origin not allowed (exact match or '*') -> {}
// TODO: Allow-Origin is '*' only for a wildcard WITHOUT credentials,
// otherwise echo the origin and add Vary: Origin
// TODO: credentials -> Access-Control-Allow-Credentials: 'true'
// TODO: preflight (OPTIONS + requestMethod): check method and headers,
// then add Allow-Methods, Allow-Headers and Max-Age
throw new Error('TODO');
}
Your solution
Hints
All hints revealedUse strict, exact comparisons for origins: a suffix or substring check would accept https://app.example.com.evil.io
Decide the Allow-Origin value from two inputs: is there a wildcard, and are credentials enabled
Normalise case once (uppercase methods, lowercase headers) before comparing lists
function resolveCors(array $config, string $method, ?string $origin, ?string $requestMethod = null, array $requestHeaders = []): array
{
if ($origin === null) {
return [];
}
$wildcard = in_array('*', $config['allowed_origins'], true);
if (! $wildcard && ! in_array($origin, $config['allowed_origins'], true)) {
return [];
}
$headers = [];
if ($wildcard && ! $config['supports_credentials']) {
$headers['Access-Control-Allow-Origin'] = '*';
} else {
$headers['Access-Control-Allow-Origin'] = $origin;
$headers['Vary'] = 'Origin';
}
if ($config['supports_credentials']) {
$headers['Access-Control-Allow-Credentials'] = 'true';
}
if ($method !== 'OPTIONS' || $requestMethod === null) {
return $headers;
}
$requestMethod = strtoupper($requestMethod);
$methods = array_map('strtoupper', $config['allowed_methods']);
if (! in_array('*', $methods, true) && ! in_array($requestMethod, $methods, true)) {
return [];
}
$requested = array_map(fn (string $h): string => strtolower(trim($h)), $requestHeaders);
$allowed = array_map('strtolower', $config['allowed_headers']);
if (! in_array('*', $allowed, true) && array_diff($requested, $allowed) !== []) {
return [];
}
$headers['Access-Control-Allow-Methods'] = $requestMethod;
if ($requested !== []) {
$headers['Access-Control-Allow-Headers'] = implode(', ', $requested);
}
$headers['Access-Control-Max-Age'] = (string) $config['max_age'];
return $headers;
}
function resolveCors(config, method, origin, requestMethod = null, requestHeaders = []) {
if (origin === null || origin === undefined) return {};
const wildcard = config.allowed_origins.includes('*');
if (!wildcard && !config.allowed_origins.includes(origin)) return {};
const headers = {};
if (wildcard && !config.supports_credentials) {
headers['Access-Control-Allow-Origin'] = '*';
} else {
headers['Access-Control-Allow-Origin'] = origin;
headers['Vary'] = 'Origin';
}
if (config.supports_credentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
if (method !== 'OPTIONS' || requestMethod === null || requestMethod === undefined) {
return headers;
}
const reqMethod = requestMethod.toUpperCase();
const methods = config.allowed_methods.map((m) => m.toUpperCase());
if (!methods.includes('*') && !methods.includes(reqMethod)) return {};
const requested = requestHeaders.map((h) => h.trim().toLowerCase());
const allowed = config.allowed_headers.map((h) => h.toLowerCase());
if (!allowed.includes('*') && requested.some((h) => !allowed.includes(h))) return {};
headers['Access-Control-Allow-Methods'] = reqMethod;
if (requested.length > 0) {
headers['Access-Control-Allow-Headers'] = requested.join(', ');
}
headers['Access-Control-Max-Age'] = String(config.max_age);
return headers;
}

