Using Sanctum to authenticate a React SPA
Published on by Alex Pestell
Sanctum is Laravel’s lightweight API authentication package. In this tutorial, I’ll be looking at using Sanctum to authenticate a React-based single-page app (SPA) with a Laravel backend. Assuming the front- and back-end of the app are sub-domains of the same top-level domain, we can use Sanctum’s cookie-based authentication, thereby saving us the trouble of managing API tokens. To this end, I’ve set up Homestead to give me two domains: api.sanctum.test
, which points to the public
folder of backend
(the new Laravel project which we’ll create), and sanctum.test
, which points to a completely separate directory, frontend
. I’ve also provisioned a MySQL database, sanctum_backend
.
Links to the final code can be found at the end of this article.
The backend
Let’s start with the API:
laravel new backend
Our API could be anything – let’s say it’s for a library, and we have just one resource, books
. We can create most of what we need with one artisan command:
php artisan make:model Book -mr
The -m
flag generates a migration, while -r
creates a resourceful controller with methods for all the CRUD operations you will need. For this tutorial we will only need index
, but it’s good to know this option exists. So, let’s create a couple of fields in the migration:
Schema::create('books', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('author'); $table->timestamps();});
…and run the migration (don’t forget to update the .env
file with your database credentials):
php artisan migrate
Now update DatabaseSeeder.php
to give us some books (and a user for later):
Book::truncate();$faker = \Faker\Factory::create();for ($i = 0; $i < 50; $i++) { Book::create([ 'title' => $faker->sentence, 'author' => $faker->name, ]);}User::truncate();User::create([ 'name' => 'Alex', 'email' => 'alex@alex.com', 'password' => Hash::make('pwdpwd'),]);
Now run php artisan db:seed
to seed this data. Finally, we need to create the route and the controller action. That’s simple enough. Add this to the routes/api.php
file:
Route::get('/book', 'BookController@index');
and then in the index
method of BookController
, return all the books:
return response()->json(Book::all());
Of course in a real API, we would probably want to transform those objects using something like Laravel’s API resources, but this will do for now. Now if we hit api.sanctum.test/api/book
in our browser or HTTP client of choice (Postman, Insomnia, etc), you should see a list of all the books.
The frontend
To create the SPA, I’ll use create-react-app
:
npx create-react-app frontendcd frontend
We’re going to want to use the react-router-dom
package to add routing to the app, as well as Axios to make HTTP requests. Once that’s done, start the app:
npm install axios react-router-domnpm start
Now let’s create a quick Books
component that will use Axios to call the books endpoint and show the books in an unordered list:
import React from 'react';import axios from 'axios'; const Books = () => { const [books, setBooks] = React.useState([]); React.useEffect(() => { axios.get('https://api.sanctum.test/api/book') .then(response => { setBooks(response.data) }) .catch(error => console.error(error)); }, []); const bookList = books.map((book) => <li key={book.id}>{book.title}</li> ); return ( <ul>{bookList}</ul> );} export default Books;
Reference this component in App.js
and we’re good to go:
import React from 'react';import { BrowserRouter as Router, Switch, Route, NavLink } from 'react-router-dom';import Books from './components/Books'; const App = () => { return ( <Router> <div> <NavLink to='/books'>Books</NavLink> </div> <Switch> <Route path='/books' component={Books} /> </Switch> </Router> );}; export default App;
Visit the books
page in the browser, and you’ll see the list of books returned by the endpoint.
Now, let’s say we want to gate-keep who gets to look at these books. Or maybe the API displays different books to different users. This is where Sanctum comes into play. So, back in the backend
directory, let’s require the Sanctum package:
composer require laravel/sanctum laravel/ui
We’re also requiring the laravel/ui
package because it gives us some authentication boilerplate. To create it, and to publish the Sanctum config, run:
php artisan ui:authphp artisan vendor:publish
…and add the sanctum
middleware to the route:
Route::middleware('auth:sanctum')->get('/book', 'BookController@index');
Since our goal is to have the frontend – sanctum.test
– communicating with the backend – api.sanctum.test
– it makes sense from now on to build our SPA with npm run build
. This way we can visit the SPA at sanctum.test
(rather than the development server’s default localhost
). This will make more sense when we come to configuring Sanctum’s stateful domains.
So, build the frontend, and try to hit that books
page again. If you look at the request in your browser’s dev tools, you should see a 401 Unauthenticated
error: we need to log in. Here’s the SPA’s Login
component:
import React from 'react';import axios from 'axios'; const Login = (props) => { const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); axios.post('https://api.sanctum.test/login', { email: email, password: password }).then(response => { console.log(response) }); } return ( <div> <h1>Login</h1> <form onSubmit={handleSubmit}> <input type="email" name="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required /> <input type="password" name="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required /> <button type="submit">Login</button> </form> </div> );} export default Login;
It’s just a basic form, that uses Axios to post an email and password to the backend’s login
route and log the response. Add it to the App
component:
import Login from './components/Login';[...]<Switch> <Route path='/books' component={Books} /> <Route path='/login' component={Login} /></Switch>
Now, visit the login page, fill out the user’s details (seeded above), and hit “Login”. Oops! Take a look at the console: it’s giving us a “Cross-Origin Request Blocked” error.
A digression on CORS
The same-origin policy is a security measure embedded in browsers, which prevents scripts running on one origin (where an origin is defined by its scheme [http
, https
, ftp
, etc], hostname and port number) from accessing data stored on another origin. In our context, this has particular applicability to Fetch / XMLHttpRequest calls. The same-origin policy, while it allows us to make calls to other domains (hence opening us up to CSRF attacks, for which see later), does not allow us to read responses from other domains.
CORS (Cross-Origin Resource Sharing) is a browser solution to this issue: it allows you to send an Origin
header with your request, while the server’s response has an Access-Control-Allow-Origin
header. If the two match, then the response is approved and can be received by the browser.
All well and good, but if you look in the network tab, you will see that we don’t even get as far as making a POST
request. In fact, all we see is an OPTIONS
request. Why is that? Well, the reason is that our request doesn’t qualify as a so-called “simple request”, because its Content-Type
header is application/json
. This makes it a “preflighted request”: before the actual request is sent, a “preflight” OPTIONS
request is sent to the server, which will respond with a set of headers from which the browser can determine whether to proceed to make the actual request. Since our Laravel app isn’t yet set up for CORS, it doesn’t send any Access-Control-
headers back, and so the request proper doesn’t take place. The front-end side is actually covered for us, because the browser automatically sends the Origin
header with the request. So we just need to set up the backend.
Actually, as of Laravel 7 the framework comes with a CORS middleware out of the box. It’s configurable in the cors
config file. Open it up, and you’ll see that allowed_origins
is by default set to *
– that is, everything can make read requests. So why isn’t it working? Well, a little further up there’s a paths
key, which allows anything in the api
namespace. But our login route by default is in the root namespace: /login
. So let’s add 'login'
to the paths
array. Now fill out the login form again and submit it.
CSRF
A new error! 419
. Check the response: “CSRF token mismatch”. On to our next issue! CSRF stands for “Cross-Site Request Forgery”: it’s a way for a malicious agent to execute actions in an authenticated environment. An example, from the OWASP guide: You are logged in to your online banking website. Via social engineering, you are tricked into visiting a website while you are still logged in to the bank’s site. This “visit” to the hacker’s URL could be hidden in a 0x0 image in an email, or an enticing link, or whatever. Anyway, this URL will hit the bank’s API and do something awful with your account. Because you’re already logged in to the bank, it won’t require going through any authentication steps. Horror ensues.
How to get around this? One way, the way we will pursue here, is to get the server to send a random token in a cookie to the client, which then includes the token as a custom header with every request to the server. If we run php artisan route:list
, we’ll see that the login
route belongs to the web
middleware group, which includes the VerifyCsrfToken
middleware. In its handle
method we see this condition:
if ( $this->isReading($request) || $this->runningUnitTests() || $this->inExceptArray($request) || $this->tokensMatch($request))
If this condition evaluates to false
, a TokenMismatchException
is thrown. Now, since we are not reading (we’re sending a POST
request), and not running unit tests, and there is nothing configured as an exception, it will run the tokensMatch
method. And since we haven’t sent a token, this will also fail, and so we get the exception.
Ok, so this sets up the gate-keeping for us. But how do we get the CSRF token in the first place? If we were staying on the server side, we could get Laravel to pass it within the framework, from a controller to a view, for example. But our view is not served by the framework, so somehow the framework needs to send the CSRF token to us. The api
auth guard won’t do that for us out of the box. This is where Sanctum comes in. Sanctum will allow us to ask for a CSRF token, which we can then pass in our headers. If you run
php artisan route:list
you’ll see a new route there: GET /sanctum/csrf-cookie
. (How does the framework know about this? It comes from the defineRoutes
method, which is in the SanctumServiceProvider
‘s boot
method, which in turn was triggered when we ran artisan vendor:publish
.)
So let’s make our first call to the CSRF route. Back in the login code for the frontend, I’m going to modify the Axios call that’s made when you submit the login form. First it will make a call to request the CSRF token; then it will make the login
call:
axios.get('https://api.sanctum.test/sanctum/csrf-cookie') .then(response => { axios.post('https://api.sanctum.test/login', { email: email, password: password }).then(response => { console.log(response) }) });
Fill in the form, hit return, and… Error! “Cross-Origin Request Blocked”. I thought we’d dealt with this? We had: but now we need to add this new route to our list of paths
in the cors
config file:
'paths' => ['api/*', 'login', 'sanctum/csrf-cookie'],
Now, before you hit submit the form again, open the browser’s dev tools and look in the “Storage” tab (Firefox) or the “Application” tab (Chrome). Hopefully, you’ll see no cookies in there (if you do, delete them). Now submit the form. And… still no cookies! What’s going wrong here?
Take a look in the Network tab: your call to sanctum/csrf-cookie
is getting a 204
response, which is good. Click on the request and then click on the Cookies
tab: you’ll see two cookies, the Laravel session cookie and the one we want, XSRF-TOKEN
. But if you go to the browser storage these cookies aren’t being saved. Why not?
Well, this has to do with the scope of the cookie. As the MDN document suggests, the Domain
directive will allow you to specify subdomains to which the cookie is applicable. Let’s take a look at the anatomy of the XSRF-TOKEN
cookie, which is visible in the response headers to the network request:
XSRF-TOKEN=<token>; expires=Sat, 02-May-2020 21:40:15 GMT; Max-Age=7200; path=/; samesite=lax
Sure enough, there is no domain
directive there. Let’s add it in our backend’s .env
file:
SESSION_DOMAIN=sanctum.test
Now, resubmit the login request, and the cookies are still not listed… This is because we’re missing one more piece of the puzzle in our frontend. If we look at the MDN docs, we see the following:
XMLHttpRequest
responses from a different domain cannot set cookie values for their own domain unlesswithCredentials
is set totrue
before making the request
So we need to set withCredentials
to true
in our Axios configuration. Since we’re going to need to do that for all requests, let’s refactor the SPA code to centralize the API configuration. Create a new folder services
in the src
directory, and add a file api.js
with the following contents:
import axios from 'axios'; const apiClient = axios.create({ baseURL: 'https://api.sanctum.test', withCredentials: true,}); export default apiClient;
Now we can import that in our Book
and Login
components:
import apiClient from '../services/api';
And instead of calling axios
, we call apiClient
, omitting the hostname since we’ve defined that in the baseURL
of our Axios config:
apiClient.get('/sanctum/csrf-cookie') .then(response => { apiClient.post('/login', { email: email, password: password }).then(response => { console.log(response) }) });
Now, log in again, and look at the browser tools for the cookies: this time they should appear. But if you check the console, you’ll see that “Cross-Origin Request Blocked” error again, but this time with a new reason: “expected ‘true’ in CORS header ‘Access-Control-Allow-Credentials’”. To be extra safe, the browser will only perform this request if the server has this flag set to true. We can do this by setting supports_credentials
to true
in cors.php
.
Now if you log in with the correct credentials (the ones you seeded earlier on), you will see a 204
response from the login request. This is good: you are authenticated. But if you head to the “Books” page, you’ll still get the 401 Unauthenticated
error when Axios calls the book
endpoint. The way to fix this is with Sanctum’s “stateful domains”. Open up app/Http/Kernel.php
, and add the EnsureFrontendRequestsAreStateful
middleware to the api
group:
'api' => [ EnsureFrontendRequestsAreStateful::class, 'throttle:60,1', \Illuminate\Routing\Middleware\SubstituteBindings::class,],
Let’s take a look at the handle
method of this class to see what it does:
config([ 'session.http_only' => true, 'session.same_site' => 'lax',]);
First, it overrides the session
config. It sets http_only
to true
, meaning that a client-side script (for example a malicious script that is using XSS to try to attack your app) has no access to the token. (As this OWASP article says, “the majority of XSS attacks target theft of session cookies”.) It also sets same_site
to “lax”. According to the MDN docs, this will prevent cookies being sent for cross-site requests except for when the request comes from a link to your site from another site (this blog post does a good job of explaining why that’s useful).
return (new Pipeline(app())) ->send($request) ->through(static::fromFrontend($request) ? [ // Middleware ] : []) ->then(function ($request) use ($next) { return $next($request); });
To understand this part of the method, it’s helpful to know that Laravel’s middleware is processed by a Pipeline, which is a Laravel utility class that allows you to concatenate an array of pipes to send data through. If you look at Illuminate\Foundation\Http\Kernel.php
‘s sendRequestThroughRouter
method, you’ll see similar code to the above:
return (new Pipeline($this->app)) ->send($request) ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware) ->then($this->dispatchToRouter());
So what Sanctum’s EnsureFrontendRequestsAreStateful
middleware does is actually insert more middleware. But only if the request is coming from the frontend – that’s the purpose of this check:
static::fromFrontend($request) ? [ // some middleware] : []
If the request is coming from the frontend, queue up this middleware, otherwise, just give the pipeline an empty array. The static method fromFrontend
looks at the referer
header: if it contains the string you’ve set in the Sanctum config, it will know the request should be put through the middleware specific to Sanctum. This string that it compares the referer
header with can be set with the SANCTUM_STATEFUL_DOMAINS
variable in .env
:
SANCTUM_STATEFUL_DOMAINS=sanctum.test
And what is the Sanctum-specific middleware?
[ config('sanctum.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class), \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, config('sanctum.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class),]
These four middleware pipes are standard: if you take a look at the web
middleware in Kernel.php
, you’ll see all four of them there.
-
EncryptCookies
: Encrypting a cookie means that even if an attacker can gain access to the cookie, modifying its content will result in the cookie being rejected by the server when it is sent back. -
AddQueuedCookiesToResponse
: Handles any cookies that have been queued with theCookie
facade. -
StartSession
: Sets up the Laravel session along with its session cookie, which it adds to the response. -
VerifyCsrfToken
: Checks that everything’s in order with the CSRF token.
Authentication
Now, adding this middleware sorts out the cookie process. But the actual authentication occurs because we’ve set auth:sanctum
in our API route. This means: use the “sanctum” guard to authenticate. But if we look at the Sanctum guard class, something seems odd. According to the docs for adding custom guards, a custom guard has to implement the Illuminate\Contracts\Auth\Guard
interface. But none of this interface’s methods are included in this guard, which in any case has no implements
keyword in the class definition. Instead, we just have this __invoke
magic method.
Let’s look at the SanctumServiceProvider
to clear this up. It uses the $auth->extend
method as advised in the docs:
$auth->extend('sanctum', function ($app, $name, array $config) use ($auth) { return tap($this->createGuard($auth, $config), function ($guard) { $this->app->refresh('request', $guard, 'setRequest'); });});
This is confusing, so let’s break it down into smaller parts. The tap
command is a short-hand way of saying “create the guard, then pass it to the closure in the second argument; then return the guard”. Let’s look at createGuard
:
return new RequestGuard( new Guard($auth, config('sanctum.expiration'), $config['provider']), $this->app['request'], $auth->createUserProvider());
First of all, we can see that this returns an instance of RequestGuard
, which, since it implements Guard
, satisfies the extend
method’s argument type. This RequestGuard
takes a closure as its first argument, which in our case is Sanctum’s Guard
class. The only difference is that the “closure” (Sanctum’s Guard
class) is a class with an __invoke
magic method: you can think of this kind of class as a closure-with-state: it gives you a simple invokable function which also can have properties.
RequestGuard
then uses the callback to return a user. Here are the relevant lines in the Sanctum guard:
if ($user = $this->auth->guard(config('sanctum.guard', 'web'))->user()) { return $this->supportsTokens($user) ? $user->withAccessToken(new TransientToken) : $user;}
The first line gets the user from the web
guard (since we are using the usual web authentication routes to login). If a user is found, the guard returns it; otherwise, nothing is returned.
And now, once you’ve set the SANCTUM_STATEFUL_DOMAINS
environment variable, you should be able to log in and view the books page as an authenticated user.
Finishing the SPA
So, now we have a working authentication system on the backend, we can finish off the front-end. This part of the article isn’t explicitly related to Sanctum, so feel free to ignore it.
First, we want some state in the App
component to show whether the user has logged in, defaulting to false
:
const [loggedIn, setLoggedIn] = React.useState(false);
Let’s add a method called login
which sets this variable to true
:
const login = () => { setLoggedIn(true);};
Now we can pass this method to the Login
component:
<Route path='/login' render={props => ( <Login {...props} login={login} />)} />
then in our handleSubmit
method call this login
method, after checking that we have got the expected 204
response from calling the login route:
apiClient.get('/sanctum/csrf-cookie') .then(response => { apiClient.post('/login', { email: email, password: password }).then(response => { if (response.status === 204) { props.login(); } }) });
(I also have some logic to redirect to the homepage after logging in – take a look at the final repo to see that.) Now that the parent App
component knows when a user is logged in, it can pass this to the Books
component so that it can act accordingly:
<Route path='/books' render={props => ( <Books {...props} loggedIn={loggedIn} />)} />
Now, if loggedIn
is false
, the Books
component knows not to try to load the books and instead to show the user a helpful message:
React.useEffect(() => { if (props.loggedIn) { apiClient.get('/api/book') .then(response => { setBooks(response.data) }) .catch(error => console.error(error)); }});// ...if (props.loggedIn) { return ( <ul>{bookList}</ul> );}return ( <div>You are not logged in.</div>);
How about logging out? Let’s modify the current Login link to conditionally display a Logout button if the user is logged in, and a link to the Login page if they’re not logged in.
const authLink = loggedIn ? <button onClick={logout}>Logout</button> : <NavLink to='/login'>Login</NavLink>;return ( <Router> <div> <NavLink to='/books'>Books</NavLink> {authLink} </div> <Switch> <Route path='/books' component={Books} /> <Route path='/login' render={props => ( <Login {...props} login={login} /> )} /> </Switch> </Router>);
Now we can add a logout
method. Laravel’s auth scaffolding provides us with a POST
route to logout, so we can add a method to the App
component:
const logout = () => { apiClient.post('/logout').then(response => { if (response.status === 204) { setLoggedIn(false); } })};
Try this, and… oops! Another CORS error. Add logout
to the paths
array in our cors.php
config file:
'paths' => ['api/*', 'login', 'logout', 'sanctum/csrf-cookie'],
Now login, log out again and you should see the menu item updating. And after you’ve logged out, you won’t be able to access the books page.
The final step is to save the loggedIn
boolean to the browser’s storage. If we don’t do this, when the user refreshes the browser, the SPA will reset the user to being not logged-in. We can use the browser’s sessionStorage
API for that:
const [loggedIn, setLoggedIn] = React.useState( sessionStorage.getItem('loggedIn') == 'true' || false);const login = () => { setLoggedIn(true); sessionStorage.setItem('loggedIn', true);};const logout = () => { apiClient.post('/logout').then(response => { if (response.status === 204) { setLoggedIn(false); sessionStorage.setItem('loggedIn', false); } })};
The final code for the backend and frontend can be found here: