REST API Development

API Security Best Practices

18 min Lesson 22 of 35

API Security Best Practices

Security is paramount when building APIs. A single vulnerability can expose sensitive data, compromise user accounts, or bring down your entire system. In this lesson, we'll explore the OWASP API Security Top 10, input sanitization, CORS configuration, content security, and preventing mass assignment vulnerabilities.

OWASP API Security Top 10

The Open Web Application Security Project (OWASP) maintains a list of the most critical API security risks. Understanding these risks is the first step to building secure APIs.

OWASP API Security Top 10 (2023):
  1. Broken Object Level Authorization (BOLA) - Users can access resources they shouldn't
  2. Broken Authentication - Weak authentication mechanisms
  3. Broken Object Property Level Authorization - Mass assignment and excessive data exposure
  4. Unrestricted Resource Consumption - Lack of rate limiting
  5. Broken Function Level Authorization - Missing authorization checks on functions
  6. Unrestricted Access to Sensitive Business Flows - No protection against automated threats
  7. Server Side Request Forgery (SSRF) - API fetches remote resources without validation
  8. Security Misconfiguration - Improper security settings
  9. Improper Inventory Management - Lack of API versioning and documentation
  10. Unsafe Consumption of APIs - Blindly trusting data from third-party APIs

Preventing Broken Object Level Authorization (BOLA)

BOLA is the #1 API security risk. It occurs when an API doesn't properly check if a user has permission to access a specific resource.

// ❌ VULNERABLE CODE Route::get('/api/v1/orders/{id}', function ($id) { $order = Order::findOrFail($id); return response()->json($order); }); // Problem: Any authenticated user can access ANY order by ID // ✅ SECURE CODE Route::get('/api/v1/orders/{id}', function ($id) { $order = Order::findOrFail($id); // Check if the authenticated user owns this order if ($order->user_id !== auth()->id()) { abort(403, 'Unauthorized to access this order'); } return response()->json($order); }); // ✅ BETTER: Use Laravel Policy Route::get('/api/v1/orders/{order}', function (Order $order) { $this->authorize('view', $order); return new OrderResource($order); }); // app/Policies/OrderPolicy.php class OrderPolicy { public function view(User $user, Order $order): bool { return $user->id === $order->user_id || $user->hasRole('admin'); } public function update(User $user, Order $order): bool { // Users can only update their own pending orders return $user->id === $order->user_id && $order->status === 'pending'; } }
Never Trust URL Parameters: Always validate that the authenticated user has permission to access the resource specified in the URL. Don't assume that because a user knows an ID, they should be able to access it.

Input Sanitization and Validation

Never trust user input. Every piece of data from clients must be validated and sanitized before processing.

<?php // app/Http/Requests/CreateUserRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => [ 'required', 'string', 'max:255', 'regex:/^[a-zA-Z\s]+$/' // Only letters and spaces ], 'email' => [ 'required', 'email:rfc,dns', // Validates RFC compliance and DNS 'max:255', 'unique:users,email' ], 'password' => [ 'required', 'confirmed', Password::min(8) ->mixedCase() ->numbers() ->symbols() ->uncompromised() // Checks against leaked password database ], 'phone' => [ 'nullable', 'regex:/^\+?[1-9]\d{1,14}$/' // E.164 format ], 'website' => [ 'nullable', 'url', 'active_url' // Verifies DNS record exists ], 'bio' => [ 'nullable', 'string', 'max:1000' ] ]; } public function messages(): array { return [ 'email.email' => 'Please provide a valid email address', 'password.uncompromised' => 'This password has been exposed in a data breach. Please choose a different one.', 'name.regex' => 'Name can only contain letters and spaces' ]; } // Sanitize input after validation protected function passedValidation(): void { $this->merge([ 'name' => strip_tags($this->name), 'email' => strtolower(trim($this->email)), 'bio' => $this->bio ? strip_tags($this->bio) : null ]); } } // Usage in controller public function store(CreateUserRequest $request) { // $request->validated() returns only validated and sanitized data $user = User::create($request->validated()); return new UserResource($user); }

Preventing SQL Injection

Always use parameterized queries or ORM methods. Never concatenate user input into SQL queries.

<?php // ❌ VULNERABLE to SQL injection $email = $request->input('email'); $user = DB::select("SELECT * FROM users WHERE email = '$email'"); // If user sends: admin@example.com' OR '1'='1 // Query becomes: SELECT * FROM users WHERE email = 'admin@example.com' OR '1'='1' // This returns ALL users! // ✅ SAFE: Use parameter binding $email = $request->input('email'); $user = DB::select('SELECT * FROM users WHERE email = ?', [$email]); // ✅ BETTER: Use Query Builder $user = DB::table('users')->where('email', $email)->first(); // ✅ BEST: Use Eloquent ORM $user = User::where('email', $email)->first(); // Even better with scopes class User extends Model { public function scopeByEmail($query, string $email) { return $query->where('email', $email); } } $user = User::byEmail($email)->first();

Mass Assignment Protection

Mass assignment vulnerabilities occur when users can modify fields they shouldn't have access to.

<?php // ❌ VULNERABLE CODE class User extends Model { // No protection! } Route::post('/api/v1/users', function (Request $request) { $user = User::create($request->all()); return response()->json($user); }); // Attacker sends: // { // "name": "John", // "email": "john@example.com", // "is_admin": true, ← Should not be settable by user! // "balance": 1000000 ← Should not be settable by user! // } // ✅ SECURE: Use $fillable or $guarded class User extends Model { // Option 1: Whitelist allowed fields protected $fillable = [ 'name', 'email', 'password', 'bio' ]; // Option 2: Blacklist forbidden fields protected $guarded = [ 'id', 'is_admin', 'balance', 'created_at', 'updated_at' ]; // Make sensitive fields never visible in JSON protected $hidden = [ 'password', 'remember_token', 'two_factor_secret' ]; } // Best practice: Only pass validated data Route::post('/api/v1/users', function (CreateUserRequest $request) { // $request->validated() only contains allowed fields $user = User::create($request->validated()); return new UserResource($user); });
Always Use Form Requests: Form Request classes provide validation, authorization, and ensure you only pass validated data to your models. This prevents mass assignment vulnerabilities automatically.

CORS Configuration

Cross-Origin Resource Sharing (CORS) controls which domains can access your API from browsers. Misconfigured CORS can expose your API to attacks.

<?php // config/cors.php return [ // ❌ TOO PERMISSIVE 'paths' => ['api/*'], 'allowed_methods' => ['*'], 'allowed_origins' => ['*'], // Allows ANY domain! 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => [], 'max_age' => 0, 'supports_credentials' => true, // DANGEROUS with allowed_origins: * // ✅ SECURE CONFIGURATION 'paths' => ['api/*'], 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'allowed_origins' => [ 'https://myapp.com', 'https://www.myapp.com', 'https://admin.myapp.com' ], 'allowed_origins_patterns' => [ '/^https:\/\/([a-z]+\.)?myapp\.com$/' ], 'allowed_headers' => [ 'Content-Type', 'Authorization', 'X-Requested-With', 'Accept' ], 'exposed_headers' => [ 'X-Total-Count', 'X-Page-Count' ], 'max_age' => 86400, // 24 hours 'supports_credentials' => true, ]; // For environment-based configuration return [ 'allowed_origins' => array_filter([ env('FRONTEND_URL'), env('ADMIN_URL'), ]), ];
NEVER use `allowed_origins: ['*']` with `supports_credentials: true`! This combination allows any website to make authenticated requests to your API on behalf of your users, leading to CSRF attacks.

Content Security Policy (CSP)

While primarily for web pages, APIs should also implement security headers to prevent various attacks.

<?php // 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); // Prevent MIME type sniffing $response->headers->set('X-Content-Type-Options', 'nosniff'); // Prevent clickjacking $response->headers->set('X-Frame-Options', 'DENY'); // Enable XSS protection $response->headers->set('X-XSS-Protection', '1; mode=block'); // Strict Transport Security (force HTTPS) $response->headers->set( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); // Content Security Policy $response->headers->set( 'Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'" ); // Referrer Policy $response->headers->set('Referrer-Policy', 'no-referrer'); // Permissions Policy (formerly Feature Policy) $response->headers->set( 'Permissions-Policy', 'geolocation=(), microphone=(), camera=()' ); return $response; } } // Register in app/Http/Kernel.php protected $middlewareGroups = [ 'api' => [ SecurityHeaders::class, // ... other middleware ], ];

Rate Limiting and Throttling

Protect your API from abuse and denial-of-service attacks with rate limiting.

<?php // app/Http/Kernel.php protected $middlewareGroups = [ 'api' => [ \Illuminate\Routing\Middleware\ThrottleRequests::class . ':api', ], ]; // config/routes.php - Define rate limits use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); // Different limits for different endpoints RateLimiter::for('login', function (Request $request) { return Limit::perMinute(5)->by($request->ip()) ->response(function () { return response()->json([ 'errors' => [[ 'status' => '429', 'title' => 'Too Many Requests', 'detail' => 'Please wait before attempting to login again' ]] ], 429); }); }); RateLimiter::for('uploads', function (Request $request) { return $request->user()->isPremium() ? Limit::perMinute(100) : Limit::perMinute(10); }); // Apply in routes Route::middleware(['throttle:login'])->group(function () { Route::post('/login', [AuthController::class, 'login']); Route::post('/register', [AuthController::class, 'register']); }); Route::middleware(['auth:sanctum', 'throttle:uploads'])->group(function () { Route::post('/upload', [UploadController::class, 'store']); });

Secure Password Storage

<?php // ❌ NEVER store plain text passwords $user->password = $request->password; // ❌ NEVER use weak hashing $user->password = md5($request->password); $user->password = sha1($request->password); // ✅ Use bcrypt (Laravel default) $user->password = Hash::make($request->password); // ✅ Better: Use Argon2id (more secure, configurable) // config/hashing.php return [ 'driver' => 'argon2id', 'argon' => [ 'memory' => 65536, 'threads' => 4, 'time' => 4, ], ]; $user->password = Hash::make($request->password); // Verify passwords if (Hash::check($request->password, $user->password)) { // Password matches } // Check if password needs rehashing (algorithm changed) if (Hash::needsRehash($user->password)) { $user->password = Hash::make($request->password); $user->save(); }

Preventing Timing Attacks

<?php // ❌ VULNERABLE to timing attacks public function login(Request $request) { $user = User::where('email', $request->email)->first(); if (!$user) { return response()->json(['error' => 'Invalid email'], 401); } if (!Hash::check($request->password, $user->password)) { return response()->json(['error' => 'Invalid password'], 401); } // Login successful } // Problem: Response time differs based on whether email exists // ✅ SECURE: Always perform same operations public function login(Request $request) { $user = User::where('email', $request->email)->first(); // Always hash the password, even if user doesn't exist $passwordValid = $user && Hash::check( $request->password, $user->password ); if (!$passwordValid) { // Same response regardless of email existence return response()->json([ 'errors' => [[ 'status' => '401', 'title' => 'Authentication Failed', 'detail' => 'Invalid credentials' ]] ], 401); } // Login successful }
Exercise:
  1. Audit an existing API endpoint for BOLA vulnerabilities
  2. Implement a comprehensive Form Request with validation and sanitization
  3. Configure CORS properly for a production environment
  4. Add security headers middleware to your API
  5. Implement tiered rate limiting (different limits for free vs premium users)
  6. Review your models and ensure all have proper $fillable or $guarded properties
Security is a Process: Security isn't a one-time task. Regularly audit your code, stay updated on new vulnerabilities, use dependency scanning tools, and conduct penetration testing on your APIs.