Laravel SPA Authentication: setup and common mistakes
The internet is filled with questions and issue reports about authenticating SPAs using Laravel Sanctum. Not because Sanctum is a complex package or that it's hard to set up; it only has a few configuration steps. The problem is that each and every step is crucial and very, very easy to get wrong.
This is the video version of this article - watch it if you have the time; otherwise, jump at the most common mistakes.
I'll start this post with the most common misconfigurations, and this should cover the people filled with rage who found this post hoping for a solution. Then, in the second part, I'll show you how I, personally, pair Laravel with a Vue SPA, explain the auth flow and why it's secure, and finally, I'll go over some common questions I received while preparing this post and video.
Laravel Sanctum common mistakes
Mistake 1. Not using the same top-level domain
Your SPA and Laravel API must be on the same top-level domain. You cannot have your SPA at domainA.com
and the Laravel API at domainB.com
. You also cannot have app.test
and localhost:3000
.
But why? you might ask. The answer is because laravel/sanctum
, the recommended Laravel authentication package for SPAs, works by setting up an HttpOnly, Lax cookie. This cookie is secure, it cannot be read or stolen, but most importantly, it cannot be shared across different domains. And this is why you must have your frontend and backend on the same top-level domain. Usually, you would have a subdomain either for the SPA or Laravel API.
Whether in production or development, your Laravel API and SPA must always be on the same top-level domain.
Mistake 2. Misconfigured CORS
When a browser makes an ajax request from one origin to a different origin, we call that a cross-origin request. By default, browsers block these kinds of requests.
Looking at the console, you'll see errors like the one below:
But what is an origin, anyway?
An origin is defined by the scheme, host, and port (if specified).
When comparing two URLs, if any of those three parts are different, the URLs are considered to have different origins.
Consider the examples:
✅ Same origins; All three parts (scheme, hostname, port) are the same.
http://mysite:8000/page-a
http://mysite:8000/page-b
❌ The scheme is different (http vs https)
http://mysite:8000/page-a
https://mysite:8000/page-b
❌ The hostname is different (mysite vs sub.mysite)
http://mysite:8000/page-a
http://sub.mysite:8000/page-b
❌ The port is missing from the first URL
http://mysite.com/page-a
http://mysite.com:8000/page-b
So browsers don't allow requests from one origin to another. But that's only by default; this is where CORS kicks in.
CORS (Cross-Origin Resource Sharing) is an HTTP-header-based mechanism that allows a server to specify any origins other than its own from which a browser should allow making requests.
Laravel makes it super easy to configure this under the config/cors.php
configuration file.
One of the most common mistakes when configuring cors has to do with setting the allowed_origins
. Either they are not set at all, or the entered origin contains a trailing slash (/
).
Consider the examples:
✅ http://localhost:5173
✅ https://spa.tallpad.com
❌ http://localhost:5173/
❌ https://spa.tallpad.com/
An allowed origin should include the schema, hostname, and port (if present), but without a trailing slash.
Mistake 3. Misconfigured stateful domain
When receiving requests, Laravel Sanctum will only try to authenticate requests from domains present in the stateful domains list. Mess this up, and you'll receive 401 responses.
Set the stateful domain by specifying the hostname and port (if present).
Consider the examples:
✅ localhost:5173
✅ tallpad.com
❌ http://localhost:5173/
❌ http://localhost:5173
❌ localhost:5173/
When specifying the stateful domains, make sure you only write the hostname and port (if present).
Mistake 4. Misconfigured cookie domain
Misconfigure this, the browser won't be able to set the correct cookies, and you will receive 419 responses.
Consider the examples:
✅ localhost
✅ tallpad.com
❌ https://localhost:8000
❌ http:://localhost
❌ localhost:8000
But there is something even worse than setting the wrong cookie domain.
Not explicitly setting the cookie domain.
This will make cookies available on the top-level domain but not on subdomains. If your SPA lives on a subdomain, things will quickly become super confusing!
By the time you realize you must set the cookie domain explicitly, you'll probably end up with two sets of cookies, causing Laravel to throw 401
errors.
The excruciating part is that you won't even realize you have two pairs of cookies 🤬. You won't see it unless you visit and inspect the top-level domain.
To check if you have two sets of cookies, go to the top-level domain URL, open the dev tools, go to the Application tab and check the cookies. If you see something like the image below, delete all cookies and retry authenticating after explicitly setting the cookie domain.
Make sure you explicitly set the cookie domain and do it without any scheme, slashes, or ports.
Laravel Vue SPA Authentication setup
For this example, I'll be setting up a Vue app, but it should work pretty much the same with every other front-end framework.
- Create a new vue application, cd into it, install all npm dependencies and run the dev script.
npm init vue@latest
cd spa.sanctum
npm install
npm run dev
Typically, the app will start at http://localhost:5173/
- this will be our allowed origin (without the trailing slash /
!)
- Create a new Laravel application, cd into it, composer require
laravel/breeze
and run the install command withapi
as option.
laravel new sanctum
cd sanctum
composer require laravel/breeze
php artisan breeze:install api
The breeze:install
command will do a couple of useful things:
- deletes all frontend-related files (package.json, vite.config.js, css, js files, blade views, etc)
- adds auth-related routes, controllers, and tests
- pre-configures some of Laravel Sanctum's options
PS: commit before running the breeze:install
command and then use git diff
if you want to see exactly what changed.
- Open the
.env
file and configure the database connection
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=api
DB_USERNAME=root
DB_PASSWORD=yourRootPassword
- Open
database/seeders/DatabaseSeeder.php
and un-comment the factory code creating the test user.
\App\Models\User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
- Migrate and seed
php artisan migrate:fresh --seed
- Serve the laravel application
php artisan serve
Typically, artisan will serve the app at http://127.0.0.1:8000/
. This immediately puts us in a pickle because, remember, both the SPA and Laravel API need to be on the same top-level domain! Our vue application started on http://localhost:5173
and we have Laravel running on http://127.0.0.1:8000
.
However, localhost
actually points to 127.0.0.1
. This means you can also access Laravel at http://localhost:8000
- so now both apps are using the same top-level domain; the only difference is the port (API on: 8000, SPA on 5173).
- Opening the
config/cors.php
file, you should see this:
<?php
return [
'paths' => ['*'],
'allowed_methods' => ['*'],
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
paths
allows you to specify a list of paths that will allow cross-origin requests. Star (*) means "all". You can also use something likeapi/*
- and this will match all paths starting with/api
.allowed_methods
is a list of HTTP methods you'd like to accept (post, put, delete, etc).allowed_origins
, and this one is very important, is the list of origins from which your server will allow cross-origin requests.allowed_origins_patterns
- same as above, but you can pass a regex that will match the origins.allowed_headers
- what headers can be sent in.exposed_headers
- what headers can be exposed to scripts running in the browser.max_age
is used to cache preflight requestssupports_credentials
tells Laravel to share cookies with the SPA origin
- Open the
.env
file and set the frontend url to our Vue SPA url (without trailing slash/
!)
FRONTEND_URL=http://localhost:5173
- Open the
.env
file and set the cookie domain.
Even though right now we aren't using any subdomains (both apps are running on localhost), it's a good idea to set the cookie domain, so you don't forget about it when deploying the Laravel App to production.
Make sure you only write the domain name (no scheme, no port, no slash, no nothing).
SESSION_DOMAIN=localhost
And we are done configuring Laravel!
Testing authentication
Open the Vue project and install axios. Axios is great because it does some nice things out of the box (like taking the XSRF-TOKEN
cookie and set it as a X-XSRF-TOKEN
header when making requests).
npm install axios
Open your App.vue
file, and inside script setup, add an onMounted
hook. Then, inside the hook, configure axios to include credentials and make three requests: one to get and set the XSRF-TOKEN
cookie, one to login the user, and another one to /api/user
- this will test things are working.
onMounted(async() => {
axios.defaults.withCredentials = true;
await axios.get('http://localhost:8000/sanctum/csrf-cookie');
await axios.post('http://localhost:8000/login', {
email: 'test@example.com',
password: 'password'
});
const {data} = await axios.get('http://localhost:8000/api/user');
console.log(data); // should output user details.
});
How Laravel Sanctum works and why is it secure?
Here's the entire flow and logic behind it.
-
Every request is checked and only allowed if coming from a trusted origin (cors).
-
Establish CSRF protection by making a get request at
/sanctum/csrf-cookie
.
The response of the request will set two cookies:
- one cookie with the generated csrf token (XSRF-TOKEN)
- another cookie with the generated session id (yourAppName_session) - the key difference here is that this cookie is set as being
HttpOnly
; these kinds of cookies cannot be read by scripts. Unlike saving tokens in the browser's local storage, there is no way to steal them.
Both cookies will be sent with every subsequent request. The only difference is that the csrf token will also be sent as a X-XSRF-TOKEN
header.
- Once you establish CSRF protection, make a
post
request to/login
Laravel will authenticate the user and update the session if the credentials are correct.
- Subsequent requests are authenticated only if the origin domain is part of the stateful domains list.
Common questions
Here is a list of questions I received while writing this post:
1. Do I need to call /sanctum/csrf-cookie
every time I make an authenticated request?
No, you only do that for any unauthenticated request that isn't a GET
. For example, post
requests to: /login
, /register
, /forgot-password
.
2. Why is the /login
route inside the web.php
file? Can I move it to api.php
?
You should be able to move it, but I wouldn't. At some point, I might need to add token-based authentication for mobile clients, and it would make sense for /api/login
to return a token and /login
to update the session cookie.
3. How do I refresh the csrf token?
You don't. The csrf token is refreshed and prolonged every time a request is made. It will only expire due to user inactivity (no more requests being made) after 2hrs. You can configure the session lifetime in your .env
file. Change SESSION_LIFETIME
to whatever value you want; in minutes.
4. What are preflight requests?
A preflight request is sent before making a cross-origin request, and it's the browsers' way of asking the server if they will accept an intended cross-origin request.
Imagine a conversation going something like this:
Browser: Hey, uhm… can I make a post request from this origin to this endpoint of yours with these headers?
Server: Let me look you up. Ah, yes, this origin is on the list and we accept post requests. Go ahead, send it over.
Browser: *Sends actual request*
And that was it. As you've seen in this post, configuring sanctum isn't that hard; it's just tricky because one mistake can mess everything up!
If you think there's something I missed and should be added to this post, let me know on twitter or youtube.