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,GETandOPTIONS), 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$exceptarray in theVerifyCsrfTokenmiddleware 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); finallytokensMatch(): 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!