You split the project in two: Laravel serves an API on localhost:8000, and a React or Vue app runs on localhost:5173. The first request from the browser fails, and the console shows a message along these lines (Chrome's wording):
Access to fetch at 'http://localhost:8000/api/users' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. The API itself is usually fine. CORS is a browser rule: the browser asks the server whether a page from another origin may read its response, and the server answers with headers. Laravel already has the machinery for that answer. Most fixes are a few lines of config, and I checked each one against the real middleware.
Why you only see it in the browser
Two URLs have the same origin only if scheme, host and port all match. localhost:5173 and localhost:8000 differ in the port, so every request between them is cross-origin. The server never blocks anything. It sends Access-Control-* headers, and the browser decides whether your JavaScript may see the response. That is why curl and Postman work while the browser fails, and why the real fix always means changing what the server sends, not changing the frontend code.
Requests that use Authorization, JSON content types or methods like PUT and DELETE trigger a preflight first: the browser sends an OPTIONS request asking permission, and only sends the real request if the answer allows it. A CORS problem is very often a failed preflight.
How Laravel handles CORS
Laravel's HandleCors middleware is in the global middleware stack by default, and it uses the fruitcake/php-cors library. In Laravel 11 and newer, a fresh application does not have a config/cors.php file, but the framework supplies one. In the Laravel 13 source (13.38.0) the defaults are:
'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => ['*'], 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => false, To change anything, publish the file and edit it:
php artisan config:publish cors Also note that a new Laravel 11+ skeleton has no routes/api.php until you run php artisan install:api. If your frontend calls /api/... and gets a 404, that is a routing problem, not CORS.
Test the preflight without a browser
Reproduce what the browser sends and read the headers that come back:
curl -i -X OPTIONS http://localhost:8000/api/users \ -H 'Origin: http://localhost:5173' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type,authorization' A healthy answer is a 204 with Access-Control-Allow-Origin, Access-Control-Allow-Methods and Access-Control-Allow-Headers. I ran this through the real HandleCors middleware and fruitcake/php-cors with the default config:
status 204 access-control-allow-origin: * access-control-allow-methods: POST access-control-allow-headers: content-type,authorization With defaults, a plain API on /api/* using bearer tokens works from any origin. If yours does not, the cause is one of the cases below.
Cause 1: The path is not in paths
CORS headers are only added for URLs that match paths. A request to /login, /broadcasting/auth, /graphql or /storage/... gets none, because those do not start with api/. In my test, a preflight to /login with the default config came back with no CORS headers at all. Add what you serve to the frontend:
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout', 'broadcasting/auth'], Patterns use the same matching as $request->is(), so * is a wildcard.
Cause 2: Cookies or sessions (Sanctum SPA) need credentials
If your React or Vue app logs in through cookies, the browser will not send or accept them across origins unless both sides opt in. On the server:
'allowed_origins' => ['http://localhost:5173'], // an explicit list, not '*' 'supports_credentials' => true, In my test, a preflight with that configuration returned:
access-control-allow-origin: http://localhost:5173 access-control-allow-credentials: true On the frontend, the request must ask to include cookies. With axios that is withCredentials: true, with fetch it is credentials: 'include'. The Sanctum SPA setup needs more besides CORS: the stateful-domains setting, the session cookie domain, and a call to /sanctum/csrf-cookie before logging in. Check the Sanctum documentation for those, since I only tested the CORS part. In Laravel 13, $middleware->statefulApi() in bootstrap/app.php turns on Sanctum's frontend middleware.
Do not combine credentials with '*'
Browsers refuse a wildcard origin on a credentialed request. What surprised me is what Laravel does with that configuration. With allowed_origins => ['*'] and supports_credentials => true, a request from http://localhost:5173 got this back:
access-control-allow-origin: http://localhost:5173 vary: Origin access-control-allow-credentials: true The library reflected the caller's origin instead of sending *. That makes it work in the browser, and it also means any website can make credentialed requests to your API and read the answers. For production, list your real frontend origins (or use allowed_origins_patterns with a strict regex) and never leave * on when supports_credentials is true.
Cause 3: A request header is not allowed
The default allowed_headers is ['*']. If you set an explicit list and forget Authorization or a custom header, the preflight succeeds but the browser then blocks the real request. In my test, with ['Content-Type', 'X-Requested-With'] and a preflight asking for authorization:
access-control-allow-headers: content-type, x-requested-with There is no authorization in the list, so the browser refuses to send it. Add the header to the list, or go back to ['*'].
Cause 4: The origin is not listed
If you set allowed_origins to one origin, the response carries that origin whatever the caller sends. With ['http://localhost:5173'], a request from http://evil.test still got access-control-allow-origin: http://localhost:5173, and the browser rejected it because it does not match the page. When the browser says the origin is not allowed, compare it character by character: http against https, a trailing slash, 127.0.0.1 against localhost, and the port are all different origins. For several environments, use a list or a pattern:
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], 'allowed_origins_patterns' => ['#^https://.*\.example\.com$#'], Things that make it look like a CORS error
- Stale config. After editing
config/cors.phpon a server that caches config, runphp artisan config:clear(orconfig:cacheagain). The cached copy wins over the file. - The real error is something else. Open the browser's Network tab and look at the status code of the request. A 404, 419 or 401 from Laravel is not a CORS problem. In my test, a 500 response from the application still carried the CORS header, so you should see the real status. Errors produced by the web server or a proxy in front of Laravel (502, 504, 413) do not pass through the middleware and arrive without CORS headers, so the browser reports them as CORS failures.
- Headers added twice. If Nginx or Apache also adds
Access-Control-Allow-Origin, the browser can receive two values and reject the response. Set CORS in one place only. I did not test this. - Redirects. A request that Laravel redirects (for example an unauthenticated browser request sent to a login page) can fail the CORS check on the redirect. Send
Accept: application/jsonfrom your API client so Laravel answers with JSON errors instead of redirects. - Dev proxy as an alternative. Vite can proxy
/apito Laravel during development, which removes CORS from local development. That hides a misconfiguration you will meet in production, so keep the real settings correct as well.
Sources
- MDN: Cross-Origin Resource Sharing (CORS)
- Laravel Documentation: Routing, CORS
- fruitcake/php-cors on GitHub
- Laravel Documentation: Sanctum, SPA authentication
Originally published on DEV Talk.