CSRF tricks a logged-in user's browser into sending a request they never meant to send, and XSS sneaks attacker JavaScript into your pages. Laravel blocks the first with @csrf tokens and the second with {{ }} escaping.
๐ฆCSRF is like someone slipping a pre-filled cheque into your pile of papers for you to sign without reading. XSS is like someone writing graffiti on your shop window that then talks to every customer who walks by. A secret stamp on every real cheque stops the first; washing every message before you hang it in the window stops the second.
Two attacks that target your users, not your server
They act as your users
Both attacks borrow a real user's logged-in session. The damage happens in their name: changed emails, deleted posts, stolen data.
Laravel defends by default
CSRF checks run on every web form, and Blade escapes output automatically. You stay safe as long as you don't switch the defences off.
Easy to undo by accident
One stray {!! !!} or one route excluded from CSRF is all it takes. Knowing why the defaults exist keeps you from breaking them.
How a CSRF attack works
CSRF stands for Cross-Site Request Forgery. A "forged" request comes from another website, but your browser attaches your login cookie to it automatically, so your app thinks you sent it.
Laravel's defence is a CSRF token: a random secret stored in the user's session. Every form on your site must send it back. The attacker's site can't read your pages, so it can't know the token, and its forged request is rejected.
@csrf adds a hidden _token_token to the sessionHow an XSS attack works
XSS stands for Cross-Site Scripting. The attacker saves some JavaScript as ordinary-looking data (a comment, a username). If your page prints that data as raw HTML, the browser runs the script for every visitor.
Blade's defence is escaping: turning special characters like < and > into harmless text codes (<, >). The browser then shows the script as text instead of running it.
| Blade syntax | What it does | Use it for |
|---|---|---|
{{ $value }} | Escapes with htmlspecialchars(), then prints | Almost everything: names, titles, comments, any user input |
{!! $value !!} | Prints raw HTML, no escaping | Only HTML you generated or have sanitised |
@json($value) | Safely encodes data for JavaScript | Passing PHP data into a <script> block |
Use Laravel's defences correctly
Each step shows one habit. Together they cover the vast majority of CSRF and XSS risks.
-
Add
@csrfto every formAny form that uses
POST,PUT,PATCHorDELETEneeds the token. The@csrfdirective renders a hidden input for you.<form method="POST" action="{{ route('account.email.update') }}"> @csrf @method('PATCH') <input type="email" name="email" value="{{ old('email', $user->email) }}"> <button>Save</button> </form>In the browser's page source you'll see something like this:
<input type="hidden" name="_token" value="kX3v9Qe...random40chars"> -
Know what the CSRF middleware checks
Every route in
routes/web.phpruns through thewebmiddleware group, which includes the CSRF middleware (historically calledVerifyCsrfToken; in Laravel 11+ it'sValidateCsrfToken). For any request that changes data, it looks for the token in:- the
_tokenform field, or - the
X-CSRF-TOKENheader, or - the
X-XSRF-TOKENheader (set automatically by Axios from theXSRF-TOKENcookie).
GET,HEADandOPTIONSrequests are not checked. That's why you should never change data in a GET route.โน๏ธ What's a 419?If the token is missing or wrong, Laravel responds with 419 Page Expired. During development this usually means you forgot
@csrf, or your session expired while the form was open. - the
-
Send the token from JavaScript
For
fetch()calls, put the token in a meta tag in your layout and send it as a header.<meta name="csrf-token" content="{{ csrf_token() }}">const token = document.querySelector('meta[name="csrf-token"]').content; fetch('/posts/42/like', { method: 'POST', headers: { 'X-CSRF-TOKEN': token, 'Accept': 'application/json' }, });If you use Axios (included in new Laravel apps), it reads the
XSRF-TOKENcookie and sends it for you. -
Exclude routes only when an outside service calls them
Webhooks (for example, from a payment provider) can't know your token. Exclude only those exact paths in
bootstrap/app.php, and verify the webhook's own signature instead.->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ 'webhooks/stripe', ]); })Routes in
routes/api.phpare stateless and token-authenticated (see Sanctum), so they don't use CSRF tokens at all. -
Print user data with
{{ }}Double curly braces escape the output. Here's what happens to a malicious comment:
@foreach ($post->comments as $comment) <p>{{ $comment->body }}</p> @endforeach<p><script>steal()</script></p>The visitor sees the text
<script>steal()</script>on screen, and nothing runs. -
Treat
{!! !!}as a red flagSometimes you really need raw HTML, for example a post body written in Markdown. Convert and sanitise it first, then print it raw.
use Illuminate\Support\Str; public function bodyHtml(): string { // Markdown โ HTML, with raw HTML in the input stripped out return Str::markdown($this->body, [ 'html_input' => 'strip', 'allow_unsafe_links' => false, ]); }<article>{!! $post->bodyHtml() !!}</article>For rich-text editors that produce HTML, use a dedicated sanitiser package (such as HTML Purifier) before saving or displaying.
Avoid this, do that
<h1>Welcome, {!! $user->name !!}</h1>
A user who sets their name to a <script> tag now runs code for everyone who sees that greeting.
<h1>Welcome, {{ $user->name }}</h1>
Escaped output displays exactly what they typed, safely, as text.
Route::get('/posts/{post}/delete', [PostController::class, 'destroy']);
GET requests skip the CSRF check, so a simple image tag on another site could delete posts.
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
Use DELETE with a @csrf + @method('DELETE') form, so the token is required.
{{ }} is safe for HTML text and quoted attributes. It does not make a user-supplied URL safe inside href (a javascript: link still runs). Validate URLs with the url or active_url rules, and use @json when passing data into JavaScript.
Before a release, search your views for raw output. Every match should have a clear reason to exist.
grep -rn "{!!" resources/views
- Remove
@csrffrom one of your forms, submit it and confirm you get a 419. Put it back. - Open the page source and find the hidden
_tokeninput. Refresh: is the value the same within one session? - Save a comment with the body
<b>bold?</b><script>alert(1)</script>. Display it with{{ }}and check that it shows as plain text. - Temporarily switch to
{!! !!}in a local-only branch to see the alert fire, then switch back and delete the test comment. - Add a
fetch()POST that sends theX-CSRF-TOKENheader from a meta tag.
Key takeaways
- CSRF forges requests using a victim's cookies; XSS injects scripts into your pages.
- Put
@csrfin every non-GET form; a missing or wrong token gives a 419. - The CSRF middleware checks
_token,X-CSRF-TOKENorX-XSRF-TOKENon POST/PUT/PATCH/DELETE. - Never change data in a GET route.
{{ }}escapes output;{!! !!}prints raw HTML and needs trusted or sanitised input.- Exclude CSRF only for third-party webhooks, and verify their signatures instead.