Laravel Under The Hood - CSRF

December 12, 2023

Hello TokenMismatchException 👋

csrf

I know you've probably encountered this at least once. You copy-pasted the exception, did a little Googling, and found out that adding a directive like @csrf or including the header X-CSRF-TOKEN in your request is the fix. We've all been down that road. But have you ever wondered why Laravel throws this exception in the first place? Do you truly need to send a token with every single request? Well, yes, YEEES you have to. Otherwise, they'll start making jokes about PHP not being secure 😒. To get this, let's step back and talk about an old yet still existing vulnerability called CSRF.

Soo, Cross-Site Request Forgery?

Look at me, a scary name 👻, but I'm pretty straightforward.

Imagine you're a high school student with a major crush on a girl. The catch? You're shy, wishing she would send a friend request your way instead. Wouldn't that be cool? Well, let's make it work 😈

The mission: Get her to act as if she is you without her realizing it.

So, you'd create a simple web page featuring cute dog pics (or whatever she's into). When she loads this page, a request triggers in the background, this request is crafted by you. For instance, it could be a POST request to an endpoint that adds whatever is given in the payload as a friend. In our case, you'd request to add yourself as her friend (I hate to say it, but you have to use JavaScript). Sneakily, you'll share this link, maybe through her friends or, well, that part's up to you 😛. She receives the link, clicks it, the code executes, and the request is sent to the server using her cookies and her active session. This happens because cookies are automatically sent with every single request, and this is what enables the vulnerability. If she's logged in (most likely), she unknowingly sends you that friend request. And that, my friend, is CSRF in action.

Note that there are certain cookie security flags we set to prevent this behavior, but we won't be discussing them for now.

In summary, you exploit the victim's (in this case, the girl) ongoing session. You trick them into loading a web page or clicking a button that sends a request, crafted by you, to the server using their session. This allows you to execute any action you want on their account, such as deletion, liking your own posts, or potentially even worse scenarios..

And how does Laravel solve this?

Let's first discuss the solution concept and then dive into Laravel's implementation. In our scenario, everything fell into place because, well, the guy had everything needed to craft the payload. This could have been prevented if there were a randomly generated token tied to the girl's session that couldn't be guessed or cracked. Why? Because this token is encrypted using a key stored only on the server. That's the essence of the solution: any request doing a significant task, like adding a friend, should carry a token, to ensure the genuine user, of their own volition, initiated the action. For instance, if someone attempts a request while you're admiring those cute dog pictures, the server won't authorize it because there's no way the hacker has guessed and provided the token in their crafted request.

To explore how Laravel implements this, let's navigate to app/Http/Kernel.php

'web' => [
    \App\Http\Middleware\EncryptCookies::class,
    \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
    \Illuminate\Session\Middleware\StartSession::class,
    \Illuminate\View\Middleware\ShareErrorsFromSession::class,
    \App\Http\Middleware\VerifyCsrfToken::class, // <- this guy
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

These represent the middlewares applied to all web routes. Our focus lies on VerifyCsrfToken. Let's take a closer look at it

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array<int, string>
     */
    protected $except = [
        //
    ];
}

Nothing special, so our next direction is the parent class VerifyCsrfToken

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Closure;
use Illuminate\Session\TokenMismatchException;

class VerifyCsrfToken
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     *
     * @throws \Illuminate\Session\TokenMismatchException
     */
    public function handle($request, Closure $next)
    {
        if (
            $this->isReading($request) ||
            $this->runningUnitTests() ||
            $this->inExceptArray($request) ||
            $this->tokensMatch($request)
        ) {
            return tap($next($request), function ($response) use ($request) {
                if ($this->shouldAddXsrfTokenCookie()) {
                    $this->addCookieToResponse($request, $response);
                }
            });
        }

        throw new TokenMismatchException('CSRF token mismatch.');
    }

    // more code
}

Like every middleware, we are interested in the handle() method. You can see several checks are performed. If all these checks fail, Laravel throws the TokenMismatchException exception. Now that you know where the exception is thrown, let's discuss the checks:

  • isReading(): This checks if the request uses an HTTP read verb (HEAD, GET and OPTIONS), that is why you have never encountered this issue when making one these of requests;
  • runningUnitTests(): As you might have guessed, this checks if the application is running in the console and the request is made from tests, there is no absolute need to verify the token (assuming you write tests 👀);
  • inExceptArray(): Remember the $except array in the VerifyCsrfToken middleware we previously explored? This checks if the current route is defined in the array and should be excluded from the token verification (don't mess with it unless you know what you are doing); finally
  • tokensMatch(): This is where the magic happens. It checks if the token passed along with the request matches the one stored in the session. This step is usually the cause of the exception, so let's take a closer look.
<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;

class VerifyCsrfToken
{
    protected function tokensMatch(Request $request): bool
    {
        $token = $this->getTokenFromRequest($request);

        return is_string($request->session()->token()) &&
               is_string($token) &&
               hash_equals($request->session()->token(), $token);
    }

    // more code
}

So, Laravel attempts to retrieve a token from the request. Let's explore this further

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Contracts\Encryption\DecryptException;

class VerifyCsrfToken
{
    protected Encrypter $encrypter;

    protected function getTokenFromRequest(Request $request): string
    {
        $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');
        
        if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
            try {
                $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized()));
            } catch (DecryptException) {
                $token = '';
            }
        }

        return $token;
    }

    // more code
}

Laravel tries to get the token from a field named _token for any write request, typically associated with a regular form submission. If that's not found, it looks for the token in the header X-CSRF-TOKEN, used for AJAX requests.

Should the token remain unset, Laravel checks for it in X-XSRF-TOKEN. Now, you might wonder about this header. It's primarily for developer convenience. Laravel sends back a cookie named XSRF-TOKEN with every response. Some libraries, when making requests, set the value of this cookie to the X-XSRF-TOKEN header on every request they make, automatically. Essentially, Laravel is like, let's also check if this request is made by Axios, or other JS libraries. Eventually, the $token is returned.

Back to the tokensMatch() method

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Illuminate\Http\Request;

class VerifyCsrfToken
{
    protected function tokensMatch(Request $request): bool
    {
        $token = $this->getTokenFromRequest($request);

        return is_string($request->session()->token()) &&
               is_string($token) &&
               hash_equals($request->session()->token(), $token);
    }

    // more code
}

The $token value retrieved from the request is compared to whatever is stored in the Laravel session. Let's navigate to storage/framework/sessions (assuming you haven't touched the default session config), there you will find user sessions. Inspect any of these files

a:3:{s:6:"_token";s:40:"bi2fA9ienYF09b5Ny3ovCvUR5NpStGkPAMDWOFg7";s:9:"_previous";a:1:{s:3:"url";s:21:"http://localhost";}s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}}

A serialized PHP object that encapsulates the session, and notice the _token field.

Hence, whatever is stored in the user's session under the key _token must match the token provided in any write request. If not Laravel will throw the TokenMismatchException exception.

Now you might be wondering, "When did I even send this token?" Well, when you encountered this exception, the solution involved adding the @csrf directive right? This directive embeds a hidden field in your HTML form with the correct token value.

Shall we explore this further? go to Illuminate\View\Compilers\Concerns\CompilesHelpers

<?php

namespace Illuminate\View\Compilers\Concerns;

trait CompilesHelpers
{
    protected function compileCsrf(): string
    {
        return '<?php echo csrf_field(); ?>';
    }

    // more code here
}

The result of this method will replace the @csrf directive. Taking a look into the csrf_field() function found in the helpers.php file, we'll come across the following code snippet

function csrf_field()
{
    return new HtmlString('<input type="hidden" name="_token" value="'.csrf_token().'" autocomplete="off">');
}

Looks familiar? That is the hidden field named _token, I was telling you about. This is why the getTokenFromRequest() method looks for the _token key. Now, let's solidify everything we've discussed throughout this article by inspecting the csrf_token() function

function csrf_token()
{
    $session = app('session');

    if (isset($session)) {
        return $session->token();
    }

    throw new RuntimeException('Application session store not set.');

    // more code
}

Notice how the function returns whatever is within $session->token() (though we won't dive into this code otherwise we will have to discuss Laravel's manager pattern 😛). The input field, named _token, sent along with the request, has the exact value set in the session. If these values match in the tokensMatch() method (and now you know why), which they will if the request is genuine, Laravel will be happy, otherwise, he will throw the exception.

That's it! you gathered all the pieces!

Conclusion

Hey, you made it this far 🎉 Look at you, now well-versed in Laravel internals and also more aware of web vulnerabilities! The next time Laravel throws an exception, don't get mad, it's for your own good!

I'm considering writing more about Laravel internals, do you think it is a good idea? I would love to hear your thoughts. Feel free to reach out to me on any of the platforms listed below!


Profile picture

Written by Oussama Mater Software Engineering Student, CTF Player, and Web Developer.
Find me on X, Linkedin, Github, and Laracasts.