DEV Community

Grant
Grant

Posted on

Better CSRF refreshing in Laravel and axios

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
Enter fullscreen mode Exit fullscreen mode

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)
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

The functionality we need to generate a fresh token is only one line!

$request->session()->regenerateToken();
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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

Route::get('/csrf-token', \App\Http\Controllers\RefreshCsrfTokenController::class);
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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.
  2. 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)
})
Enter fullscreen mode Exit fullscreen mode

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']));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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

  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.

Discussion (1)

Collapse
vstruhar profile image
vstruhar

Exactly what I was looking for, perfect solution 👏 Thanks a lot!