Jonathan Bird Web Development

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 present
3on the requested resource.

Or:

1Access to XMLHttpRequest at 'http://localhost:8000/api/data' from origin
2'http://localhost:3000' has been blocked by CORS policy: Response to preflight request
3doesn'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 the
2'Access-Control-Allow-Credentials' header in the response is '' which must be
3'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:

  1. Responds to OPTIONS preflight requests automatically
  2. Adds the appropriate Access-Control-Allow-* headers to responses
  3. 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.com
2ADMIN_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.com
2FRONTEND_URL=https://app.example.com
3
4SESSION_DOMAIN=.example.com
5SANCTUM_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', {
6 email: '[email protected]',
7 password: 'secret',
8});
9 
10// Subsequent authenticated requests work normally
11const { 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.com
2SESSION_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, and Access-Control-Request-Headers
  • Response Headers: the server should return Access-Control-Allow-Origin matching the request's Origin, plus Access-Control-Allow-Methods and Access-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 204
2access-control-allow-origin: https://app.example.com
3access-control-allow-methods: *
4access-control-allow-headers: Content-Type, Authorization
5access-control-allow-credentials: true
6vary: 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): Response
2{
3 if ($request->isMethod('OPTIONS')) {
4 return $next($request);
5 }
6 
7 // your auth logic here
8}

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:

  1. Publish the CORS config with php artisan config:publish cors
  2. List specific origins in allowed_origins rather than using '*' (especially with credentials)
  3. Add your route paths to the paths array if they're not under api/*
  4. Enable supports_credentials if you're using cookies or session auth, and configure your frontend HTTP client to send credentials
  5. Use allowed_origins_patterns for wildcard subdomains
  6. Configure SESSION_DOMAIN and SANCTUM_STATEFUL_DOMAINS for SPA auth
  7. Check your web server config for conflicting CORS headers
  8. 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 article

How 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

Talk to me about your website project