Medium Security

CORS Explained: Same-Origin Policy, Preflight, Credentials and Laravel config/cors.php

CORS errors tempt you to reflect any Origin and move on. Learn what the browser checks, when preflight happens, how credentials change the rules, and safe config.

07 Nov, 2026 10 min Updated 23 Oct, 2026 6 Code blocks
Diagram
graph TD A[Script on app.example.com calls api.example.com] --> B{Simple request?} B -->|yes| C[Browser sends request with Origin header] B -->|no| D[Browser sends OPTIONS preflight] D --> E{Origin, method and headers allowed?} E -->|no| F[Real request is never sent] E -->|yes| C C --> G{Matching Allow-Origin in response?} G -->|yes| H[Script can read the response] G -->|no| I[Script sees a network error]

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.

graph TD A[Script on app.example.com calls api.example.com] --> B{Simple request?} B -->|yes| C[Browser sends request with Origin header] B -->|no| D[Browser sends OPTIONS preflight] D --> E{Origin, method and headers allowed?} E -->|no| F[Real request is never sent] E -->|yes| C C --> G{Matching Allow-Origin in response?} G -->|yes| H[Script can read the response] G -->|no| I[Script sees a network error]

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: true must be present.
  • Access-Control-Allow-Origin must be the exact origin, never *.
  • For credentialed requests, * in Allow-Headers, Allow-Methods and Expose-Headers is 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:

  • paths limits CORS to the API and the Sanctum CSRF cookie endpoint. Web routes and the admin panel get no CORS headers at all.
  • allowed_origins comes 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_patterns are full regexes. Anchor them with ^ and $ and escape the dots, or example.com.evil.io will match.
  • exposed_headers controls which response headers JavaScript can read beyond the safelisted ones. Without it, res.headers.get('X-RateLimit-Remaining') returns null even though the header is in the network tab.
  • supports_credentials must be true for cookie-based auth (Sanctum SPA mode). With Sanctum, also set SANCTUM_STATEFUL_DOMAINS and a SESSION_DOMAIN like .example.com so 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') accepts evil-example.com. Match exact origins, or anchored patterns.
  • Allowing null. Sandboxed iframes, file:// pages and some redirects send Origin: 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. OPTIONS requests carry no Authorization header. If your auth layer runs before CORS and returns 401, the preflight fails. In Laravel, HandleCors is 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. calling api., 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/api behind 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.

Interactive challenge

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

php
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'];
}
javascript
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 revealed
Hint 1

Use strict, exact comparisons for origins: a suffix or substring check would accept https://app.example.com.evil.io

Hint 2

Decide the Allow-Origin value from two inputs: is there a wildcard, and are credentials enabled

Hint 3

Normalise case once (uppercase methods, lowercase headers) before comparing lists

Solution · php
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;
}
Solution · javascript
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;
}

More from Security