¡ 8 min read

Laravel Under The Hood - CSRF

Hello TokenMismatchException

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?

Let’s say you’re logged into your banking app. An attacker wants to trick you into transferring money to their account without your knowledge. Here’s how they’d pull it off:

The attacker creates a harmless-looking webpage, maybe a fun article or a fake prize claim page. Hidden within this page is a form or script that sends a POST request to your bank’s transfer endpoint. When you visit the page, this request fires in the background, and here’s the key part: cookies are automatically sent with every request to a domain. So if you’re logged into your bank, that request carries your session cookie along with it.

The bank’s server receives what looks like a legitimate transfer request from an authenticated user (you). It has no way of knowing you didn’t intend to make this request. The transfer goes through, and the attacker walks away with your money.

In summary, CSRF exploits the victim’s active session. The attacker tricks them into loading a webpage that sends a malicious request to a server where they’re authenticated. This allows the attacker to perform any action the victim can: transferring funds, changing account settings, deleting data, or worse.

And how does Laravel solve this?

Let’s first discuss the solution concept and then dive into Laravel’s implementation. In our banking scenario, the attack succeeded because the attacker could craft a valid-looking request, they knew the endpoint and the required payload. This could have been prevented with a randomly generated token tied to the user’s session that can’t be guessed or cracked. Why? Because this token is generated using a key stored only on the server. That’s the essence of the solution: any state-changing request should carry a token to ensure the genuine user, of their own volition, initiated the action. If someone tries to trigger a request while you’re browsing a malicious page, the server rejects it because there’s no way the attacker could have guessed and included the correct token.

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(). 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!