DEV Community

Kamran Khan
Kamran Khan

Posted on

CORS is a backend decision, not a dev-server trick

Every Laravel + SPA tutorial eventually hits the CORS error, and the usual fix is a dev-server trick: proxy the API through Vite, pretend the problem doesn't exist, ship it, and rediscover the problem at 2am when the real frontend at its real URL gets blocked.

CORS is a backend decision. It belongs in the backend config, driven by one environment variable, decided once. Here's mine:

$frontendUrl = env('FRONTEND_URL', 'http://localhost:5173');

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => array_values(array_unique(array_filter([
        $frontendUrl,
        // Browsers send only scheme + host as the Origin header, so also
        // allow the bare origin when the frontend lives under a sub-path
        // (e.g. https://user.github.io/my-app).
        preg_replace('#^([a-z]+://[^/]+).*$#i', '$1', $frontendUrl),
    ]))),
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => false,
];
Enter fullscreen mode Exit fullscreen mode

Three decisions worth explaining.

1. One env var, not a list in code

FRONTEND_URL is the whole allowlist. Local dev: http://localhost:5173. Production: whatever the deployed frontend URL is. The config reads the environment instead of hardcoding origins per stage, which means adding a staging frontend is a deploy-variable change, not a code change and redeploy of the backend. Origins are deployment facts; they don't belong in version control.

2. The bare-origin derivation

Browsers send only scheme + host in the Origin header — no path. If your frontend deploys to https://user.github.io/my-app, the origin is https://user.github.io, and an allowlist containing only the full URL fails the match. The regex strips the path and allows the bare origin too. This is the exact bug that appears only on the GitHub Pages deploy and never locally, and it's the reason I derived it in config rather than debugging it at midnight.

3. supports_credentials is false — deliberately

This is token auth (Sanctum personal access tokens in the Authorization header), not cookie auth. No cookies means no withCredentials, which means supports_credentials stays false — and that keeps the CORS model simple. The moment you enable credentials, every * in the config becomes illegal and you need exact origins everywhere. Token auth sidesteps the whole class of problems. If your SPA uses cookies, that's fine — but it's a decision with CORS consequences, not just an auth preference.

What I didn't do

No wildcard origins. allowed_origins => ['*'] on an API that takes bearer tokens is handing your API to every website on the internet — any page can make credentialed-looking requests with a stolen token and the browser will happily deliver the response. Wildcards are for public, unauthenticated APIs. Mine isn't.

No per-route CORS logic in controllers or middleware. The paths key scopes it to api/* once, centrally. CORS is a deployment-boundary concern; scattering it across controllers turns it into a per-endpoint guessing game.

Set it once, from one variable, in one file. Then forget about it — that's what a good CORS config feels like.


This is the production wiring in my Laravel + React SaaS Starter Kit ($19 one-time) — Sanctum token auth, one-env-var CORS, and a deploy-ready frontend.

Top comments (0)