πŸ”’ Hacked
Chapter 7

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.

40
Security Checks
10
Categories
~2h
Full Audit Time
∞
Breaches Prevented

How to use this checklist

Each check includes:

Recommended approach:

  1. Run all CRITICAL checks first
  2. Address HIGH severity issues
  3. Schedule MEDIUM/LOW for next sprint
  4. 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:


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>

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:


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:

security-audit.sh
#!/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)

High Priority (Fix This Week)

Medium Priority (Schedule for Next Sprint)


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.