Using Sanctum to authenticate a React SPA

Published on by

Using Sanctum to authenticate a React SPA image

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 frontend
cd 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-dom
npm 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:auth
php 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 unless withCredentials is set to true 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 the Cookie 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:

Alex Pestell photo

Full stack developer at fortrabbit in Berlin.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Forge
Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Bespoke software solutions built for your business. We ♥ Laravel

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
Larafast: Laravel SaaS Starter Kit logo

Larafast: Laravel SaaS Starter Kit

Larafast is a Laravel SaaS Starter Kit with ready-to-go features for Payments, Auth, Admin, Blog, SEO, and beautiful themes. Available with VILT and TALL stacks.

Larafast: Laravel SaaS Starter Kit
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector

The latest

View all →
Anonymous Event Broadcasting in Laravel 11.5 image

Anonymous Event Broadcasting in Laravel 11.5

Read article
Microsoft Clarity Integration for Laravel image

Microsoft Clarity Integration for Laravel

Read article
Apply Dynamic Filters to Eloquent Models with the Filterable Package image

Apply Dynamic Filters to Eloquent Models with the Filterable Package

Read article
Property Hooks Get Closer to Becoming a Reality in PHP 8.4 image

Property Hooks Get Closer to Becoming a Reality in PHP 8.4

Read article
Asserting Exceptions in Laravel Tests image

Asserting Exceptions in Laravel Tests

Read article
Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4 image

Reversible Form Prompts and a New Exceptions Facade in Laravel 11.4

Read article