Laravel Security Guide: SQL Injection, XSS, and Auth Best Practices
Secure Laravel applications with Eloquent parameterization, CSRF tokens, Sanctum vs Passport, bcrypt configuration, and mass assignment fillable/guarded.
Laravel Security Guide: SQL Injection, XSS, and Auth Best Practices
Laravel provides an excellent security baseline, but developers frequently misconfigure or bypass these protections in the name of convenience. This guide walks through the most critical security controls in Laravel and shows you exactly how to apply them.
Mass Assignment: $fillable vs $guarded
Mass assignment vulnerabilities allow attackers to set model attributes they should not be able to set — such as is_admin, email_verified_at, or balance — by including them in a POST request.
Laravel's Eloquent protects against this by requiring you to declare which attributes are safe to mass-assign.
Use $fillable (explicit allowlist — preferred)
// app/Models/User.php
class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'password',
];
// is_admin, email_verified_at, and balance are NOT fillable
// User::create(['is_admin' => true]) will silently ignore is_admin
}
Avoid $guarded = []
// DANGEROUS — all attributes are mass-assignable
class User extends Model
{
protected $guarded = [];
}
This is equivalent to disabling mass assignment protection entirely. Avoid it, even in development.
The $hidden Array
Always hide sensitive fields from serialization:
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'stripe_customer_id',
];
SQL Injection Prevention with Eloquent
Eloquent uses PDO parameter binding automatically for all standard query builder operations:
// Safe — Eloquent/query builder parameterizes automatically
User::where('email', $request->email)->first();
DB::table('users')->where('id', $userId)->get();
// Safe — named bindings
DB::select('SELECT * FROM users WHERE email = :email', ['email' => $email]);
// DANGEROUS — raw string interpolation
DB::select("SELECT * FROM users WHERE email = '$email'");
User::whereRaw("name = '$name'"); // SQL injection
When you must use raw expressions, always pass bindings as the second argument:
// Safe use of whereRaw
User::whereRaw('email = ? AND active = ?', [$email, true])->get();
// Safe use of selectRaw
DB::table('orders')
->selectRaw('SUM(amount) as total')
->where('user_id', $userId)
->get();
The orderBy clause is a common SQL injection vector when the column is user-supplied:
// DANGEROUS
User::orderBy($request->sort)->get();
// Safe — validate against allowlist
$sortable = ['name', 'email', 'created_at'];
$sort = in_array($request->sort, $sortable) ? $request->sort : 'created_at';
User::orderBy($sort)->get();
CSRF Protection
Laravel's VerifyCsrfToken middleware is included in the web middleware group by default. For Blade forms, use the @csrf directive:
<form method="POST" action="/transfer">
@csrf
<input type="hidden" name="amount" value="100">
<button type="submit">Transfer</button>
</form>
For AJAX requests with Axios (the default in Laravel), the CSRF token is handled automatically if you include the meta tag:
<!-- In your layout -->
<meta name="csrf-token" content="{{ csrf_token() }}">
// Axios reads the meta tag automatically via the X-CSRF-TOKEN header
// This is configured in resources/js/bootstrap.js by default
axios.post('/api/data', { key: 'value' });
For excluding specific routes (e.g., payment webhooks from Stripe):
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'stripe/webhook',
'paypal/ipn',
];
Authentication: Sanctum vs Passport
Laravel Sanctum (recommended for most applications)
Sanctum is the right choice for:
- Single-page applications (SPA) using cookie-based sessions
- Mobile apps using API tokens
- First-party API consumers
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn(Request $request) => $request->user());
Route::apiResource('posts', PostController::class);
});
For SPA authentication, Sanctum uses HttpOnly session cookies — this is more secure than storing tokens in localStorage.
Laravel Passport (for OAuth2 server use cases)
Use Passport only when you need to act as an OAuth2 authorization server — i.e., issuing tokens to third-party clients. It's heavier and more complex than Sanctum.
composer require laravel/passport
php artisan passport:install
Password Hashing
Laravel uses bcrypt with a cost factor of 10 by default. For higher-security applications, increase the cost or switch to Argon2:
// config/hashing.php
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
],
'argon' => [
'memory' => 65536,
'threads' => 1,
'time' => 4,
],
To switch to Argon2:
// config/hashing.php
'driver' => 'argon2id',
Never hash passwords manually. Use the Hash facade or the bcrypt() helper:
// Correct
$user->password = Hash::make($request->password);
$user->password = bcrypt($request->password);
// NEVER
$user->password = md5($request->password); // Insecure
$user->password = $request->password; // Plaintext
XSS Prevention
Laravel's Blade templating auto-escapes {{ }} output. The unescaped variant {!! !!} is the danger zone:
<!-- Safe — auto-escaped -->
<p>{{ $user->bio }}</p>
<!-- DANGEROUS — raw output of user content -->
<p>{!! $user->bio !!}</p>
If you must render user HTML (rich text editors), sanitize it first:
composer require ezyang/htmlpurifier
use HTMLPurifier;
use HTMLPurifier_Config;
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.AllowedElements', 'p,br,strong,em,ul,ol,li,a');
$config->set('HTML.AllowedAttributes', 'a.href,a.rel');
$purifier = new HTMLPurifier($config);
$clean = $purifier->purify($userHtml);
Security Headers
Laravel does not add security headers by default. Add a middleware:
// app/Http/Middleware/SecurityHeaders.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=()');
if ($request->secure()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
}
return $response;
}
}
Register it in app/Http/Kernel.php in the $middleware array so it applies globally.
Environment and Secrets
# .env — never commit this file
APP_KEY=base64:... # Generated by artisan key:generate
DB_PASSWORD=securepassword
STRIPE_SECRET=sk_live_...
# .env.example — commit this without values
APP_KEY=
DB_PASSWORD=
STRIPE_SECRET=
Validate that required environment variables are set at boot:
// config/services.php
'stripe' => [
'secret' => env('STRIPE_SECRET') ?? throw new \RuntimeException('STRIPE_SECRET not set'),
],
Rate Limiting
// app/Providers/RouteServiceProvider.php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email'));
});
// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
For API routes, Laravel applies a default throttle:api rate limit of 60 requests per minute. Tighten it for sensitive endpoints:
Route::middleware(['auth:sanctum', 'throttle:5,1'])->group(function () {
Route::post('/password/change', [PasswordController::class, 'update']);
});
Security Checklist
-
$fillabledefined on all models — no$guarded = [] - No string interpolation in DB queries
-
{!! !!}reviewed — no raw user content - CSRF middleware active on all web routes
- Sanctum or Passport configured (not both)
-
bcryptrounds >= 12 or Argon2id - SecurityHeaders middleware registered globally
-
APP_DEBUG=falsein production -
.envin.gitignore -
composer auditin CI pipeline