Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

CORS Is Not Your Firewall

  • security
  • laravel
  • typescript
  • node
  • apis

Somebody on the team sees a red CORS error in the console, searches it, and finds the answer that always shows up first: set Access-Control-Allow-Origin: *. The error goes away. Everybody moves on.

Then later someone says "it's fine, CORS blocks other sites from hitting our API."

It doesn't. That's not what CORS is.

What CORS actually does

Browsers have a same-origin policy. JavaScript on https://app.example.com can't read responses from https://api.example.com by default, because those are different origins.

CORS is how your server says "it's okay, let that page read this." That's it. It's a set of response headers that loosen a browser rule.

So a few things follow from that:

  • curl doesn't care. Neither does a Python script, a Node server, Postman, or a bot. No browser, no CORS.
  • For a lot of requests, the browser still sends the request. It just won't let the page read the response.
  • The only thing CORS protects is your users' browsers from handing your responses to someone else's page.

If an endpoint shouldn't be callable by just anyone, that's authentication and authorization. CORS is not on that list.

The request still happened

This one surprises people. A "simple" request (a GET, or a POST with a form content type and no custom headers) doesn't get a preflight. The browser sends it, your server runs it, and then the browser checks the CORS headers to decide whether the page gets to see the result.

Your controller already ran. If it changed something, it's changed.

Non-simple requests, like a POST with Content-Type: application/json or anything with an Authorization header, get a preflight OPTIONS request first. If the preflight fails, the real request isn't sent. That's the case people picture, and it's only half the story.

This is why CSRF protection still matters on cookie-based routes. CORS hiding the response doesn't help when the damage is in the request. Laravel's CSRF token check on web routes and SameSite cookies are doing that job, not your CORS config.

Laravel: make the allowlist explicit

Laravel handles CORS with the built-in HandleCors middleware, driven by config/cors.php. On Laravel 11 and up it isn't in your config folder until you publish it:

php artisan config:publish cors

Then say exactly who you mean:

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    'allowed_origins' => [
        'https://app.example.com',
        'https://admin.example.com',
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
    'exposed_headers' => [],
    'max_age' => 600,
    'supports_credentials' => true,
];

If you're running a Sanctum SPA with cookie auth, supports_credentials has to be true, and the frontend has to opt in too (credentials: 'include' on fetch, withCredentials on axios). That's also exactly when * stops being an option, which is the next section.

Wildcards and credentials don't mix (on purpose)

Browsers refuse Access-Control-Allow-Origin: * on a request that includes credentials. That's a deliberate guardrail.

The classic way people get around it is to reflect whatever Origin the request sent:

// Don't do this
app.use(cors({ origin: true, credentials: true }))

In the cors package for Express, origin: true means "echo back the request's Origin." Combined with credentials: true, that tells the browser any site on the internet may make authenticated requests as your logged-in user and read the response. You've rebuilt the wildcard and removed the guardrail.

Use an allowlist instead:

import cors from 'cors'
 
const allowed = new Set([
  'https://app.example.com',
  'https://admin.example.com',
])
 
app.use(
  cors({
    origin(origin, callback) {
      // No Origin header means it's not a cross-origin browser request.
      if (!origin || allowed.has(origin)) return callback(null, true)
      return callback(null, false)
    },
    credentials: true,
  }),
)

Note the comment. Letting requests with no Origin through isn't a hole, because CORS was never going to stop them anyway. Your auth middleware is.

Regexes are where allowlists go to die

Preview deploys and customer subdomains push people toward patterns. That's fine, as long as the pattern means what you think:

// Matches https://app.example.com
// Also matches https://evil-example.com and https://example.com.attacker.io
'allowed_origins_patterns' => ['/example.com/'],

Unescaped dot, no anchors. Here's the boring version that actually works:

'allowed_origins_patterns' => [
    '#^https://([a-z0-9-]+\.)?example\.com\z#',
],

Anchor both ends, escape the dots, and pin the scheme. Same rules in TypeScript if you're matching with a RegExp, and honestly a Set of exact strings beats a clever pattern most of the time.

Also, never put the string "null" in an allowlist. Sandboxed iframes and some local files send Origin: null, and anyone can produce that.

If you reflect, send Vary: Origin

When the Access-Control-Allow-Origin value changes based on who asked, a CDN or proxy cache needs to know that. Otherwise it caches the response for origin A and serves it, header and all, to origin B.

Laravel's middleware and the Express cors package both add Vary: Origin when the allowed origin isn't a fixed value. If you hand-roll CORS headers in a route or in nginx, you have to remember it yourself.

The CORS error that isn't a CORS error

A lot of CORS errors in the console aren't configuration problems. The request failed, and the error response didn't have CORS headers on it.

A 502 from the load balancer. A 413 from nginx because the upload was too big. A timeout at the proxy. None of those pass through your app, so none of them get your CORS headers, so the browser reports it as a CORS failure and hides the real status.

Before you touch config/cors.php, open the Network tab and look at the actual response status. Or just hit it directly:

curl -i -X OPTIONS https://api.example.com/api/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

If that comes back with the right Access-Control-Allow-* headers, your CORS config is fine and the problem is somewhere else.

And if your frontend needs to read a custom response header (a request ID, pagination totals), it can't until you list it in exposed_headers. The header is there in the Network tab. JavaScript just isn't allowed to see it.

The short version

CORS answers one question: which web pages can read this response in a browser. Configure it with an explicit list of origins, keep * away from anything with credentials, and send Vary: Origin when the answer changes per request.

Then go make sure your auth is doing the job you thought CORS was doing.

← All posts