DEV Community 👩‍💻👨‍💻

Cover image for Laravel Sanctum Explained : SPA Authentication
Nicolas Bailly
Nicolas Bailly

Posted on

Laravel Sanctum Explained : SPA Authentication

This is going to be a multi-part article about Laravel Sanctum (previously known as "Airlock"), the new Laravel authentication system. I've played with Sanctum a lot in the last few weeks and it appeared to me that while the package itself works really well and does exactly what it says it does, there are A LOT of ways things could go wrong. And yes, it's almost always user error, but it can be incredibly hard to debug and find out what you missed unless you have a basic understanding of what's going on, which is what we'll try and get here.

Note that this is not a complete tutorial (that may come later), so you will still need to read the documentation along with this article

How sanctum works with Single Page applications

If you read the docs, you already know that Sanctum provides several authentication methods : API tokens, SPA Authentication, and Mobile application authentication. It boils down to two different approaches : Stateless authentication (without sessions) and Stateful authentication (with sessions).
When using a single page application that runs in the browser we want to use stateful authentication, because it only relies on a HttpOnly session cookie to identify the user, which cannot be stolen through an XSS attack. We could use stateless authentication (actually that's what most of us did before Sanctum was released, with Laravel Passport), but this gives you a bearer token that you have to store somewhere, and it usually end up in the LocalStorage or a regular cookie that can be stolen through an XSS injection.

Things to know before you start

Most of this is in the docs, but it's really important so I'll summarize here :

  • Both your SPA and your API must share the same top-level domain. They can be on different subdomains though. For example you could have your front-end SPA on mydomain.com and the API on api.mydomain.com, or ui.mydomain.com and api.mydomain.com. But you cannot have on on mydomain.com and another on anothercomain.com. It simply won't work because the login request will come from your SPA, and the browser will not allow the backend to set a cookie for a different domain.
  • You must declare the domain of your SPA as "stateful" in the sanctum configuration file. This is because Sanctum uses a Middleware to force requests from your SPA to be considered as stateful (which is to say it will start a session for those requests). If you forgot to do it or change the domain of your SPA Laravel will not even try to use a session and nothing will work
  • CORS is a pain. Luckily Laravel 7 provides a CORS middleware out of the box, but by default it's configured (in the config/cors.php file) to only apply to routes starting with /api/*, you need to either change this to * or add every path your SPA will call like /login/ or /sanctum/csrf-cookie.

The whole Process

So here's a diagram of what happens when your SPA authenticates to your backend :

Alt Text

It's a little dense, but let's see what happens with each step :

1 : Getting a CSRF token

Although our cookies should be pretty safe, we still don't want to risk a malicious website tricking a user into logging in, so the login route (like all POST routes) is protected by a CSRF token. In a typical page with a form the token is served with the form and injected in a hidden field, but of course our SPA cannot do that, so we'll have to get it manually. Sanctum provides a /sanctum/csrf-cookie route that generates a CSRF token and return it, so the very first thing we need our SPA to do is make a GET request on that route

1a : Dealing with CORS

Since our frontend and backend are on two different subdomains, there's no way the browser will let us make some ajax request without some kind of verification, so the first thing that happens is that it makes an OPTIONS request. If everything is configured correctly, the HandleCors middleware will intercept the request and anwser with the correct authorization headers.

If you notice that your SPA sends an OPTIONS request and never tries to send a GET request look no further, your CORS settings are not properly configured.

1b : retrieving the CSRF cookie

After dealing with CORS the GET request will actually go through, and Sanctum will return the csrf token. Or rather it will return an empty page with an XSRF-TOKEN cookie.
This cookie is not supposed to be used as-is, what your SPA should do is read it, and then put its content into an X-XSRF-TOKEN header when it makes a POST request to login. This is a convention that's used by several frameworks and libraries including Axios and Angular, but you can also do it yourself. Note that Angular is a little picky about this header.

2 : Loggin in

Now we can log-in. Once again the HandleCors middleware will do its magic, and then the EnsureFrontEndRequestsAreStateful Middleware will (as its long name implies) make sure the request creates and uses a new session. This middleware will only be triggered if the domain name of your SPA is listed in the SANCTUM_STATEFUL_DOMAINS variable of your .env file, so make sure it's correctly configured.

If everything works, a new session will be created and the corresponding cookie will be returned.

Note that the cookie will be set to the domain declared in the SESSION_DOMAIN of your .env file, which should be your top-level domain preceded by a .. So if you use mydomain.com and api.mydomain.com you want to set the SESSION_DOMAIN to .mydomain.com so that both subdomains will be allowed to access it.

Also, the documentation recommends you use scaffolding, but it seems to me that it defeats the purpose of making an SPA. You don't want your typical redirect to /home either, so you can make your own LoginController with a very simple login method like that :

    public function login(Request $request)
    {
        if (Auth::attempt($request->only(['email', 'password']))) {
            return response(["success" => true], 200);
        } else {
            return response(["success" => false], 403);
        }
    }
Enter fullscreen mode Exit fullscreen mode

3 : making requests to your app

From there on, you're SPA is connected like any stateful application. You can use the sanctum guard to protect routes and it will check that the user of the SPA is correctly authenticated.

That's it ! I hope this can be useful to someone. In the next weeks I'll do a complete write-up on how to use Sanctum with an Angular SPA, and with an Ionic App.

Also if you have any trouble with Sanctum, feel free to leave a comment and I'll try to help !

Top comments (22)

Collapse
 
nomikz profile image
nomikz

So if front and back on the different domains, then sanctum is not usable?

Collapse
 
nicolus profile image
Nicolas Bailly • Edited on

If front and back are on completely different domain, Sanctum is not usable in its Stateful (or "SPA") mode because it relies on sessions and you can't have a session cookie work over different domains.

You could use it in it Stateless (or "API") mode though, which I haven't covered in this article and haven't found time cover yet. It would then work as a mobile app (see description here : laravel.com/docs/7.x/sanctum#issui...) so you'd basically have to make an ajax request to exchange an e-mail and password for a Bearer token, and then pass this token in every subsequent request in the "Authorization" header like so :

Authorization: Bearer TheTokenIJusteGotFromTheApiThroughAnAjaxRequest
Enter fullscreen mode Exit fullscreen mode
Collapse
 
barcodelllllll profile image
lllllllll

Thank you very much for the post, but could you do another one telling how it is done from another domain with Sanctum? I've been dealing with it for two days.

Thread Thread
 
nicolus profile image
Nicolas Bailly

No problem, I'll see what I can do over the week-end. It's actually long overdue.

Can you tell me a bit more about what you're trying to do so I can make sure to cover your usecase ? Like do you use a front-end framework ? Is there a particular reason you can't have both on the same domain ?

Thread Thread
 
barcodelllllll profile image
lllllllll • Edited on

I made a game portal in a classic Laravel web (front and back together). When i finished it, boss tell me he want to separate back and front. So then, i made the classic web API and some copies of a "Client API" / SPA which have the classic web front and which consumes the resources of the classic web "API".

Everything went well until deploying Sanctum in production. In local it worked perfectly but of course, it used php artisan serve for the API and the API clients, both were localhost. Anyway, I was already using token authentication, so I think I'm on the right track (although I think Passport would have been the best for this...) Thanks

Thread Thread
 
nicolus profile image
Nicolas Bailly

Just to be sure we're on the same page : you can separate back and front by still using the same top level domain (for example "gameportal.com" for your front-end and api.gameportal.com for your backend. That's the recommended setup described in this article.

Also what does your "frontend" look like ? Is it an SPA built with something like Angular, Vue or React that makes ajax request to your API ? Or is it another Laravel app that makes requests to your API from the server using Curl or Guzzle ?

Either way, Passport is way overkill for what you're trying to do. According to Taylor Otwell himself, the only valid usecase for Passport is if you want a third party websites to allow their users to connect with their account on your site (Something like "Connect with Facebook" or "Connect with Google", except it would be "Connect with gameportal.com").

Thread Thread
 
barcodelllllll profile image
lllllllll

The SPA is another Laravel app that makes requests to my API from the server using Guzzle (Http::class).

API and SPA's have to be on different domains.

Yes, yes, i know Passport is, after all, to connect third party webs.

Thanks!!!

Thread Thread
 
nicolus profile image
Nicolas Bailly

OK... That sounds like a pretty weird and inefficient setup but if those are the requirements ¯\(ツ)

I've tested it out locally and thrown out a guide, I'm not publishing it yet because it's nowhere near as complete as I'd like, but let me know if this helps with your situation : dev.to/nicolus/laravel-sanctum-exp...

I've tested locally on two different domains and it works fine, there's no reason it shouldn't work in production unless you have some other issues like a firewall blocking requests between your apps or a DNS issue.

Thread Thread
 
barcodelllllll profile image
lllllllll

Thanks for the reply as always and sorry for my late answer (girl and kids are guilty)

The post you have shared is just like i did.

Why do you mean with inefficient?? ¬¬ Tell me plz.

May be its about a server config which refuses the login post request, but, before deploying Sancum I was making all the requests to serve the page with the GET method (there was no authentication in those days) and there was no problem, so it is possible that the changes I made in the API have made it always redirect with status 301 request to the POST type login endpoint, it may be that, as I say, it's something about .htaccess, something about CORS, I don't know why the hell it redirects it.

I also had to save the token in session variable XD.

Collapse
 
nomikz profile image
nomikz

Thanks for a quick reply.
So it seems to me that sanctum is just another abstraction for passport which was an abstraction for jwt.

Thread Thread
 
nicolus profile image
Nicolas Bailly

Well, the way you use it in Stateless mode is very similar to Passport indeed, but it is definitely not an abstraction for Passport, and it doesn't use JWT etiher.

The token that's generated is just an 80 characters random token that's stored in the database and it doesn't contain any information in itself. The point of Sanctum is that it is much much simpler than Passport (which is a full blown Oauth2 server) and simpler than using JWT tokens (which are not inherently secure).

Collapse
 
digitlimit profile image
Digitlimit Inc

Nice tutorial.
I have a Vue SPA on windows frontend.mydomain.test/ and Backend laravel API on Ubuntu server backend.mydomain.test/.

I have these in .env file

SESSION_DOMAIN=mydomain.test
SANCTUM_STATEFUL_DOMAINS=frontend.mydomain.test

I have also configured core and Sanctum middleware.

config/cors.php

 'paths' => [
        'api/*',
        'graphql',
        '/login',
        '/logout',
        '/sanctum/csrf-cookie'
    ],
``

On a request attempt to  http://backend.mydomain.test/sanctum/csrf-cookie, I receive the error message below. What am I missing?

Access to XMLHttpRequest at 'backend.mydomain.test/sanctum/csrf...' from origin 'frontend.mydomain.test:8000' 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.



Collapse
 
nicolus profile image
Nicolas Bailly

Hi !

Your session domain should be

SESSION_DOMAIN=.mydomain.test

With a . in front of the domain, so that it can be accessed by both the frontend and the backend.

However I doubt that's what is causing your issue with CORS. Make sure the front-end domain is listed in the 'allowed_origins' part of the cors.php config file (or that it's set to ['*']). The paths looks OK, but just in case you could try to replace them with ['*'] too just to make sure there isn't something funky going on there.

Also make sure you have

'supports_credentials' => true

in cors.php.

If none of that helps, have a look at the 'OPTIONS' request in the developer tools of your browser, and check if it returns successfully and if it has the required headers (Access-Control-Allow-Origin etc.) . Sometimes it looks like CORS is failing when really it's a completely unrelated error that makes your app crash with an 500 error before it could send the correct headers.

Collapse
 
eleftrik profile image
Erik D'Ercole

Nice article! Thanks for your clear explanation.
I think Laravel official documentation is not as clear as you are while depicting the difference between the two modes (stateless and stateful - I mean, applied to Sanctum).
In my case, I have a SPA built with Angular (example.com) and a Laravel + Sanctum API (api.example.com). But, in the future, there could be another Vue/Angular frontend on a completely different domain, so I think for me it's better to stick with the stateless authentication (as I always did with Passport).
In your opinion, why should I use stateful authentication (when using a subdomain)? CSRF cookie apart, is there any advantage?
Thank you!

Collapse
 
hungnv234 profile image
Hung Nguyen Van

Thanks for sharing.
In my case, I have 2 SPA: app.mydomain.com and cms.mydomain.com.
Laravel API is: api.mydomain.com and I use sanctum too.

When I login to cms.mydomain.com, the browser has set cookie success and I login success. But when I access app.mydomain.com, browser get same cookies of cms.mydomain.com and I can't login, the request login return status 302 found.

Do you have any idea for me? Thanks

Collapse
 
javierpomachagua profile image
Javier Pomachagua

Hi! I have api.example.com (laravel backend) and app.example.com (nuxt client). I can get successful the cookie but when I login it shows me "Unauthenticated". How do you put your .env?

Collapse
 
cryptoristic profile image
Cryptoristic • Edited on

thanks for this tutorial,.

It is possible I have 2 SPA, 1 is my landing page where it will pull data from the backend thru API and another is the Admin portal where user can manage the information. All these 2 SPA shared 1 backend API. How to configure this one? Can I put 2 more than 1 values in SANCTUM_STATEFUL_DOMAINS. TIA

Collapse
 
laugre profile image
Laurent Garnier

Hi there, thx for these explanations, useful to understand better sanctum.
I'm using react as a spa front and sanctum for authentication.
I'm wondering how to manage session lifetime when using sanctum.
Do we have to use 'expiration' preset in sanctum config ?
...or 'lifetime' preset in session config is sufficient ?
and so what 'expiration' preset is about to do ?

Collapse
 
_devlim profile image
I'M DEVLIM ⛱️ • Edited on

How do you guys test this in localhost environment? And I mean the url localhost:PORT_NUMBER?

I facing various issues when using docker compose, Laravel 8, and nuxt.js(separate repo) 🤣

Collapse
 
evnikhil profile image
EVNIKHIL

Hi I dint got you. the COOKIES will be set on the header after we Auth::attempt() and can use middle ware sanctum then to authenticate?

Collapse
 
suro11777 profile image
S.T. • Edited on

SESSION_DOMAIN=.mydomain.local
SANCTUM_STATEFUL_DOMAINS=api.mydomain.local

'supports_credentials' => true

cookies are not saved

Collapse
 
leob profile image
leob

Super, more of these kind of in-depth articles, instead of the "listicle" dross that seems to dominate dev.to these days ...

Join us at DEV Want to join the conversation?
 

It's easy! Become a DEV member to follow this post, comment, and more.