Laravel CSRF Protection: What It Is and How Laravel Handles It For You

Published on November 21, 2025
Laravel CSRF Security WebSecurity Authentication PHP

What is CSRF?

Cross-Site Request Forgery (CSRF) is a web security vulnerability that allows an attacker to trick users into executing unwanted actions on a web application in which they're authenticated.

Simple CSRF Attack Example:

  • User logs into bank.com
  • User visits malicious site evil.com
  • evil.com contains a form that submits to bank.com/transfer
  • Browser automatically includes user's cookies
  • Bank transfers money without user's knowledge

How Laravel CSRF Protection Works

Laravel automatically generates a CSRF "token" for each active user session. This token is used to verify that authenticated users are the ones actually making requests to the application.

The Protection Flow:

  • Token Generation: Laravel creates unique CSRF token for each session
  • Token Inclusion: Token included in forms and meta tags
  • Token Verification: Laravel verifies token on POST, PUT, PATCH, DELETE requests
  • Token Mismatch: Request rejected if tokens don't match

Laravel's Built-in CSRF Protection

Automatic Token Management

VerifyCsrfToken Middleware
<?php
// app/Http/Middleware/VerifyCsrfToken.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 = [
        // Add routes that don't need CSRF protection
        'stripe/*',
        'webhook/*',
    ];
}

How the Middleware Works:

// Simplified version of what happens:
public function handle($request, Closure $next)
{
    if (
        $this->isReading($request) ||
        $this->runningUnitTests() ||
        $this->inExceptArray($request) ||
        $this->tokensMatch($request)
    ) {
        return $next($request);
    }

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

Implementing CSRF Protection in Forms

Basic Form Protection

Blade Form with CSRF
{{-- resources/views/posts/create.blade.php --}}

<form method="POST" action="/posts">
    @csrf {{-- CSRF Protection --}}
    
    <div class="form-group">
        <label for="title">Title</label>
        <input type="text" name="title" id="title" value="{{ old('title') }}" required>
        @error('title')
            <span class="error">{{ $message }}</span>
        @enderror
    </div>
    
    <div class="form-group">
        <label for="content">Content</label>
        <textarea name="content" id="content" required>{{ old('content') }}</textarea>
        @error('content')
            <span class="error">{{ $message }}</span>
        @enderror
    </div>
    
    <button type="submit">Create Post</button>
</form>
What @csrf Generates:
<form method="POST" action="/posts">
    <input type="hidden" name="_token" value="X8e1zA2b3c4d5e6f7g8h9i0j...">
    <!-- Rest of the form -->
</form>

Advanced Form Examples

Multi-step Form
{{-- Step 1: Personal Information --}}
<form method="POST" action="/registration/step1">
    @csrf
    <input type="hidden" name="form_step" value="1">
    
    <div class="form-group">
        <label for="name">Full Name</label>
        <input type="text" name="name" id="name" value="{{ old('name') }}" required>
    </div>
    
    <div class="form-group">
        <label for="email">Email</label>
        <input type="email" name="email" id="email" value="{{ old('email') }}" required>
    </div>
    
    <button type="submit">Next Step</button>
</form>

{{-- Step 2: Address Information --}}
<form method="POST" action="/registration/step2">
    @csrf
    <input type="hidden" name="form_step" value="2">
    <input type="hidden" name="name" value="{{ old('name') }}">
    <input type="hidden" name="email" value="{{ old('email') }}">
    
    <div class="form-group">
        <label for="address">Address</label>
        <textarea name="address" id="address" required>{{ old('address') }}</textarea>
    </div>
    
    <button type="submit">Complete Registration</button>
</form>
File Upload Form
<form method="POST" action="/upload" enctype="multipart/form-data">
    @csrf
    
    <div class="form-group">
        <label for="document">Upload Document</label>
        <input type="file" name="document" id="document" accept=".pdf,.doc,.docx" required>
        @error('document')
            <span class="error">{{ $message }}</span>
        @enderror
    </div>
    
    <div class="form-group">
        <label for="description">Description</label>
        <textarea name="description" id="description">{{ old('description') }}</textarea>
    </div>
    
    <button type="submit">Upload File</button>
</form>
Laravel Collective Forms (Alternative)
{{-- Using Laravel Collective --}}
{!! Form::open(['url' => '/posts', 'method' => 'POST']) !!}
    {!! Form::token() !!} {{-- CSRF token --}}
    
    {!! Form::text('title', old('title'), ['class' => 'form-control']) !!}
    {!! Form::textarea('content', old('content'), ['class' => 'form-control']) !!}
    {!! Form::submit('Create Post', ['class' => 'btn btn-primary']) !!}
{!! Form::close() !!}

CSRF Protection in JavaScript Applications

Traditional Applications (jQuery, etc.)

Including CSRF Token in Meta Tag
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>My Application</title>
</head>
<body>
    <!-- Your application content -->
</body>
</html>
JavaScript Setup
// resources/js/csrf.js

// Set up CSRF token for all AJAX requests
$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});

// Alternative: Get token directly
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
AJAX Form Submission
// Example: Creating a post via AJAX
$('#post-form').on('submit', function(e) {
    e.preventDefault();
    
    const formData = new FormData(this);
    
    $.ajax({
        url: '/posts',
        method: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        success: function(response) {
            // Handle success
            alert('Post created successfully!');
            window.location.href = '/posts';
        },
        error: function(xhr) {
            // Handle errors
            if (xhr.status === 419) { // CSRF token mismatch
                alert('Session expired. Please refresh the page.');
                location.reload();
            } else {
                alert('Error creating post: ' + xhr.responseJSON.message);
            }
        }
    });
});

Single Page Applications (SPA) with Laravel

SPA Setup with CSRF
// resources/js/app.js

import axios from 'axios';

// Set CSRF token for all requests
const csrfToken = document.head.querySelector('meta[name="csrf-token"]');

if (csrfToken) {
    axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken.content;
} else {
    console.error('CSRF token not found');
}

// Handle token expiration
axios.interceptors.response.use(
    response => response,
    error => {
        if (error.response.status === 419) {
            // CSRF token mismatch - likely session expired
            localStorage.setItem('redirect_url', window.location.href);
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);
Vue.js Component Example
<template>
    <form @submit.prevent="submitForm">
        <div class="form-group">
            <label for="title">Title</label>
            <input 
                type="text" 
                id="title" 
                v-model="form.title" 
                :class="{ 'error': errors.title }"
            >
            <span v-if="errors.title" class="error-text">{{ errors.title[0] }}</span>
        </div>
        
        <div class="form-group">
            <label for="content">Content</label>
            <textarea 
                id="content" 
                v-model="form.content" 
                :class="{ 'error': errors.content }"
            ></textarea>
            <span v-if="errors.content" class="error-text">{{ errors.content[0] }}</span>
        </div>
        
        <button type="submit" :disabled="loading">
            {{ loading ? 'Creating...' : 'Create Post' }}
        </button>
    </form>
</template>

<script>
export default {
    data() {
        return {
            form: {
                title: '',
                content: ''
            },
            errors: {},
            loading: false
        }
    },
    
    methods: {
        async submitForm() {
            this.loading = true;
            this.errors = {};
            
            try {
                const response = await axios.post('/api/posts', this.form);
                
                // Success - redirect or show message
                this.$router.push('/posts/' + response.data.id);
                
            } catch (error) {
                if (error.response.status === 422) {
                    // Validation errors
                    this.errors = error.response.data.errors;
                } else if (error.response.status === 419) {
                    // CSRF token mismatch
                    alert('Your session has expired. Please refresh the page.');
                    window.location.reload();
                } else {
                    // Other errors
                    alert('An error occurred. Please try again.');
                }
            } finally {
                this.loading = false;
            }
        }
    }
}
</script>
React Component Example
// resources/js/components/PostForm.jsx

import React, { useState } from 'react';
import axios from 'axios';

const PostForm = () => {
    const [form, setForm] = useState({
        title: '',
        content: ''
    });
    const [errors, setErrors] = useState({});
    const [loading, setLoading] = useState(false);

    const handleSubmit = async (e) => {
        e.preventDefault();
        setLoading(true);
        setErrors({});

        try {
            const response = await axios.post('/api/posts', form);
            
            // Success - redirect or show message
            window.location.href = `/posts/${response.data.id}`;
            
        } catch (error) {
            if (error.response.status === 422) {
                setErrors(error.response.data.errors);
            } else if (error.response.status === 419) {
                alert('Session expired. Please refresh the page.');
                window.location.reload();
            } else {
                alert('Error creating post');
            }
        } finally {
            setLoading(false);
        }
    };

    const handleChange = (e) => {
        setForm({
            ...form,
            [e.target.name]: e.target.value
        });
    };

    return (
        <form onSubmit={handleSubmit}>
            <div className="form-group">
                <label htmlFor="title">Title</label>
                <input
                    type="text"
                    id="title"
                    name="title"
                    value={form.title}
                    onChange={handleChange}
                    className={errors.title ? 'error' : ''}
                />
                {errors.title && <span className="error-text">{errors.title[0]}</span>}
            </div>
            
            <div className="form-group">
                <label htmlFor="content">Content</label>
                <textarea
                    id="content"
                    name="content"
                    value={form.content}
                    onChange={handleChange}
                    className={errors.content ? 'error' : ''}
                />
                {errors.content && <span className="error-text">{errors.content[0]}</span>}
            </div>
            
            <button type="submit" disabled={loading}>
                {loading ? 'Creating...' : 'Create Post'}
            </button>
        </form>
    );
};

export default PostForm;

API CSRF Protection

Stateless APIs (No CSRF Protection Needed)

For stateless APIs (like mobile apps, third-party integrations), CSRF protection is typically disabled since there's no session-based authentication.

Disabling CSRF for API Routes
<?php
// app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    'api/*',
    'mobile/*',
];

Stateful APIs (With CSRF Protection)

SPA Authentication Setup
<?php
// routes/web.php

// SPA routes - protected by CSRF
Route::middleware(['web'])->group(function () {
    Route::get('/{any}', function () {
        return view('app'); // Your SPA entry point
    })->where('any', '.*');
});

// API routes for SPA - also protected by CSRF
Route::middleware(['web', 'auth'])->prefix('api')->group(function () {
    Route::get('/user', function (Request $request) {
        return $request->user();
    });
    
    Route::apiResource('posts', PostController::class);
});

Advanced CSRF Scenarios

Excluding Specific Routes

Common Exclusions
<?php
// app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    // Payment webhooks (they don't use sessions)
    'stripe/webhook',
    'paypal/webhook',
    
    // External API endpoints
    'api/v1/webhook/*',
    
    // File upload endpoints (if needed)
    'upload/temporary',
    
    // Health check endpoints
    'health',
    
    // Specific routes that need to be public
    'contact/form',
    
    // Remember: Only exclude when absolutely necessary!
];

Custom CSRF Token Handling

Custom Token Generation
<?php
// app/Http/Middleware/VerifyCsrfToken.php

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

class VerifyCsrfToken extends Middleware
{
    /**
     * Get the CSRF token from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     */
    protected function getTokenFromRequest($request)
    {
        // Check header first
        $token = $request->header('X-CSRF-TOKEN');
        
        // Then check input
        if (!$token && $token = $request->input('_token')) {
            return $token;
        }
        
        // Finally check session
        if (!$token) {
            $token = $request->session()->token();
        }
        
        return $token;
    }
    
    /**
     * Add the CSRF token to the response cookies.
     */
    protected function addCookieToResponse($request, $response)
    {
        $config = config('session');
        
        $response->headers->setCookie(
            new Cookie(
                'XSRF-TOKEN',
                $request->session()->token(),
                time() + 60 * 120,
                $config['path'],
                $config['domain'],
                $config['secure'],
                false,
                false,
                $config['same_site'] ?? null
            )
        );
        
        return $response;
    }
}

CSRF Protection for File Downloads

Secure File Download
<?php
// app/Http/Controllers/DownloadController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class DownloadController extends Controller
{
    public function download(Request $request, $fileId)
    {
        // Verify CSRF token for sensitive downloads
        if (!$request->hasValidSignature()) {
            abort(401, 'Invalid download link');
        }
        
        $file = \App\Models\File::findOrFail($fileId);
        
        // Additional authorization check
        if (!$request->user()->can('download', $file)) {
            abort(403, 'Unauthorized to download this file');
        }
        
        return Storage::download($file->path, $file->original_name);
    }
    
    public function generateDownloadLink(Request $request, $fileId)
    {
        $this->authorize('download', \App\Models\File::findOrFail($fileId));
        
        // Generate signed URL that expires in 30 minutes
        $url = URL::temporarySignedRoute(
            'files.download',
            now()->addMinutes(30),
            ['file' => $fileId]
        );
        
        return response()->json(['download_url' => $url]);
    }
}

Testing CSRF Protection

CSRF Test Examples

<?php
// tests/Feature/CsrfProtectionTest.php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class CsrfProtectionTest extends TestCase
{
    use RefreshDatabase;

    public function test_csrf_protection_blocks_requests_without_token()
    {
        $user = \App\Models\User::factory()->create();
        
        $this->actingAs($user);
        
        $response = $this->post('/posts', [
            'title' => 'Test Post',
            'content' => 'Test Content',
            // Missing _token field
        ]);
        
        $response->assertStatus(419); // CSRF token mismatch
    }

    public function test_csrf_protection_allows_requests_with_valid_token()
    {
        $user = \App\Models\User::factory()->create();
        
        $this->actingAs($user);
        
        $response = $this->post('/posts', [
            'title' => 'Test Post',
            'content' => 'Test Content',
            '_token' => csrf_token(), // Include valid CSRF token
        ]);
        
        $response->assertRedirect(); // Should redirect on success
        $this->assertDatabaseHas('posts', ['title' => 'Test Post']);
    }

    public function test_excluded_routes_dont_require_csrf_token()
    {
        $response = $this->post('/stripe/webhook', [
            'type' => 'payment_intent.succeeded',
            'data' => ['object' => ['id' => 'pi_123']]
        ]);
        
        $response->assertStatus(200); // Should not return 419
    }

    public function test_csrf_token_in_meta_tag()
    {
        $user = \App\Models\User::factory()->create();
        
        $response = $this->actingAs($user)
                        ->get('/posts/create');
        
        $response->assertSee('csrf-token');
        $response->assertSee(csrf_token());
    }
}

// tests/Unit/CsrfTokenTest.php

class CsrfTokenTest extends TestCase
{
    public function test_csrf_token_generation()
    {
        $token1 = csrf_token();
        $token2 = csrf_token();
        
        // Tokens should be the same in same request
        $this->assertEquals($token1, $token2);
        
        // Token should be 40 characters (SHA-1 hash)
        $this->assertEquals(40, strlen($token1));
    }

    public function test_csrf_token_session_storage()
    {
        $session = app('session');
        
        $token = $session->token();
        
        $this->assertNotNull($token);
        $this->assertIsString($token);
    }
}

Troubleshooting Common CSRF Issues

Common Problems and Solutions

Problem 1: 419 Page Expired Error
// Solution: Ensure CSRF token is included
<form method="POST">
    @csrf
    <!-- form fields -->
</form>

// For AJAX requests, include token in headers
$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});
Problem 2: Token Mismatch After Login
// Solution: Refresh CSRF token after authentication
$('#login-form').on('submit', function(e) {
    e.preventDefault();
    
    $.post('/login', $(this).serialize())
        .done(function(response) {
            // Update CSRF token after successful login
            $('meta[name="csrf-token"]').attr('content', response.new_csrf_token);
            
            // Update axios default header if using
            axios.defaults.headers.common['X-CSRF-TOKEN'] = response.new_csrf_token;
        });
});
Problem 3: Multiple Tabs Causing Issues
// Solution: Handle token regeneration gracefully
public function login(Request $request)
{
    // ... authentication logic
    
    // Regenerate session after login
    $request->session()->regenerate();
    
    return response()->json([
        'success' => true,
        'new_csrf_token' => csrf_token()
    ]);
}

Security Best Practices

  1. Always Use CSRF Protection
    // Never disable CSRF protection globally
    // Only exclude specific, carefully considered routes
    
    protected $except = [
        // Only webhooks and truly public endpoints
        'stripe/webhook',
        'health-check',
    ];
  2. Secure Token Transmission
    {{-- Always use HTTPS in production --}}
    @production
        <meta name="csrf-token" content="{{ csrf_token() }}" secure>
    @endproduction
  3. Session Configuration
    // config/session.php
    
    'secure' => env('SESSION_SECURE_COOKIE', true),
    'http_only' => true,
    'same_site' => 'lax', // or 'strict'
  4. Regular Session Management
    // Regenerate session ID periodically
    $request->session()->regenerate();
    
    // Invalidate old sessions
    $request->session()->invalidate();

Common Interview Questions & Answers

  1. What is CSRF and why is it dangerous?

    CSRF (Cross-Site Request Forgery) allows attackers to trick users into performing unwanted actions on websites where they're authenticated. It's dangerous because it can lead to unauthorized transactions, data changes, or account takeovers.

  2. How does Laravel protect against CSRF attacks?

    Laravel generates a unique CSRF token for each user session and verifies this token on all state-changing requests (POST, PUT, PATCH, DELETE). The VerifyCsrfToken middleware automatically handles this protection.

  3. When should you disable CSRF protection?

    Only disable CSRF for stateless endpoints like webhooks, external APIs, or public forms that don't use session authentication. Always keep it enabled for authenticated routes.

  4. What's the difference between @csrf and csrf_token()?

    @csrf is a Blade directive that generates a hidden input field with the token. csrf_token() returns the token string that you can use in JavaScript or custom forms.

  5. How do you handle CSRF in JavaScript applications?

    Include the token in a meta tag and set it as a default header for AJAX requests. For SPAs, ensure your authentication flow properly handles token regeneration.

  6. What happens when a CSRF token mismatch occurs?

    Laravel throws a TokenMismatchException which results in a 419 HTTP status code. The user typically sees a "Page Expired" error.

Performance Considerations

Token Caching

// For high-traffic applications, consider token caching
public function getCsrfToken()
{
    return Cache::remember('csrf_token_' . session()->getId(), 3600, function () {
        return Str::random(40);
    });
}

Session Driver Selection

// Use database or redis sessions for better performance
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'),

// For load-balanced applications
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
'domain' => env('SESSION_DOMAIN', '.yourdomain.com'),

You've now mastered Laravel CSRF protection and can secure your applications against cross-site request forgery attacks! In our next post, we'll explore Laravel Session Management: A Quick Guide to Storing User Data to learn how to manage user state effectively.