Everything works in Postman or Insomnia, but the moment your frontend application fires an axios or fetch request to your Laravel backend, the browser console throws a wall of red text:
Access to fetch at 'http://localhost:8000/api/v1/bookings' from origin 'http://localhost:5173'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Postman works because it is a desktop HTTP client that completely ignores browser security policies. Browsers, however, enforce Cross-Origin Resource Sharing (CORS). Whenever your frontend sends a JSON payload, attaches a Bearer token, or uses PUT/DELETE methods across different ports or domains, the browser pauses your actual request and fires a silent OPTIONS preflight request first to ask Laravel for permission.
If Laravel—or your web server—fails to answer that OPTIONS request with the exact headers the browser expects, the request is killed before your controller code ever runs.
Quick Diagnostic Reference
Match your setup to the exact failure point below:
| Symptom / Setup | Root Cause | Jump to Fix |
|---|---|---|
Fresh Laravel 11 or 12 project has no config/cors.php
|
Config file is hidden in framework core by default | Fix 1 |
Using cookies, Sanctum, or withCredentials: true
|
Wildcard * origin conflicts with credentialed requests |
Fix 2 |
Origins match in cors.php, but browser still blocks |
Trailing slash (http://localhost:5173/) or port mismatch |
Fix 3 |
Preflight passes (204), but POST/GET fails with CORS |
A backend 500 crash is stripping CORS headers |
Fix 4 |
| Works locally, fails on production Ubuntu / Nginx server | Nginx or Cloudflare is intercepting OPTIONS requests |
Fix 5 |
1. Publish and Configure cors.php (Laravel 11 & 12)
If you recently spun up a Laravel 11 or Laravel 12 project, you might notice that config/cors.php is missing from your config/ directory. Laravel moved default middleware configurations into the internal vendor directory to keep the project skeleton slim.
Run the Artisan publish command in your terminal to expose the file:
php artisan config:publish cors
Now open config/cors.php. By default, Laravel only inspects paths matching api/* and sanctum/csrf-cookie. If you registered custom routes outside the /api prefix (such as /v1/*, /webhooks/*, or /oauth/*), HandleCors ignores them completely.
Update paths and explicitly define your frontend origin via an .env variable rather than hardcoding URLs:
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_methods' => ['*'],
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:5173'),
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
Then set your exact frontend origin inside .env:
FRONTEND_URL=http://localhost:5173
Clear your cached configuration immediately so Laravel picks up the change:
php artisan config:clear
2. The Wildcard (*) and supports_credentials Trap
One of the most frequent traps happens when authenticating a SPA (React, Vue, Next.js) using Laravel Sanctum or session cookies.
When your frontend Axios client sets withCredentials: true (or credentials: 'include' in fetch), the browser enforces a strict security rule: the server cannot respond with Access-Control-Allow-Origin: *. If allowed_origins is left at ['*'] while supports_credentials is set to true, the preflight check fails immediately.
To fix credentialed cross-origin requests:
- In
config/cors.php, setsupports_credentialstotrueand replace'*'with explicit origins:
'allowed_origins' => [
env('FRONTEND_URL', 'http://localhost:3000'),
'[https://app.yourdomain.com](https://app.yourdomain.com)',
],
'supports_credentials' => true,
- In
.env, ensureSANCTUM_STATEFUL_DOMAINSincludes the host and port (withouthttp://orhttps://):
FRONTEND_URL=http://localhost:3000
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000
SESSION_DOMAIN=localhost
3. Remove Trailing Slashes in allowed_origins
Browsers send the Origin header as a strict scheme://host:port string. They never include a trailing slash.
If you put a trailing slash in your .env or config/cors.php, Laravel's string comparison fails silently:
# ❌ BROKEN: Browser sends 'http://localhost:5173', string match fails
FRONTEND_URL=http://localhost:5173/
# ✅ WORKING: Exact origin match
FRONTEND_URL=http://localhost:5173
Similarly, remember that localhost and 127.0.0.1 are treated by browsers as two completely different origins. If your terminal says Vite is running on http://localhost:5173, opening http://127.0.0.1:5173 in Chrome will trigger a CORS block unless both are listed in allowed_origins.
4. Unhandled 500 Crashes Disguised as CORS Errors
Sometimes your CORS configuration is 100% valid, yet the browser still screams about a missing Access-Control-Allow-Origin header on POST or GET requests.
Here is why: if your Laravel controller or middleware throws a fatal exception before the response passes back through the HandleCors middleware—or if PHP-FPM times out—the server returns a raw 500 Internal Server Error without attaching CORS headers. Because the 500 error response lacks the Access-Control-Allow-Origin header, Chrome hides the actual server crash and reports a CORS block in the console instead.
How to check if a 500 error is the real culprit:
- Open your browser DevTools (
F12) and click the Network tab. - Look at the status column for the failed request. If the
OPTIONSrequest returned204 No Content, but the follow-upPOST/GETrequest returned500, your CORS setup is fine—your backend code crashed. - Check the end of your Laravel log file:
tail -n 50 storage/logs/laravel.log
(Note: A classic cause of sudden 500 responses during API calls is a dropped MySQL socket or container port mismatch. If your log shows a database refusal, follow our guide on Fixing SQLSTATE[HY000] [2002] Connection Refused in Laravel).
5. Do Not Manually Add CORS Headers in Nginx AND Laravel
When a CORS error persists on a staging or production Linux server, developers often copy-paste add_header 'Access-Control-Allow-Origin' blocks into /etc/nginx/sites-available/your-site.conf.
If both Nginx and Laravel's HandleCors middleware attach the header, the browser receives duplicate values:
Access-Control-Allow-Origin: http://localhost:5173, http://localhost:5173
Browsers reject duplicate Access-Control-Allow-Origin headers just as strictly as missing ones.
The rule of thumb: Let Laravel's built-in Illuminate\Http\Middleware\HandleCors manage CORS entirely, and remove custom add_header Access-Control-* directives from your Nginx server block.
The only thing Nginx needs is a clean try_files block that passes OPTIONS requests down to index.php:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
How to Verify Your Fix in the Terminal
Browser caching on failed preflight requests can be stubborn. Instead of endlessly refreshing Chrome, verify your Laravel headers directly from your terminal using curl:
curl -i -X OPTIONS http://localhost:8000/api/v1/bookings \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
A healthy Laravel preflight response will return HTTP/1.1 204 No Content alongside your explicit origin header:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin
Once you see Access-Control-Allow-Origin echoed back in your terminal output, open your browser DevTools (F12), check Disable cache under the Network tab, and reload your frontend app.
Top comments (0)