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):
- Broken Object Level Authorization (BOLA) - Users can access resources they shouldn't
- Broken Authentication - Weak authentication mechanisms
- Broken Object Property Level Authorization - Mass assignment and excessive data exposure
- Unrestricted Resource Consumption - Lack of rate limiting
- Broken Function Level Authorization - Missing authorization checks on functions
- Unrestricted Access to Sensitive Business Flows - No protection against automated threats
- Server Side Request Forgery (SSRF) - API fetches remote resources without validation
- Security Misconfiguration - Improper security settings
- Improper Inventory Management - Lack of API versioning and documentation
- 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:
- Audit an existing API endpoint for BOLA vulnerabilities
- Implement a comprehensive Form Request with validation and sanitization
- Configure CORS properly for a production environment
- Add security headers middleware to your API
- Implement tiered rate limiting (different limits for free vs premium users)
- 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.