Use LSCache with CSRF Tokens in Laravel

This wiki assumes you're using our Laravel LSCache composer package.

When you're building a web application that contains forms, it's quite common that you have CSRF tokens to prevent cross-site scripting (XSS). However, if you want to use LSCache within your application, this often breaks form submissions because everyone gets the same CSRF token, unless you've set private cache as the default when enabling LSCache.

However, we can use the public cache in LSCache together with ESI to make the ESI tokens private.

It does require a few changes in your Laravel application to make this work.

You need to add a new route inside routes/web.php called /csrf like this:

Route::get('/csrf', function() {
    $response = csrf_token();
    return response($response, 200);
})->middleware('lscache:private;max-age=900');

What we are doing here is solely generating a CSRF token. We make it private in such a way that the response becomes unique to the user, and we cache it for 900 seconds (15 minutes). Since the tokens do not constantly refresh, there is no need to do an ESI call for every pageview if we can avoid it.

The default session timeout in Laravel is 120 minutes (2 hours). By setting a lower max-age on the cache-control for CSRF, we make sure that the /csrf endpoint gets called every 15 minutes (if the visitor has activity), this will keep the session “alive” and continue to extend the lifetime of the session by 120 minutes after “last activity”.

Next, we add an ESI_ENABLED=true to our .env file, since we'll use this in our views where we need the CSRF token.

In the pages where we have a form, you'd normally do something like:

<form method="POST" action="/profile">
    @csrf
    ...
</form>

We can simply replace this with:

<form method="POST" action="/profile">
    @if(env('ESI_ENABLED'))
        <input type="hidden" name="_token" value='<esi:include src="/csrf" cache-control="private" />'>
    @else
        @csrf
    @endif
    ...
</form>

What we do is to check whether ESI_ENABLED is true. If it is, we generate our token using the esi:include call. If ESI_ENABLED is set to false, we use the Laravel helper @csrf to generate the CSRF token.

Laravel also stores your CSRF token in a meta tag. This is used for JavaScript-driven applications, so we want to make sure the token is available in a meta tag as well.

Change the following code:

<meta name="csrf-token" content="{{ csrf_token() }}">

To:

@if(env('ESI_ENABLED'))
    <meta name="csrf-token" content='<esi:include src="/csrf" cache-control="private" />'>
@else
    <meta name="csrf-token" content="{{ csrf_token() }}">
@endif

We once again simply use our ESI_ENABLED environment variable to handle the decision whether to use ESI or not.

Older Laravel applications can also have their CSRF token set in a window.Laravel variable in JavaScript. This method isn't used anymore, but in case you have an older Laravel application, you can also handle this by doing:

<script>
    @if(env('ESI_ENABLED'))
        window.Laravel = {"csrfToken":"<esi:include src='/csrf' cache-control='private,no-cache' />"}
    @else
        window.Laravel = <?php echo json_encode(['csrfToken' => csrf_token()]); ?>
    @endif
</script>

We have a small example here of how to enable the ESI engine:

Route::get('/csrf', function() {
    $response = csrf_token();
    return response($response, 200);
})->middleware('lscache:private;max-age=900');

Route::get('/contact', function() {
    return view('contact');
})->middleware('lscache:max-age=3600;public;esi=on');

We use our lscache middleware to set a max age of 1 hour, set the cacheability to public and enable the ESI engine with esi=on. It's important that you use esi=on within the lscache-middleware for all the pages where you use your ESI blocks - if you do not add this, the ESI engine won't get enabled and ESI won't be used.

For performance reasons, please do not enable ESI globally.

OpenLiteSpeed doesn't have ESI available, so you can't use the above ESI implementation with OpenLiteSpeed, however, we can still do something similar using javascript:

<script type="text/javascript">
  $(document).ready(function () {
    $.ajax({
      url: '/csrf',
      success: function(csrf_token) {
        $('meta[name=csrf-token]').attr('content', csrf_token);
        $('input[name=_token]').attr('value', csrf_token);
      }
    });
  });
</script>

You'll still use the /csrf endpoint mentioned earlier:

Route::get('/csrf', function() {
    $response = csrf_token();
    return response($response, 200);
})->middleware('lscache:private;max-age=900');
  • Admin
  • Last modified: 2019/05/02 08:45
  • by Lucas Rolff