Laravel Api Auth with React and Sanctum: All You Need To Know

Laravel Api Auth with React and Sanctum: All You Need To Know
Admin
Tuesday, March 28, 2023 6 mins to read
Share
Laravel Api Auth with React and Sanctum: All You Need To Know

One of the ways to become a full-stack developer is to adapt Laravel + React pair. And part of that is authentication. In this tutorial, we will explore how to use Laravel, React, and Laravel Sanctum together to build an API authentication, in two ways:

  • In two-in-one Laravel + React SPA
  • Or, as separate React + API projects

Are you ready? Let's dive in!


Project 1. Laravel SPA: Breeze React Example

To have a quick head start, Laravel Breeze starter kit provides a minimal, simple implementation of all Laravel's authentication features. Laravel Breeze also offers React scaffolding via an Inertia frontend implementation.

First, create a new Laravel project and install Laravel Breeze:

composer require laravel/breeze --dev

After that execute breeze:install Artisan command with React stack and all auth scaffolding will be installed, you should also compile your application's frontend assets:

php artisan breeze:install react
php artisan migrate
npm install
npm run dev

Now you have a full working Single Page Application (SPA). Authentication controllers are placed in the app/Http/Controllers/Auth folder. Let's lookup at the app/Http/Controllers/Auth/AuthenticatedSessionController.php file's store method:

public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
 
$request->session()->regenerate();
 
return redirect()->intended(RouteServiceProvider::HOME);
}

This method is called when you log in to your application. As we can see there are no references to tokens. That's right, React and Inertia scaffolding uses the laravel_session cookie for authenticated sessions and is handled automatically, so no additional implementation is needed.

Let's move forward with the current setup and create a "protected" demo component that is accessible only to authorized users and displays the currently logged-in user's id and name.

  1. First create a new controller app/Http/Controllers/DemoController.php with the following command.
php artisan make:controller DemoController

And this is the content of the file:

<?php
 
namespace App\Http\Controllers;
 
use Inertia\Inertia;
use Illuminate\Http\Request;
 
class DemoController extends Controller
{
public function index()
{
return Inertia::render('Demo/Index');
}
}
  1. Add a route to routes/web.php for this controller, it should be under auth middleware along profile routes:
// ...
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/demo', [DemoController::class, 'index'])->name('demo');
});
// ...
  1. Add a link into the menu for the new demo route in the resources/js/Layouts/AuthenticatedLayout.jsx file, it can be right after the Dashboard:
<NavLink href={route('dashboard')} active={route().current('dashboard')}>
Dashboard
</NavLink>
<NavLink href={route('demo')} active={route().current('demo')}> {/*
Demo {/*
</NavLink> {/*
  1. And finally React page resources/js/Pages/Demo/Index.jsx itself with the following content:
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, usePage } from '@inertiajs/react';
 
export default function Index(props) {
const user = usePage().props.auth.user;
 
return (
<AuthenticatedLayout
auth={props.auth}
errors={props.errors}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Demo</h2>}
>
<Head title="Demo" />
 
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div className="p-6 text-gray-900">My protected content</div>
<div className="p-6 text-gray-900">
<div>Id: { user.id }</div>
<div>Name: { user.name }</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

To display currently logged-in user data using blade files usually, we use auth()->user(). When using React and Inertia equivalent to displaying user data is the usePage() function which gives us access to globally shared data with the session.

If we check the app/Http/Kernel.php file we have two new middlewares added:

\App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,

Interesting is the first middleware. We look up at contents in the app/Http/Middleware/HandleInertiaRequests.php file.

public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => $request->user(),
],
'ziggy' => function () use ($request) {
return array_merge((new Ziggy)->toArray(), [
'location' => $request->url(),
]);
},
]);
}

This is where globally shared data for currently logged-in users is added under the auth key, which corresponds to the usePage().props.auth.user line in a React component.

So far so great, usually if the front end is the only consumer of the backend there's no need to use an API.


Sanctum and SPA Authentication

Suppose you intend to share identical data with both your application and external third-party services that use your API or have any other good reason. In such a scenario, using an API would be preferable. Employing a single "source of truth" eliminates the need to maintain multiple separate locations for the same information.

Laravel Sanctum offers a simple way to authenticate SPA that needs to communicate with Laravel API. It can authenticate using cookies from the Laravel session if you are currently authenticated (stateful) or use API tokens (stateless).

Endpoint to retrieve authenticated user's data is already defined and comes with the default Laravel installation. It is located in the routes/api.php file. As we can see it is protected by auth:sanctum middleware.

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
  1. Update the resources/js/Pages/Demo/Index.jsx component with the following content:
import { useEffect, useState } from 'react'
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
 
export default function Index(props) {
const [user, setUser] = useState([])
 
useEffect(() => {
fetch('api/user')
.then(response => response.json())
.then(setUser);
}, [])
 
return (
<AuthenticatedLayout
auth={props.auth}
errors={props.errors}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">Demo</h2>}
>
<Head title="Demo" />
 
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div className="p-6 text-gray-900">My protected content</div>
<div className="p-6 text-gray-900">
<div>Id: { user.id }</div>
<div>Name: { user.name }</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

Now when the component is loaded it will try to fetch data from api/user. But there's a problem, the request will get rejected with 401 Unauthorized status.

  1. You should add Sanctum's middleware to your api middleware group within your app/Http/Kernel.php file. This middleware is responsible for ensuring that incoming requests from your SPA can authenticate using Laravel's session cookies, while still allowing requests from third parties or mobile applications to authenticate using API tokens:
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class . ':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Sanctum will only attempt to authenticate using cookies when the incoming request originates from your own SPA front end.

When Sanctum examines an incoming HTTP request, it will first check for an authentication cookie and, if none is present, Sanctum will then examine the Authorization header for a valid API token. We will cover API tokens in the next chapter.

In order to authenticate, your SPA and API must share the same top-level domain. However, they may be placed on different subdomains

  1. Setting up environment variables

In order to get Sanctum to authenticate our requests we need to specify which domains of our application should be treated as stateful. This can be done by specifying SANCTUM_STATEFUL_DOMAINS in our .env file.

Requests from the following domains/hosts will receive stateful API authentication cookies. Typically, these should include your local and production domains which access your API via a frontend SPA.

If you have configured the local domain and API is deployed under the same domain it is sufficient to only specify the correct APP_URL in your .env file, for example:

APP_URL=http://myproject.test

Sanctum will try to resolve the SANCTUM_STATEFUL_DOMAINS value by inheriting the domain value from APP_URL if possible. In case your APP_URL is not defined or doesn't match the URL in the browser SANCTUM_STATEFUL_DOMAINS should be defined explicitly:

SANCTUM_STATEFUL_DOMAINS=myproject.test

Sometimes you might run the application using the php artisan serve command, and then API authentication wouldn't work.

In such case if you are accessing your application via a URL that includes a port (127.0.0.1:8000) like using mentioned php artisan serve command, you should define the SANCTUM_STATEFUL_DOMAINS environment variable and ensure that you include the port number with the domain:

SANCTUM_STATEFUL_DOMAINS=127.0.0.1:8000

Project 2. React Client + Laravel API

Sanctum and API Token Authentication

Sanctum allows you to issue API tokens that may be used to authenticate API requests to your application. In this chapter, we will create a separate front-end client to consume API powered by Laravel.

Now for the API server, we can reuse the same application from chapter one because it is already up and running, but if needed, you can create a new Laravel project for this purpose.

  1. To begin issuing tokens for users, your User model should use the Laravel\Sanctum\HasApiTokens trait:
use Laravel\Sanctum\HasApiTokens;
 
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}

It is very likely that you already have the HasApiTokens trait present in your User model if the project was created recently.

  1. To issue a token, we use the createToken method. It returns a Laravel\Sanctum\NewAccessToken instance, but you may access the plain-text value of the token using the plainTextToken property of the NewAccessToken instance.

To allow users to "log in" and "logout" using the API you need to add corresponding routes to your routes/api.php file:

use Illuminate\Support\Facades\Auth;
 
Route::post('/login', function (Request $request) {
if (! Auth::attempt($request->only('email', 'password'))) {
return response(['message' => __('auth.failed')], 422);
}
 
$token = auth()->user()->createToken('client');
 
return ['token' => $token->plainTextToken];
});
 
Route::middleware('auth:sanctum')->post('/logout', function (Request $request) {
$request->user()->currentAccessToken()->delete();
 
return response()->noContent();
});

When a user accesses the /login route, we verify the provided credentials. If they are valid, we generate a new token and return it to the user. Otherwise, we send a message indicating authentication failure with a status code of 422 Unprocessable Entity.

If a user initiates a logout request, the token used to authenticate the request will be deleted. It is important to note that the /logout route is safeguarded by the auth:sanctum middleware, which ensures that only authenticated users can request the removal of their own tokens.

The logic for these routes can be put into controllers, but for clarity, in this tutorial, we will leave it as is.

React Client

In this section, we will introduce how to set up a React Single Page Application. The created project will be using a build setup based on Vite and will consume API from a separate Laravel project.

Make sure you have an up-to-date version of Node.js installed, then run the following command in your command line:

npm create vite@latest

We have selected the following options:

✔ Project name: … myproject
✔ Select a framework: › React
✔ Select a variant: › JavaScript

Once the project is created, follow the instructions to install dependencies and start the dev server:

cd myproject
npm install
npm run dev

When the server starts you will be prompted that the server is ready and the URL to access it will be shown:

VITE v4.2.0 ready in 203 ms
 
âžś Local: http://127.0.0.1:5173/
âžś Network: use --host to expose
âžś press h to show help
  1. First, we need to install React Router so that our application could have routes, and Axios library for requests to API:
npm install react-router-dom axios

Then add the following content to the src/main.jsx file:

import axios from 'axios'
import { BrowserRouter, Routes, Route, redirect } from 'react-router-dom'
 
window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
window.axios.defaults.baseURL = 'http://laravelapi.test/api/'
 
if (localStorage.getItem('token')) {
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}
 
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
redirect('/login')
}
 
return Promise.reject(error);
}
);

We set the X-Requested-With header to tell the server it is an XHR request, and it serves an additional purpose so the server must consent to CORS policies.

The convenience option is axios.defaults.baseURL = "http://laravelapi.test/api/"; so we can omit full URLs in our requests and just type in the relative path of the server's API endpoint.

We are going to store the token in the browser's localStorage with a key token. When the client is loaded it will immediately try to retrieve the token from localStorage and set the Authorization header for future axios requests. This is done with the following code section:

if (localStorage.getItem('token')) {
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}

If any request to the backend fails due to an expired or invalid token with the 401 Unauthenticated status we need to set up an Axios interceptor. The concept of interceptor is basically the same as working with Laravel middleware.

Interceptor clears the token from the storage and Axios header. As a result, the user will be redirected to the login page. Implementation can be observed in the following snippet:

axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
router.push({ name: 'login' })
}
 
return Promise.reject(error);
}
);

The full content of the src/main.jsx file now should look like that:

import axios from 'axios'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, redirect } from 'react-router-dom'
import App from './App'
import './index.css'
 
window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
window.axios.defaults.baseURL = 'http://laravelapi.test/api/'
 
if (localStorage.getItem('token')) {
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}
 
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
redirect('/login')
}
 
return Promise.reject(error);
}
);
 
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
  1. Create a new src/views/Login.jsx component with the following content:
import { useState } from "react";
import { useNavigate } from 'react-router-dom'
 
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, seterrorMessage] = useState('')
 
const navigate = useNavigate()
 
function handleSubmit(event) {
event.preventDefault()
 
axios.post('/login', { email: email, password: password })
.then(response => {
localStorage.setItem('token', response.data.token)
axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
navigate('/user')
})
.catch(error => {
if (error.response.status === 422) {
seterrorMessage(error.response.data.message)
}
setPassword('')
})
.finally(() => setPassword(''))
}
 
return (
<div>
{errorMessage && <div>{errorMessage}</div>}
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input id="email" name="email" type="text" onChange={event => setEmail(event.target.value)} autoComplete="email" />
</div>
<div>
<label>Password</label>
<input id="password" name="password" type="password" onChange={event => setPassword(event.target.value)} />
</div>
<button type="submit">
Login
</button>
</form>
</div>
);
}

Our login form has email and password fields with their values bound to email and password respectively. errorMessage is used to display validation errors for demonstration purposes. When the form is submitted the handleSubmit method will be invoked and the token will be saved in the client:

function handleSubmit(event) {
event.preventDefault()
 
axios.post('/login', { email: email, password: password })
.then(response => {
localStorage.setItem('token', response.data.token)
axios.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
navigate('/user')
})
.catch(error => {
if (error.response.status === 422) {
seterrorMessage(error.response.data.message)
}
setPassword('')
})
.finally(() => setPassword(''))
}

Remember we had set axios.defaults.baseURL = 'http://<YOUR-LARAVEL-API-SERVER>/api' in the src/main.jsx file. So the following call of axios.post('/login', { email: email, password: password }) is equivalent to axios.post('http://<YOUR-LARAVEL-API-SERVER>/api/login', , { email: email, password: password }). The second argument is the form data we are submitting.

then() section will be executed if the authentication attempt was successful. The token will be stored on the client and the Axios header is updated and the user redirected to the User component using the navigate method.

The catch() section is executed if the request to authenticate has been denied due to invalid credentials and the message value will be updated and displayed on the client.

The finally() section is executed always when the request is resolved and will clear the password field in the form.

  1. Create a new src/views/User.jsx component
import { useState, useEffect } from "react";
import { useNavigate } from 'react-router-dom'
import axios from "axios";
 
export default function User() {
const [user, setUser] = useState([])
 
const navigate = useNavigate()
 
useEffect(() => {
axios.get('user')
.then(response => setUser(response.data));
}, [])
 
function logout() {
axios.post('logout').finally(() => {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
navigate('/login')
})
}
 
return (
<div>
<div>ID: {user.id}</div>
<div>Email: {user.email}</div>
<div>
<button type="button" onClick={logout}>Logout</button>
</div>
</div>
);
}

When the User component is mounted it will automatically call the axios HTTP request using the useEffect() hook and will fetch data from the /api/user endpoint setting response data to user.

function logout() {
axios.post('logout').finally(() => {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
navigate('/login')
})
}

The logout method sends a request to the server to delete the token in use so it will become invalid and requests will no longer be valid. After sending the request client ignores what the response server gave and always "forces" logout by deleting the token and clearing Axios Authentication header.

  1. Finally to "glue" all the pieces define routes for Login and User in the src/main.jsx file:
import axios from 'axios'
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route, redirect } from 'react-router-dom'
import App from './App'
import './index.css'
import Login from './views/Login'
import User from './views/User'
 
window.axios = axios
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
window.axios.defaults.baseURL = 'http://demo.test/api/'
 
if (localStorage.getItem('token')) {
axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`
}
 
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
axios.defaults.headers.common['Authorization'] = 'Bearer'
redirect('/login')
}
 
return Promise.reject(error);
}
);
 
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route path="/login" element={<Login />} />
<Route path="/user" element={<User />} />
</Route>
</Routes>
</BrowserRouter>
</React.StrictMode>,
)

And change src/App.jsx to show routed pages by adding Outlet:

import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { Outlet } from 'react-router-dom'
 
function App() {
const [count, setCount] = useState(0)
 
return (
<div className="App">
<Outlet />
</div>
)
}
 
export default App

You now have at your disposal all the essential examples for utilizing Sanctum authentication to consume Laravel API, whether it is for a consolidated application that uses cookies for the stateful session, or for two entirely separate repositories: one for the API and one for the frontend.