How to Fix CORS Errors in Laravel 13 (2026 Guide)
If you've ever built a Laravel API and tried to call it from a separate frontend, you've probably hit a CORS error at some point. It's one of those problems that looks scary in the browser console but is almost always a config issue rather than a real code problem. The fix is usually a one-line change once you know what to look for.
In this guide, I'll walk you through what causes CORS errors in Laravel 13, how to diagnose them, and the various ways to fix them. Whether you're calling your API from a Vue or React SPA, a mobile app, or a separate marketing site, I've got you covered in this article.
What is a CORS Error?
CORS (Cross-Origin Resource Sharing) is a browser security mechanism that prevents JavaScript on one origin from reading responses from a different origin unless the server explicitly allows it. An "origin" is the combination of scheme, host, and port. So https://app.example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:8000.
When the browser blocks a request, you'll see something like this in your DevTools console:
1Access to fetch at 'https://api.example.com/users' from origin 'https://app.example.com'2has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present3on the requested resource.
Or:
1Access to XMLHttpRequest at 'http://localhost:8000/api/data' from origin2'http://localhost:3000' has been blocked by CORS policy: Response to preflight request3doesn't pass access control check: It does not have HTTP ok status.
Or, when credentials are involved:
1Access to fetch ... has been blocked by CORS policy: The value of the2'Access-Control-Allow-Credentials' header in the response is '' which must be3'true' when the request's credentials mode is 'include'.
The key thing to remember is that CORS is enforced by the browser, not the server. Your Laravel app will happily process the request and return a 200 response. The browser then looks at the response headers, decides the JavaScript caller isn't allowed to read it, and blocks access. That's why you can hit the same endpoint with cURL or Postman and it works fine, but it fails in the browser.
How CORS Works in Laravel 13
Since Laravel 9.2, the framework ships with built-in CORS handling via the Illuminate\Http\Middleware\HandleCors middleware. You don't need to install fruitcake/laravel-cors or barryvdh/laravel-cors anymore. Those packages are unmaintained, and Laravel's built-in middleware is a fork of the same underlying asm89/stack-cors library.
In Laravel 13, the HandleCors middleware is included in the global middleware stack by default. It runs on every request and:
- Responds to
OPTIONSpreflight requests automatically - Adds the appropriate
Access-Control-Allow-*headers to responses - Reads its config from
config/cors.php
The catch is that config/cors.php doesn't actually exist in a fresh Laravel 13 install. The middleware uses sensible defaults baked into the framework, but to customise anything you need to publish the config first.
Common Causes of CORS Errors
Before jumping into fixes, it helps to understand the various ways CORS errors show up in a Laravel application.
CORS Config Hasn't Been Published
Laravel 13 ships with default CORS values that allow api/* and sanctum/csrf-cookie paths from any origin. If your routes don't match those paths, or you need different origins, you have to publish the config to customise it. Lots of developers add CORS config to a file that doesn't exist yet, then wonder why nothing changes.
Frontend Origin Isn't in the Allowed List
The most common scenario: your allowed_origins is set to ['*'] or to a specific list, but your actual frontend URL isn't there. Maybe you're testing locally on http://localhost:5173 (Vite's default) but your config only lists http://localhost:3000.
Routes Outside the Configured Paths
The paths config controls which URL patterns the CORS middleware applies to. If you have routes at /v1/users but paths is ['api/*'], the middleware won't add CORS headers to those responses, and the browser will block them.
Wildcard Origin with Credentials Enabled
If you set allowed_origins => ['*'] and supports_credentials => true, browsers will reject the response. The CORS spec forbids using a wildcard origin when credentials (cookies, authorization headers) are involved. You have to specify exact origins.
Preflight Request Fails
For "complex" requests (anything with custom headers, JSON content type, or non-simple methods like PUT or DELETE), the browser sends an OPTIONS preflight request first. If that preflight returns a non-2xx response, the browser blocks the actual request. This often happens when:
- The route doesn't accept OPTIONS (e.g., a manually defined
Route::post()only) - Authentication middleware rejects the OPTIONS request because there's no auth token on a preflight
- A web server (Nginx, Apache) intercepts OPTIONS before Laravel sees it
Web Server Stripping or Adding Headers
Sometimes Nginx or Apache is configured to add its own CORS headers, which can conflict with Laravel's. The browser sees duplicate Access-Control-Allow-Origin headers and rejects the response.
Sanctum SPA Misconfiguration
If you're using Laravel Sanctum for SPA authentication, CORS is only one piece of the puzzle. You also need supports_credentials => true, the right SESSION_DOMAIN, and stateful domains configured in config/sanctum.php. Miss any of those and you'll see CORS errors that are actually session or CSRF errors in disguise.
Custom Headers Not Allowed
By default, Laravel's CORS config allows all headers ('allowed_headers' => ['*']), but if you've narrowed this down and your frontend sends a custom header like X-API-Key or X-Tenant-ID, the preflight will fail.
How to Fix CORS Errors
Let's work through the solutions from most common to more advanced scenarios.
Fix 1: Publish the CORS Config
If you don't have a config/cors.php file, that's the first thing to fix. In Laravel 13, run:
1php artisan config:publish cors
This creates config/cors.php with the default values:
1<?php 2 3return [ 4 'paths' => ['api/*', 'sanctum/csrf-cookie'], 5 6 'allowed_methods' => ['*'], 7 8 'allowed_origins' => ['*'], 9 10 'allowed_origins_patterns' => [],11 12 'allowed_headers' => ['*'],13 14 'exposed_headers' => [],15 16 'max_age' => 0,17 18 'supports_credentials' => false,19];
Now you can edit it to suit your application. After any change to this file, clear the config cache:
1php artisan config:clear
If you're running with config:cache enabled in production, you'll need to redeploy or run php artisan config:cache again for changes to take effect.
Fix 2: Set Allowed Origins Correctly
Replace the wildcard with the actual origins of your frontend applications:
1'allowed_origins' => [2 'https://app.example.com',3 'https://admin.example.com',4 'http://localhost:5173',5 'http://localhost:3000',6],
For environment-specific origins, use environment variables. In config/cors.php:
1'allowed_origins' => array_filter([2 env('FRONTEND_URL'),3 env('ADMIN_URL'),4]),
Then in your .env:
1FRONTEND_URL=https://app.example.com2ADMIN_URL=https://admin.example.com
For local development, you can append localhost origins conditionally:
1'allowed_origins' => array_filter([2 env('FRONTEND_URL'),3 env('ADMIN_URL'),4 app()->environment('local') ? 'http://localhost:5173' : null,5 app()->environment('local') ? 'http://localhost:3000' : null,6]),
The exact origin string has to match what the browser sends. That includes the scheme (http vs https), the port if it's non-standard, and the hostname (localhost is different from 127.0.0.1). Trailing slashes don't apply to origins, so don't include one.
Fix 3: Add Your Route Paths
If your API routes don't live under api/*, you need to add them to the paths config. For example, if you have versioned routes at /v1/, /v2/, and a separate /auth/ namespace:
1'paths' => [2 'api/*',3 'v1/*',4 'v2/*',5 'auth/*',6 'sanctum/csrf-cookie',7],
You can also include specific paths:
1'paths' => [2 'api/*',3 'broadcasting/auth',4 'webhooks/stripe',5],
A common mistake is leaving off sanctum/csrf-cookie. If you're using Sanctum SPA auth, the CSRF cookie endpoint must be included or your SPA won't be able to bootstrap a session.
Fix 4: Enable Credentials Support
If your frontend sends cookies or authorization headers (which Sanctum SPA auth and most session-based auth flows do), you need to enable credentials:
1'supports_credentials' => true,
When this is true, you cannot use '*' in allowed_origins. The browser will reject the response. You must list specific origins:
1'allowed_origins' => [2 'https://app.example.com',3 'http://localhost:5173',4],5 6'supports_credentials' => true,
You also need to configure your HTTP client to send credentials. With Axios:
1axios.defaults.withCredentials = true;2axios.defaults.withXSRFToken = true;
With the native fetch API:
1fetch('https://api.example.com/user', {2 credentials: 'include',3 headers: {4 'Accept': 'application/json',5 },6});
If you forget credentials: 'include', the browser won't send cookies and your Laravel session won't be recognised.
Fix 5: Allow Wildcard Subdomains
Standard CORS doesn't support wildcards in origins (e.g., you can't put https://*.example.com in allowed_origins). Laravel's CORS middleware works around this with allowed_origins_patterns, which uses regex:
1'allowed_origins' => [],2 3'allowed_origins_patterns' => [4 '#^https://.*\.example\.com$#',5],
The pattern is a PCRE regex. The # characters are delimiters (you could also use / if you escape any forward slashes in the pattern). The \. matches a literal dot, and .* matches any subdomain.
For multiple TLDs:
1'allowed_origins_patterns' => [2 '#^https://.*\.example\.com$#',3 '#^https://.*\.example\.com\.au$#',4],
Be careful with patterns. A pattern like #example\.com# would match evil-example.com.attacker.com. Always anchor with ^ and $, and escape dots.
Fix 6: Configure Sanctum SPA Authentication
For Sanctum SPA auth, CORS is only the start. The full setup looks like this.
In config/cors.php:
1'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],2 3'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],4 5'supports_credentials' => true,
In your .env:
1APP_URL=https://api.example.com2FRONTEND_URL=https://app.example.com34SESSION_DOMAIN=.example.com5SANCTUM_STATEFUL_DOMAINS=app.example.com
In bootstrap/app.php, register Sanctum's stateful middleware so cookie-based requests from your SPA are authenticated:
1->withMiddleware(function (Middleware $middleware) {2 $middleware->statefulApi();3})
If your project upgraded from Laravel 10 and still uses app/Http/Kernel.php, add \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class to the top of your api middleware group instead.
The leading dot on SESSION_DOMAIN lets the cookie be shared across subdomains. Your SPA and API must share a top-level domain for cookie-based auth to work. You can put them on different subdomains, but you can't put them on completely different domains and expect cookies to flow.
In your frontend's HTTP client setup:
1import axios from 'axios';2 3axios.defaults.withCredentials = true;4axios.defaults.withXSRFToken = true;5axios.defaults.headers.common['Accept'] = 'application/json';
The login flow then becomes:
1// First, get the CSRF cookie 2await axios.get('https://api.example.com/sanctum/csrf-cookie'); 3 4// Then log in 5await axios.post('https://api.example.com/login', { 7 password: 'secret', 8}); 9 10// Subsequent authenticated requests work normally11const { data } = await axios.get('https://api.example.com/api/user');
If you skip the csrf-cookie call, the login request will fail with a 419 error (which I've also written about in this guide).
Fix 7: Cross-Subdomain Sessions
Even with CORS configured correctly, cookie-based auth across subdomains needs the right session config. In config/session.php:
1'domain' => env('SESSION_DOMAIN'),2 3'secure' => env('SESSION_SECURE_COOKIE', true),4 5'same_site' => 'lax',
In .env:
1SESSION_DOMAIN=.example.com2SESSION_SECURE_COOKIE=true
For local development over HTTP, set SESSION_SECURE_COOKIE=false. Production should always be true.
The same_site setting matters too. The default is lax, which works for most cross-subdomain setups. If your SPA is on a completely different site (cross-site rather than cross-origin), you need same_site => 'none' and secure => true. Note that 'none' requires HTTPS, which is why it pairs with secure => true.
Fix 8: Allow Custom Headers
If your frontend sends a custom header like X-Tenant-ID, X-API-Version, or X-Requested-With, the preflight check will fail unless that header is allowed. The default config ('allowed_headers' => ['*']) allows everything, but if you've narrowed it down:
1'allowed_headers' => [2 'Accept',3 'Authorization',4 'Content-Type',5 'X-Requested-With',6 'X-XSRF-TOKEN',7 'X-Tenant-ID',8],
Be aware that '*' in allowed_headers doesn't actually allow Authorization. The CORS spec treats Authorization as a special case, so if your frontend sends bearer tokens, list it explicitly:
1'allowed_headers' => ['*', 'Authorization'],
If you need to expose custom response headers to the JavaScript caller (so it can read them), use exposed_headers:
1'exposed_headers' => [2 'X-Total-Count',3 'X-Page-Count',4],
Without this, headers like X-Total-Count exist on the response but JavaScript can't read them via response.headers.get().
Fix 9: Verify the Middleware is Registered
In Laravel 13, HandleCors is in the global middleware stack by default. If someone removed it from bootstrap/app.php, you'll need to add it back. Check bootstrap/app.php:
1return Application::configure(basePath: dirname(__DIR__)) 2 ->withRouting( 3 web: __DIR__.'/../routes/web.php', 4 commands: __DIR__.'/../routes/console.php', 5 health: '/up', 6 ) 7 ->withMiddleware(function (Middleware $middleware) { 8 // HandleCors is included by default - if it's been removed, add it back: 9 $middleware->prepend(\Illuminate\Http\Middleware\HandleCors::class);10 })11 ->withExceptions(function (Exceptions $exceptions) {12 //13 })->create();
If your project upgraded from Laravel 10 (and still uses the older structure with app/Http/Kernel.php), check that \Illuminate\Http\Middleware\HandleCors::class is in the $middleware array.
You can verify the middleware is active by inspecting the response headers on a request that should match your paths config:
1curl -I -X OPTIONS https://api.example.com/api/users \2 -H "Origin: https://app.example.com" \3 -H "Access-Control-Request-Method: POST"
You should see Access-Control-Allow-Origin in the response. If you don't, the middleware isn't running for that path.
Fix 10: Web Server Configuration
If you've configured Laravel correctly but still see CORS issues, your web server might be involved. A few common pitfalls.
Nginx adding its own CORS headers. Look for add_header Access-Control-* directives in your Nginx config and remove them. Let Laravel handle CORS, not Nginx. If you have multiple add_header directives at different scopes, Nginx replaces (not appends) headers, which can lead to confusion.
OPTIONS requests bypassing PHP-FPM. Some Nginx configs intercept OPTIONS requests before they reach PHP. If you see preflight failures and your Laravel logs show no OPTIONS requests at all, this might be it. Make sure your location block sends OPTIONS to PHP-FPM like any other method.
Apache with mod_headers. Similar to Nginx, check your .htaccess and virtual host config for any Header set Access-Control-* directives. Remove them so Laravel can handle CORS centrally.
CloudFlare or other CDNs stripping headers. Some CDN configurations cache or strip CORS headers. Check your CDN's response header rules and make sure Access-Control-* headers pass through.
Debugging CORS Issues
If the fix isn't immediately obvious, here are some debugging strategies.
Inspect the Preflight in DevTools
Open your browser's DevTools, go to the Network tab, and trigger the failing request. Look for an OPTIONS request immediately before the actual request. Click it and check:
- Status code: should be 200 or 204
- Request Headers: the browser sends
Origin,Access-Control-Request-Method, andAccess-Control-Request-Headers - Response Headers: the server should return
Access-Control-Allow-Originmatching the request'sOrigin, plusAccess-Control-Allow-MethodsandAccess-Control-Allow-Headers
If the OPTIONS request fails (4xx or 5xx), the actual request never happens. Fix the preflight first.
Test the Preflight with cURL
You can simulate a preflight request from the command line:
1curl -i -X OPTIONS https://api.example.com/api/users \2 -H "Origin: https://app.example.com" \3 -H "Access-Control-Request-Method: POST" \4 -H "Access-Control-Request-Headers: Content-Type, Authorization"
You should see a response like:
1HTTP/2 2042access-control-allow-origin: https://app.example.com3access-control-allow-methods: *4access-control-allow-headers: Content-Type, Authorization5access-control-allow-credentials: true6vary: Access-Control-Request-Method, Access-Control-Request-Headers
If access-control-allow-origin is missing or doesn't match the Origin you sent, your config isn't matching the path or origin correctly.
Check Which Origins Are Actually Allowed
Use Tinker to see what your config resolves to at runtime:
1php artisan tinker
1config('cors.allowed_origins');2config('cors.paths');3config('cors.supports_credentials');
This catches typos in .env values and config caching issues. If you've recently changed config/cors.php but Tinker shows the old values, run php artisan config:clear.
Watch for Authentication Middleware on Preflight
Sanctum and other auth middleware can sometimes reject OPTIONS preflights because they don't include credentials. The HandleCors middleware runs early in the stack to handle preflights before auth middleware sees them, but custom middleware ordering can break this.
If you have custom middleware that checks for an auth header, make sure it allows OPTIONS to pass through:
1public function handle(Request $request, Closure $next): Response2{3 if ($request->isMethod('OPTIONS')) {4 return $next($request);5 }6 7 // your auth logic here8}
Don't Add Headers Manually
A tempting "fix" you'll see in Stack Overflow answers is to add header('Access-Control-Allow-Origin: *') somewhere in your code, or to write custom middleware that injects CORS headers. Don't do this. You'll end up with duplicate headers, missing OPTIONS handling, and a config that's hard to reason about. The built-in HandleCors middleware does this correctly. Use it.
Special Cases
Laravel API Resources Behind Subdomain Routing
If you're using Laravel's subdomain routing (Route::domain('{tenant}.example.com')->...), the CORS config still works the same way. Just make sure your allowed_origins_patterns covers all valid tenant subdomains:
1'allowed_origins_patterns' => [2 '#^https://[a-z0-9-]+\.example\.com$#',3],
WebSockets and Laravel Reverb
Laravel Reverb (and Pusher-compatible WebSocket servers) handle their own CORS. You configure allowed origins in config/reverb.php rather than config/cors.php. The HandleCors middleware doesn't apply to WebSocket connections.
Webhook Endpoints
Webhooks from Stripe, GitHub, or similar services are server-to-server requests. They don't use CORS because there's no browser involved. You don't need to add webhook paths to config/cors.php. If you've done so to "fix" a webhook issue, the real problem is somewhere else (most likely CSRF or signature verification).
CORS in Local Development with Vite
If you're using Laravel's Vite integration with a separate frontend running on http://localhost:5173, you might also hit CORS issues with Vite's HMR endpoints. That's a Vite config issue, not a Laravel one. The Laravel Vite plugin auto-allows localhost, *.test, *.localhost, 127.0.0.1, ::1, and your APP_URL by default, so most setups don't need any config. If you do need to add origins, in vite.config.js:
1export default defineConfig({ 2 server: { 3 cors: { 4 origin: [ 5 'https://backend.test', 6 'http://localhost:8000', 7 ], 8 }, 9 },10});
Summary
CORS errors in Laravel are almost always a config issue. Here's the quick checklist:
- Publish the CORS config with
php artisan config:publish cors - List specific origins in
allowed_originsrather than using'*'(especially with credentials) - Add your route paths to the
pathsarray if they're not underapi/* - Enable
supports_credentialsif you're using cookies or session auth, and configure your frontend HTTP client to send credentials - Use
allowed_origins_patternsfor wildcard subdomains - Configure
SESSION_DOMAINandSANCTUM_STATEFUL_DOMAINSfor SPA auth - Check your web server config for conflicting CORS headers
- Test preflights with cURL to isolate browser-specific issues
The most common scenario is a missing config/cors.php file or an allowed_origins array that doesn't include the actual frontend URL. Fix 1 and Fix 2 will solve it for the majority of cases.
Hitting CORS issues you can't resolve, or building a Laravel API for a separate frontend? I specialise in Laravel development and integration work for teams running Laravel APIs alongside SPAs and mobile apps. Get in touch to discuss your project.
Topics
Syntax highlighting by Torchlight
More articles
How to Fix "Target Class Does Not Exist" Error in Laravel 13 (2026 Guide)
The "Target class does not exist" error in Laravel is usually caused by incorrect controller references, missing imports, or autoloading issues. This guide covers all the common causes and how to fix them in Laravel 13.
Read articleHow to Fix "Failed to Open Stream: Permission Denied" Error in Laravel 13 (2026 Guide)
The "failed to open stream: Permission denied" error in Laravel is almost always a file ownership or permissions issue with the storage and bootstrap/cache directories. This guide covers all the common causes and how to fix them in Laravel 13.
Read article