API Security Audit Checklist for Laravel: OWASP Best Practices

· 7 min read

Introduction

APIs are the main gateway of modern applications and also the most common attack target. This article provides a comprehensive security checklist for Laravel APIs based on OWASP API Security Top 10.

OWASP API Security Top 10 (2023)

  1. API1:2023 - Broken Object Level Authorization
  2. API2:2023 - Broken Authentication
  3. API3:2023 - Broken Object Property Level Authorization
  4. API4:2023 - Unrestricted Resource Consumption
  5. API5:2023 - Broken Function Level Authorization
  6. API6:2023 - Unrestricted Access to Sensitive Business Flows
  7. API7:2023 - Server Side Request Forgery (SSRF)
  8. API8:2023 - Security Misconfiguration
  9. API9:2023 - Improper Inventory Management
  10. API10:2023 - Unsafe Consumption of APIs

1. Authentication Security

Secure Token Configuration

// config/sanctum.php
return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
    ))),
    
    'guard' => ['web'],
    
    'expiration' => 60 * 24, // 24 hours
    
    'middleware' => [
        'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
        'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
    ],
];

Token Abilities (Scopes)

// app/Http/Controllers/AuthController.php
<?php

namespace App\Http\Controllers;

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

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required|string',
            'device_name' => 'required|string|max:255',
        ]);
        
        $user = User::where('email', $request->email)->first();
        
        if (!$user || !Hash::check($request->password, $user->password)) {
            // Generic error message to prevent user enumeration
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }
        
        // Check if account is locked
        if ($user->isLocked()) {
            throw ValidationException::withMessages([
                'email' => ['Account is temporarily locked. Please try again later.'],
            ]);
        }
        
        // Define abilities based on user role
        $abilities = $this->getAbilitiesForUser($user);
        
        // Revoke old tokens for this device
        $user->tokens()
            ->where('name', $request->device_name)
            ->delete();
        
        $token = $user->createToken(
            $request->device_name,
            $abilities,
            now()->addHours(24)
        );
        
        return response()->json([
            'token' => $token->plainTextToken,
            'expires_at' => $token->accessToken->expires_at,
        ]);
    }
    
    private function getAbilitiesForUser(User $user): array
    {
        return match ($user->role) {
            'admin' => ['*'],
            'manager' => ['users:read', 'users:write', 'orders:read', 'orders:write'],
            'staff' => ['orders:read', 'orders:write'],
            default => ['profile:read', 'profile:write'],
        };
    }
}

Brute Force Protection

// app/Services/LoginAttemptService.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;

class LoginAttemptService
{
    private const MAX_ATTEMPTS = 5;
    private const DECAY_MINUTES = 15;
    private const LOCKOUT_MINUTES = 60;
    
    public function recordFailedAttempt(string $email, string $ip): void
    {
        $emailKey = $this->getEmailKey($email);
        $ipKey = $this->getIpKey($ip);
        
        RateLimiter::hit($emailKey, self::DECAY_MINUTES * 60);
        RateLimiter::hit($ipKey, self::DECAY_MINUTES * 60);
        
        if ($this->tooManyAttempts($email, $ip)) {
            $this->lockAccount($email);
        }
    }
    
    public function tooManyAttempts(string $email, string $ip): bool
    {
        return RateLimiter::tooManyAttempts($this->getEmailKey($email), self::MAX_ATTEMPTS) ||
               RateLimiter::tooManyAttempts($this->getIpKey($ip), self::MAX_ATTEMPTS * 3);
    }
    
    private function lockAccount(string $email): void
    {
        Cache::put("account_locked:{$email}", true, now()->addMinutes(self::LOCKOUT_MINUTES));
        
        Log::channel('security')->warning('Account locked due to too many failed attempts', [
            'email' => $email,
        ]);
    }
    
    private function getEmailKey(string $email): string
    {
        return 'login_attempts:email:' . hash('sha256', strtolower($email));
    }
    
    private function getIpKey(string $ip): string
    {
        return 'login_attempts:ip:' . $ip;
    }
}

2. Authorization (BOLA & BFLA Protection)

Policy-Based Authorization

// app/Policies/OrderPolicy.php
<?php

namespace App\Policies;

use App\Models\Order;
use App\Models\User;

class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        // Admin can view all
        if ($user->isAdmin()) {
            return true;
        }
        
        // Users can only view their own orders
        return $user->id === $order->user_id;
    }
    
    public function update(User $user, Order $order): bool
    {
        if (!$user->tokenCan('orders:write')) {
            return false;
        }
        
        return $user->id === $order->user_id || $user->isAdmin();
    }
    
    public function delete(User $user, Order $order): bool
    {
        return $user->isAdmin() && $user->tokenCan('orders:delete');
    }
}

Prevent Mass Assignment

// app/Models/User.php
class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'email',
        'password',
    ];
    
    // Never allow these to be mass assigned
    protected $guarded = [
        'id',
        'role',
        'is_admin',
        'email_verified_at',
    ];
    
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_secret',
    ];
}

3. Input Validation

Request Validation

// app/Http/Requests/StoreUserRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => [
                'required',
                'string',
                'min:2',
                'max:255',
                'regex:/^[\pL\s\-\']+$/u',
            ],
            'email' => [
                'required',
                'string',
                'email:rfc,dns',
                'max:255',
                'unique:users,email',
            ],
            'password' => [
                'required',
                'string',
                Password::min(12)
                    ->letters()
                    ->mixedCase()
                    ->numbers()
                    ->symbols()
                    ->uncompromised(3),
                'confirmed',
            ],
        ];
    }
}

SQL Injection Prevention

// ❌ NEVER do this - SQL Injection vulnerable
$users = DB::select("SELECT * FROM users WHERE email = '{$request->email}'");

// ✅ Use parameter binding
$users = DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);

// ✅ Or use Eloquent/Query Builder
$users = User::where('email', $request->email)->get();

4. Rate Limiting

Advanced Rate Limiting

// app/Providers/RouteServiceProvider.php
protected function configureRateLimiting(): void
{
    // Default API rate limit
    RateLimiter::for('api', function (Request $request) {
        $user = $request->user();
        
        if ($user) {
            $limit = match ($user->subscription_tier) {
                'enterprise' => 10000,
                'business' => 5000,
                'pro' => 1000,
                default => 100,
            };
            
            return Limit::perMinute($limit)->by($user->id);
        }
        
        return Limit::perMinute(20)->by($request->ip());
    });
    
    // Strict rate limit for authentication
    RateLimiter::for('auth', function (Request $request) {
        return [
            Limit::perMinute(5)->by($request->ip()),
            Limit::perMinute(10)->by($request->input('email', 'guest')),
        ];
    });
}

5. Security Headers

// app/Http/Middleware/SecurityHeaders.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-XSS-Protection', '1; mode=block');
        $response->headers->set('X-Frame-Options', 'DENY');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
        
        if ($request->secure()) {
            $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
        }
        
        $response->headers->remove('X-Powered-By');
        $response->headers->remove('Server');
        
        return $response;
    }
}

6. Logging & Monitoring

// app/Services/SecurityLogger.php
<?php

namespace App\Services;

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

class SecurityLogger
{
    public function logAuthenticationAttempt(Request $request, bool $success): void
    {
        $context = [
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'email' => $request->input('email'),
            'success' => $success,
            'timestamp' => now()->toIso8601String(),
        ];
        
        if ($success) {
            Log::channel('security')->info('Authentication successful', $context);
        } else {
            Log::channel('security')->warning('Authentication failed', $context);
        }
    }
    
    public function logSuspiciousActivity(Request $request, string $activity, array $details = []): void
    {
        Log::channel('security')->alert('Suspicious activity detected', [
            'user_id' => $request->user()?->id,
            'activity' => $activity,
            'details' => $details,
            'ip' => $request->ip(),
            'timestamp' => now()->toIso8601String(),
        ]);
    }
}

7. SSRF Prevention

// app/Services/SafeHttpClient.php
<?php

namespace App\Services;

class SafeHttpClient
{
    private array $blockedHosts = [
        'localhost', '127.0.0.1', '::1', '0.0.0.0',
        '169.254.169.254', // AWS metadata
    ];
    
    private array $blockedCidrs = [
        '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
        '127.0.0.0/8', '169.254.0.0/16',
    ];
    
    public function validateUrl(string $url): void
    {
        $parsed = parse_url($url);
        
        if (!$parsed || !isset($parsed['host'])) {
            throw new \InvalidArgumentException('Invalid URL');
        }
        
        $host = strtolower($parsed['host']);
        
        if (in_array($host, $this->blockedHosts)) {
            throw new \InvalidArgumentException('Access to this host is not allowed');
        }
        
        $ips = gethostbynamel($host);
        
        foreach ($ips as $ip) {
            if ($this->isBlockedIp($ip)) {
                throw new \InvalidArgumentException('Access to this IP range is not allowed');
            }
        }
    }
}

8. API Security Checklist

Authentication

  • Use strong token-based authentication
  • Implement token expiration
  • Implement brute force protection
  • Support MFA/2FA

Authorization

  • Implement resource-level authorization (Policies)
  • Check ownership for every resource access
  • Prevent mass assignment vulnerabilities

Data Protection

  • Use HTTPS everywhere
  • Encrypt sensitive data at rest
  • Hash passwords with bcrypt/argon2

Rate Limiting

  • Apply rate limits to all endpoints
  • Stricter limits for authentication endpoints
  • Per-user rate limiting

Headers & CORS

  • Set all security headers
  • Configure CORS properly
  • Enable HSTS

Logging & Monitoring

  • Log all authentication events
  • Log authorization failures
  • Monitor for suspicious patterns

Conclusion

API security is an ongoing process. Make sure to:

  1. Review code regularly
  2. Update dependencies periodically
  3. Monitor logs and alerts
  4. Conduct security audits
  5. Stay updated with OWASP guidelines

References

Comments