This article is written in the context of using Inertia.js, but the concept and code snippets work anytime you're using the axios
package in your Laravel project.
I'm a huge fan of Inertia.js when building applications with Laravel. If you're unfamiliar with it, go to their site and read up on it.
A common issue when doing an SPA-like application, like when using Inertia, is that you'll run in to CSRF mismatch exceptions (read more about the what and why of CSRF here). Inertia mentions a way to make the user aware in a friendly way, but I still find that lacking as far as a good user experience.
The user has no concept of what a CSRF token is or that it's needed to make some requests in the application. If they come back to their session after it being idle, the token will have expired. If they're in the middle of doing something and attempt to save, telling them that the "page has expired" will be frustrating, and it's not very intuitive that they will need to refresh the page to solve the problem.
I've found that by adding a simple axios interceptor and an endpoint to create a fresh token, it can be seamless for the user and still serve its security purpose.
Adding the endpoint
First, let's add the endpoint for getting a fresh token.
php artisan make:controller RefreshCsrfTokenController --invokable
This command will generate an invokable (single-action) controller.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class RefreshCsrfTokenController extends Controller
{
/**
* Handle the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function __invoke(Request $request)
{
//
}
}
The functionality we need to generate a fresh token is only one line!
$request->session()->regenerateToken();
That creates a new token and stores it in our session for us. After regenerating a new token, we can return a simple json response. The completed controller function looks like this:
public function __invoke(Request $request)
{
$request->session()->regenerateToken();
return response()->json();
}
Finally, we need to add the endpoint to routes/web.php
.
Route::get('/csrf-token', \App\Http\Controllers\RefreshCsrfTokenController::class);
Now that the backend is ready, let's handle the frontend.
Adding an axios interceptor
Axios, which is the popular library that Inertia uses to make requests, has a feature called interceptors which allows us to "intercept" the response immediately after it is made and do some custom handling before Inertia takes over the response.
I like to organize this behavior in a plugins
directory in my Vue application and include it in the main app.js
file. Let's create a file in resources/js/plugins
called https.js
.
We need to import the axios
library and set up the interceptor.
// resources/js/plugins/http.js
import axios from 'axios'
axios.interceptors.response.use()
// Add this line in resources/js/app.js
import './plugins/http'
The axios.interceptors.response.use
function accepts two arguments, a successful (2xx
status code) response handler and an error (non-2xx
status code) handler. For the successful response, we don't need to do anything.
axios.interceptors.response.use(response => response)
This just returns the response normally without any processing.
For our error handler, we need to check for a status code of 419
, which is what Laravel sends when it throws an Illuminate\Session\TokenMismatchException
. I'm going to be using lodash/get
(lodash
comes with the default Laravel project) for a clean way to get a variable from an object that may not have all the properties we're wanting.
import axios from 'axios'
import get from 'lodash/get'
axios.interceptors.response.use(response => response, err => {
const status = get(err, 'response.status')
if (status === 419) {
// Do something
}
return Promise.reject(err)
})
If the status isn't 419, we're going to reject the promise as usual.
In the error response, axios gives us the configuration that was used to make the initial request. Basically, we just need to do a couple things at this point:
- Set a new token using the
/csrf-token
endpoint that we made at the beginning. - Retry the original request having the regenerated token.
I'm going to make the callback async
so we can call our /csrf-token
endpoint using await
. Here's the full implementation.
import axios from 'axios'
import get from 'lodash/get'
axios.interceptors.response.use(response => response, async err => {
const status = get(err, 'response.status')
if (status === 419) {
// Refresh our session token
await axios.get('/csrf-token')
// Return a new request using the original request's configuration
return axios(err.response.config)
}
return Promise.reject(err)
})
At this point, we actually only needed to add a couple lines of code to make a seamless experience for the end user.
Testing
Let's create a simple post
endpoint to try the functionality using a closure. In routes/web.php
Route::post('/test', fn () => response()->json(['status' => 'ok']));
Now we need to add a little disruption to our app/Http/Middleware/VerifyCsrfToken.php
middleware to simulate an expired token. By default, this middleware extends Laravel's Illuminate\Foundation\Http\Middleware\VerifyCsrfToken
class that already includes the functionality for the handle
function. We can cause a little mayhem by regenerating our token before it's checked.
public function handle($request, Closure $next)
{
if (random_int(0, 1)) {
$request->session()->regenerateToken();
}
return parent::handle($request, $next);
}
The random_int(0, 1)
generates a random number, either 0
or 1
, and if it's 1
then it will generate a new token in our session. Here's how it breaks down:
- When the middleware gets handled, it will check if the token that was passed with the request matches the one we sent.
- The snippet we added will randomly regenerate the token. If it gets regenerated, the tokens won't match.
- When they don't match, it will throw the
TokenMismatchException
, aka a419
status code. - Our interceptor will then call
/csrf-token
, which then once again regenerates a token. - Eventually, the middleware will not preemptively change the token and the request will resolve correctly.
Since I'm using Vue 3 with Inertia, I'm going to create a component to make this request.
<template>
<button type="button" @click.prevent="sendTest">Test</button>
</template>
<script>
import { defineComponent } from 'vue'
import axios from 'axios'
export default defineComponent({
setup () {
const sendTest = async () => {
const { data } = await axios.post('/test')
console.log(data)
}
return {
sendTest
}
}
})
</script>
I've just got a simple button that when clicked sends a post
request to our /test
endpoint and logs the results, which in this case should be { "status": "ok" }
as returned from our /test
endpoint.
Clicking on my button shows this in the console:
- The first time it posts to
/test
, it fails with a419
, which means that it threw theTokenMismatchException
. - It then sends a request to
/csrf-token
, which means that it has been caught by our interceptor. - The original
/test
request is retried, this time successful (the middleware didn't regenerate the token before handling the request).
If we click the button several times, sometimes it doesn't fail, while other times it will fail multiple times before finally being successful. This is because we're simulating a random token mismatch.
Cleaning up
Once we're done testing, we can delete the middleware's handle
function entirely since we want Laravel manage this particular middleware. We'll also delete the /test
route from routes/web.php
.
Summary
Overall, the impact on our codebase is quite minimal, yet the user experience is greatly improved. The user can leave their session and allow their CSRF token to expire. Coming back and doing a request won't interrupt their desired workflow, which in my opinion is more desired than telling them that the "page has expired" and making them refresh the page.
Top comments (5)
This is a neat solution.
But what is the reason you aren't using sanctum's endpoint /sanctum/csrf-cookie? Isn't it essentially doing the same thing?
I actually didn't know that this existed until I read the docs just now. I've not needed to use Sanctum with Inertia.js (motivation for writing the article).
Sanctum is included by default, but in my case I've never needed it so I usually end up removing it.
Thanks for the new knowledge!
Exactly what I was looking for, perfect solution 👏 Thanks a lot!
Hi there,
Great article! However i have a question:
Will this affect security in anyway?
Thanks!
Basically doing this is the same as as refreshing the page when traditionally running into 419 errors, but without having to refresh the page. Since the CSRF lives in the user's session, it sticks ok.
Since it's only through the axios side, it doesn't pose any security risks (that I'm aware of). Someone doing a cross-site attack would not be using axios, but direct requests to the app