40 security checks for your Laravel application
Youβve learned about malware signatures, evasion techniques, and CVEs. Now itβs time to audit your own application.
This chapter provides 40 actionable security checks organized by category. Run through this checklist on every Laravel project - whether itβs a new build or legacy code youβve inherited.
How to use this checklist
Each check includes:
- What to look for - The security issue
- How to check - Commands or code to verify
- How to fix - Remediation steps
- Severity - CRITICAL / HIGH / MEDIUM / LOW
Recommended approach:
- Run all CRITICAL checks first
- Address HIGH severity issues
- Schedule MEDIUM/LOW for next sprint
- Re-run checklist after major releases
Category 1: Configuration (8 checks)
Check #1: Debug Mode Disabled
Severity: CRITICAL
Debug mode exposes environment variables, database credentials, and enables XSS vulnerabilities.
# Check .env file
grep "APP_DEBUG" .env
# Must be: APP_DEBUG=false
// Or in tinker
config('app.debug'); // Must be false in production
Fix: Set APP_DEBUG=false in production .env
Check #2: Environment Set to Production
Severity: CRITICAL
grep "APP_ENV" .env
# Must be: APP_ENV=production
Why it matters: Many packages behave differently in local vs production. Security features may be disabled in non-production environments.
Check #3: APP_KEY Properly Set
Severity: CRITICAL
grep "APP_KEY" .env
# Must start with: APP_KEY=base64:
# Must be 32 bytes (44 chars in base64)
// Verify key length
strlen(base64_decode(substr(config('app.key'), 7))); // Must be 32
Fix: php artisan key:generate
Never Share APP_KEY
If APP_KEY is leaked, attackers can decrypt all encrypted data, forge sessions, and execute RCE via deserialization.
Check #4: .env Not in Git
Severity: CRITICAL
# Check if .env is tracked
git ls-files | grep "^\.env$"
# Should return nothing
# Check .gitignore
grep "\.env" .gitignore
# Should include .env
Fix: Add to .gitignore:
.env
.env.backup
.env.*.local
Check #5: Debugbar Disabled
Severity: HIGH
# Check if installed
composer show | grep debugbar
# If installed, check config
grep "DEBUGBAR_ENABLED" .env
# Must be: DEBUGBAR_ENABLED=false
Fix: Install only in require-dev and explicitly disable:
composer require barryvdh/laravel-debugbar --dev
Check #6: Telescope Secured
Severity: HIGH
// app/Providers/TelescopeServiceProvider.php
protected function gate()
{
Gate::define('viewTelescope', function ($user) {
// Must restrict access!
return in_array($user->email, [
'admin@yourdomain.com',
]);
});
}
Fix: Always define a Gate that restricts Telescope access.
Check #7: register_argc_argv Disabled
Severity: HIGH
php -i | grep register_argc_argv
# Must be: Off
Fix: In php.ini:
register_argc_argv = Off
Check #8: Error Display Disabled
Severity: MEDIUM
php -i | grep display_errors
# Must be: Off
// Also check Laravel config
config('app.debug'); // false
Category 2: Authentication (5 checks)
Check #9: Password Hashing Algorithm
Severity: HIGH
// config/hashing.php
'driver' => 'bcrypt', // or 'argon2id'
// Verify bcrypt cost
'bcrypt' => [
'rounds' => 12, // Minimum 10, recommended 12
],
Check existing passwords:
// Passwords should start with $2y$ (bcrypt) or $argon2id$
User::first()->password;
Check #10: Session Security
Severity: HIGH
// config/session.php
'secure' => true, // Cookies only over HTTPS
'http_only' => true, // No JavaScript access
'same_site' => 'lax', // Or 'strict' for more security
'lifetime' => 120, // Reasonable timeout (minutes)
# Check .env
grep "SESSION_DRIVER" .env
# Avoid: file (use database, redis, or memcached)
Check #11: Login Throttling
Severity: HIGH
// Check if throttle middleware is applied to login
// routes/web.php or LoginController
Route::post('/login', [LoginController::class, 'login'])
->middleware('throttle:5,1'); // 5 attempts per minute
With Breeze/Jetstream:
// App uses RateLimiter - verify it's not disabled
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->email);
});
Check #12: Password Reset Token Expiry
Severity: MEDIUM
// config/auth.php
'passwords' => [
'users' => [
'expire' => 60, // Minutes - don't set too high
],
],
Check #13: MFA Implementation
Severity: MEDIUM
# Check if MFA package is installed
composer show | grep -E "two-factor|2fa|totp"
Recommended: Implement MFA for admin accounts at minimum.
Category 3: Authorization (4 checks)
Check #14: Gates Return Explicit Boolean
Severity: HIGH
// VULNERABLE - implicit return
Gate::define('admin', function ($user) {
if ($user->is_admin) {
return true;
}
// Missing return false!
});
// SECURE - explicit return
Gate::define('admin', function ($user) {
return $user->is_admin === true;
});
Audit all Gates:
grep -rn "Gate::define" app/
Check #15: Policies Registered
Severity: MEDIUM
// app/Providers/AuthServiceProvider.php
protected $policies = [
Post::class => PostPolicy::class,
User::class => UserPolicy::class,
// All models with authorization should be here
];
Check #16: Middleware Applied to Routes
Severity: HIGH
# List all routes and their middleware
php artisan route:list --columns=uri,middleware
Check for unprotected admin routes:
// All admin routes should have auth + authorization
Route::middleware(['auth', 'can:admin'])->prefix('admin')->group(...);
Check #17: API Routes Protected
Severity: HIGH
// routes/api.php - check auth middleware
Route::middleware('auth:sanctum')->group(function () {
// Protected API routes
});
// Public routes should be intentional and limited
Category 4: Input validation (5 checks)
Check #18: Form Requests Used
Severity: HIGH
# Check if Form Requests exist
ls app/Http/Requests/
# Controllers should use Form Requests, not inline validation
grep -rn "validate(\$request" app/Http/Controllers/
# Minimize inline validation
Best practice:
public function store(StorePostRequest $request) // Form Request
{
Post::create($request->validated()); // Only validated data
}
Check #19: Mass Assignment Protection
Severity: CRITICAL
// Models should define $fillable or $guarded
class User extends Model
{
protected $fillable = ['name', 'email']; // Whitelist approach
// OR
protected $guarded = ['id', 'is_admin']; // Blacklist approach
// NEVER use:
// protected $guarded = []; // Allows ALL fields!
}
Audit all models:
grep -rn "guarded = \[\]" app/Models/
# Should return nothing!
Check #20: SQL Injection Prevention
Severity: CRITICAL
# Search for raw queries with variables
grep -rn "DB::raw\|whereRaw\|selectRaw" app/
Vulnerable:
DB::select("SELECT * FROM users WHERE id = $id"); // VULNERABLE
Secure:
DB::select("SELECT * FROM users WHERE id = ?", [$id]); // Parameterized
User::where('id', $id)->first(); // Eloquent (safe)
Check #21: File Upload Validation
Severity: CRITICAL
// Validate file type by content, not just extension
$request->validate([
'document' => [
'required',
'file',
'mimes:pdf,doc,docx', // MIME type validation
'max:10240', // Size limit (KB)
],
]);
// Store outside public directory
$path = $request->file('document')->store('documents'); // storage/app/documents
Never Trust File Extensions
Attackers rename shell.php to shell.pdf. Always validate MIME type and
store uploads outside the web root.
Check #22: XSS Prevention in Output
Severity: HIGH
# Search for unescaped output
grep -rn "{!!" resources/views/
Vulnerable:
{!! $userInput !!} // Renders HTML - XSS risk!
Secure:
{{ $userInput }} // Escaped by default
{!! clean($html) !!} // If HTML needed, sanitize first
Category 5: Database security (4 checks)
Check #23: Database Credentials Secure
Severity: CRITICAL
# Check database config
grep "DB_PASSWORD" .env
# Should be strong password, not 'password' or 'secret'
Never hardcode:
// config/database.php
'password' => env('DB_PASSWORD'), // Good - from environment
'password' => 'mypassword', // Bad - hardcoded
Check #24: Encryption for Sensitive Data
Severity: HIGH
// Sensitive data should use Laravel's encryption
use Illuminate\Support\Facades\Crypt;
// Storing
$encrypted = Crypt::encryptString($ssn);
// Retrieving
$ssn = Crypt::decryptString($encrypted);
// Or use Eloquent casts
protected $casts = [
'ssn' => 'encrypted',
];
Check #25: Database Backups Encrypted
Severity: HIGH
# Check backup configuration
# If using spatie/laravel-backup
grep -A5 "encryption" config/backup.php
Ensure backups are:
- Encrypted at rest
- Stored off-server
- Tested regularly
Check #26: No Sensitive Data in Logs
Severity: HIGH
// config/logging.php - check what's being logged
// Ensure passwords, tokens, etc. are not logged
// In models, hide sensitive attributes
protected $hidden = ['password', 'remember_token', 'api_key'];
# Search logs for sensitive data patterns
grep -rn "password\|api_key\|secret" storage/logs/
Category 6: API security (4 checks)
Check #27: Rate Limiting Configured
Severity: HIGH
// app/Providers/RouteServiceProvider.php
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// routes/api.php
Route::middleware(['throttle:api'])->group(...);
Check #28: CORS Properly Configured
Severity: MEDIUM
// config/cors.php
'allowed_origins' => ['https://yourdomain.com'], // NOT ['*'] in production!
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
'allowed_headers' => ['Content-Type', 'Authorization'],
'supports_credentials' => true, // Only if needed
Check #29: API Tokens Rotated
Severity: MEDIUM
// Sanctum tokens should have expiration
'expiration' => 60 * 24, // 24 hours
// Implement token rotation
$user->tokens()->delete(); // Invalidate old tokens
$newToken = $user->createToken('api');
Check #30: Sensitive Data Not in URLs
Severity: HIGH
# Check routes for sensitive data in GET params
php artisan route:list | grep -E "GET.*\{.*token\|key\|password"
Vulnerable:
Route::get('/reset/{token}', ...); // Token in URL = logged everywhere
Better:
Route::post('/reset', ...); // Token in POST body
Category 7: File system security (4 checks)
Check #31: Storage Permissions Correct
Severity: HIGH
# Check permissions
ls -la storage/
# Directories: 755 (drwxr-xr-x)
# Files: 644 (-rw-r--r--)
# Fix if needed
chmod -R 755 storage/
chmod -R 755 bootstrap/cache/
Check #32: No PHP in Upload Directories
Severity: CRITICAL
# Search for PHP files in upload directories
find storage/app/public -name "*.php" -o -name "*.phtml"
find public/uploads -name "*.php" -o -name "*.phtml"
# Should return nothing!
Block PHP execution in uploads:
# public/uploads/.htaccess
<FilesMatch "\.php$">
Order Deny,Allow
Deny from all
</FilesMatch>
Check #33: Symbolic Links Secure
Severity: MEDIUM
# Check storage link
ls -la public/storage
# Should point to storage/app/public only
Check #34: Backup Files Not Accessible
Severity: HIGH
# Check for backup files in public directory
find public -name "*.bak" -o -name "*.sql" -o -name "*.zip"
# Should return nothing!
Category 8: Dependencies (3 checks)
Check #35: Composer Audit Clean
Severity: CRITICAL
composer audit
# Should return: No security vulnerability advisories found
If vulnerabilities found:
composer update package/name
# Or if patch not available, find alternative
Check #36: Dependencies Up to Date
Severity: HIGH
composer outdated
# Review and update critical packages
Priority updates:
laravel/frameworklivewire/livewirelaravel/sanctum- Any auth-related packages
Check #37: No Dev Dependencies in Production
Severity: MEDIUM
# Production install should use --no-dev
composer install --no-dev --optimize-autoloader
Category 9: Logging & monitoring (2 checks)
Check #38: Security Events Logged
Severity: HIGH
// Log important security events
Log::warning('Failed login attempt', [
'email' => $request->email,
'ip' => $request->ip(),
]);
Log::alert('Admin action', [
'user' => auth()->id(),
'action' => 'deleted_user',
'target' => $userId,
]);
Check #39: Log Files Protected
Severity: MEDIUM
# Logs should not be web accessible
curl https://yoursite.com/storage/logs/laravel.log
# Should return 403 or 404, not log contents
Category 10: HTTPS & headers (1 check)
Check #40: Security Headers Set
Severity: HIGH
// app/Http/Middleware/SecurityHeaders.php
public function handle($request, $next)
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$response->headers->set('Content-Security-Policy', "default-src 'self'");
return $response;
}
Register in Kernel.php:
protected $middleware = [
\App\Http\Middleware\SecurityHeaders::class,
// ...
];
Quick audit script
Run this script to check critical issues:
#!/bin/bash
echo "=== Laravel Security Audit ==="
echo ""
# Check 1: Debug mode
DEBUG=$(grep "APP_DEBUG" .env | cut -d'=' -f2)
if [ "$DEBUG" = "true" ]; then
echo "β CRITICAL: APP_DEBUG=true"
else
echo "β
APP_DEBUG=false"
fi
# Check 2: Environment
ENV=$(grep "APP_ENV" .env | cut -d'=' -f2)
if [ "$ENV" != "production" ]; then
echo "β οΈ WARNING: APP_ENV=$ENV (not production)"
else
echo "β
APP_ENV=production"
fi
# Check 3: APP_KEY set
KEY=$(grep "APP_KEY" .env | cut -d'=' -f2)
if [[! $KEY == base64:*]]; then
echo "β CRITICAL: APP_KEY not properly set"
else
echo "β
APP_KEY configured"
fi
# Check 4: .env in git
if git ls-files | grep -q "^.env$"; then
echo "β CRITICAL: .env is tracked in git!"
else
echo "β
.env not in git"
fi
# Check 5: Composer audit
echo ""
echo "Running composer audit..."
composer audit 2>/dev/null
if [ $? -eq 0 ]; then
echo "β
No known vulnerabilities"
fi
# Check 6: PHP files in uploads
echo ""
PHP_IN_UPLOADS=$(find storage/app/public public/uploads -name "*.php" 2>/dev/null | wc -l)
if [ "$PHP_IN_UPLOADS" -gt 0 ]; then
echo "β CRITICAL: PHP files found in upload directories!"
find storage/app/public public/uploads -name "*.php" 2>/dev/null
else
echo "β
No PHP in upload directories"
fi
# Check 7: Guarded empty
GUARDED=$(grep -rn "guarded = []" app/Models/ 2>/dev/null | wc -l)
if [ "$GUARDED" -gt 0 ]; then
echo "β CRITICAL: Models with empty $guarded found!"
grep -rn "guarded = []" app/Models/
else
echo "β
No unguarded models"
fi
echo ""
echo "=== Audit Complete ==="
Summary checklist
Critical (Must Fix Immediately)
- #1 - Debug mode disabled
- #3 - APP_KEY properly set
- #4 - .env not in git
- #19 - Mass assignment protected
- #20 - SQL injection prevented
- #21 - File uploads validated
- #32 - No PHP in upload directories
- #35 - Composer audit clean
High Priority (Fix This Week)
- #2 - Environment set to production
- #5 - Debugbar disabled
- #6 - Telescope secured
- #7 - register_argc_argv disabled
- #9 - Strong password hashing
- #10 - Session security configured
- #11 - Login throttling enabled
- #14 - Gates return explicit boolean
- #16 - Middleware on routes
- #17 - API routes protected
- #18 - Form Requests used
- #22 - XSS prevention
- #23 - Database credentials secure
- #24 - Sensitive data encrypted
- #27 - Rate limiting configured
- #30 - No sensitive data in URLs
- #31 - Storage permissions correct
- #36 - Dependencies updated
- #38 - Security events logged
- #40 - Security headers set
Medium Priority (Schedule for Next Sprint)
- #8 - Error display disabled
- #12 - Password reset expiry
- #13 - MFA implementation
- #15 - Policies registered
- #25 - Backups encrypted
- #26 - No sensitive data in logs
- #28 - CORS configured
- #29 - API tokens rotated
- #33 - Symbolic links secure
- #34 - No backup files in public
- #37 - No dev dependencies in production
- #39 - Log files protected
Next: Chapter 8 - The Impossible Task: Staying Secure Without Automation
Youβve seen 40 checks. Imagine running them across 10 applications, every week, while also writing features. The next chapter explains why manual security is unsustainable - and what the alternative looks like.