In one sentence

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.

Why it matters

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.

Attack #1

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.

๐Ÿ˜ˆAttackerbuilds evil-site.com with a hidden form
๐Ÿ™‚Victimlogged in to your blog, visits evil-site.com
๐Ÿ“จForged POSTform auto-submits to /account/email with the victim's cookie
๐Ÿ’ฅEffectemail changed to the attacker's, account taken over
The victim never clicks anything on your site. Their browser does the work because cookies travel with every request to your domain.

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.

๐Ÿ“Your form@csrf adds a hidden _token
๐ŸงชCSRF middlewarecompares _token to the session
โœ…Matchrequest continues to your controller
โ›”No match419 Page Expired
Only pages served by your own app contain the right token, so only they can submit successfully.
Attack #2

How 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.

๐Ÿ˜ˆAttackerposts a comment containing a <script> tag
๐Ÿ’พYour databasestores the comment as text
๐Ÿ™‚Victimopens the post; page prints the comment raw
๐Ÿ’ฅEffectscript runs as the victim: reads the page, sends requests, fakes a login box
Stored XSS: the script lives in your data and attacks everyone who views it.

Blade's defence is escaping: turning special characters like < and > into harmless text codes (&lt;, &gt;). The browser then shows the script as text instead of running it.

Blade syntaxWhat it doesUse it for
{{ $value }}Escapes with htmlspecialchars(), then printsAlmost everything: names, titles, comments, any user input
{!! $value !!}Prints raw HTML, no escapingOnly HTML you generated or have sanitised
@json($value)Safely encodes data for JavaScriptPassing PHP data into a <script> block
Step by step

Use Laravel's defences correctly

Each step shows one habit. Together they cover the vast majority of CSRF and XSS risks.

  1. Add @csrf to every form

    Any form that uses POST, PUT, PATCH or DELETE needs the token. The @csrf directive 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">
            
  2. Know what the CSRF middleware checks

    Every route in routes/web.php runs through the web middleware group, which includes the CSRF middleware (historically called VerifyCsrfToken; in Laravel 11+ it's ValidateCsrfToken). For any request that changes data, it looks for the token in:

    • the _token form field, or
    • the X-CSRF-TOKEN header, or
    • the X-XSRF-TOKEN header (set automatically by Axios from the XSRF-TOKEN cookie).

    GET, HEAD and OPTIONS requests 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.

  3. 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-TOKEN cookie and sends it for you.

  4. 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.php are stateless and token-authenticated (see Sanctum), so they don't use CSRF tokens at all.

  5. 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>&lt;script&gt;steal()&lt;/script&gt;</p>
            

    The visitor sees the text <script>steal()</script> on screen, and nothing runs.

  6. Treat {!! !!} as a red flag

    Sometimes 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.

Common mistakes

Avoid this, do that

โŒ Avoid

          <h1>Welcome, {!! $user->name !!}</h1>
        

A user who sets their name to a <script> tag now runs code for everyone who sees that greeting.

โœ… Do

          <h1>Welcome, {{ $user->name }}</h1>
        

Escaped output displays exactly what they typed, safely, as text.

โŒ Avoid

          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.

โœ… Do

          Route::delete('/posts/{post}', [PostController::class, 'destroy']);
        

Use DELETE with a @csrf + @method('DELETE') form, so the token is required.

โš ๏ธ Escaping doesn't cover every context

{{ }} 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.

๐Ÿ’ก Pro tip: search before you ship

Before a release, search your views for raw output. Every match should have a clear reason to exist.


        grep -rn "{!!" resources/views
      
๐Ÿ‹๏ธ Try it yourself
  1. Remove @csrf from one of your forms, submit it and confirm you get a 419. Put it back.
  2. Open the page source and find the hidden _token input. Refresh: is the value the same within one session?
  3. Save a comment with the body <b>bold?</b><script>alert(1)</script>. Display it with {{ }} and check that it shows as plain text.
  4. Temporarily switch to {!! !!} in a local-only branch to see the alert fire, then switch back and delete the test comment.
  5. Add a fetch() POST that sends the X-CSRF-TOKEN header from a meta tag.
Recap

Key takeaways

Check yourself

Mini quiz