Better CSRF refreshing in Laravel and axios
Tags: laravel, inertia
Published: 2021-10-10 09:17:02

This article is written in the context of using [Inertia.js](https://inertiajs.com/), 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](https://inertiajs.com/) 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](https://laravel.com/docs/csrf)). Inertia [mentions a way](https://inertiajs.com/csrf-protection#handling-mismatches) 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](https://laravel.com/docs/controllers#single-action-controllers).

```php
<?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!

```php
$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:

```php
public function __invoke(Request $request)
{
    $request->session()->regenerateToken();

    return response()->json();
}
```

Finally, we need to add the endpoint to `routes/web.php`.

```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](https://github.com/axios/axios#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.

```js
// 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.

```js
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.

```js
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:

1. Set a new token using the `/csrf-token` endpoint that we made at the beginning.
3. 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.

```js
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`

```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.

```php
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:

1. When the middleware gets handled, it will check if the token that was passed with the request matches the one we sent.
2. The snippet we added will randomly regenerate the token. If it gets regenerated, the tokens won't match.
3. When they don't match, it will throw the `TokenMismatchException`, aka a `419` status code.
4. Our interceptor will then call `/csrf-token`, which then once again regenerates a token.
5. 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.

```vue
<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:

![Network logs](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w8zv5i1dhfp11l6ml90b.png)

1. The first time it posts to `/test`, it fails with a `419`, which means that it threw the `TokenMismatchException`.
2. It then sends a request to `/csrf-token`, which means that it has been caught by our interceptor.
3. 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.