API Security Best Practices: HMAC, Rate Limiting, and Idempotency
Essential security patterns for building robust APIs. Covering request signing, rate limiting strategies, and ensuring idempotent operations.
Building secure APIs requires more than just authentication. In this post, I'll cover three critical patterns I use in every production API: request signing with HMAC, intelligent rate limiting, and idempotent operations.
Request Signing with HMAC
For APIs that handle sensitive operations, simple API keys aren't enough. HMAC (Hash-based Message Authentication Code) ensures that requests haven't been tampered with in transit.
How It Works
The client creates a signature by hashing the request details with a shared secret:
// Client-side signing
class ApiClient
{
public function sign(string $method, string $path, array $body): string
{
$timestamp = time();
$payload = implode("\n", [
$method,
$path,
json_encode($body),
$timestamp,
]);
return hash_hmac('sha256', $payload, $this->secretKey);
}
}The server verifies the signature before processing:
// Server-side verification
class HmacMiddleware
{
public function handle(Request $request, Closure $next)
{
$signature = $request->header('X-Signature');
$timestamp = $request->header('X-Timestamp');
// Reject old requests (prevents replay attacks)
if (abs(time() - $timestamp) > 300) {
return response()->json(['error' => 'Request expired'], 401);
}
$expectedSignature = $this->calculateSignature(
$request->method(),
$request->path(),
$request->all(),
$timestamp
);
if (!hash_equals($expectedSignature, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
return $next($request);
}
}Key Points
- Always use
hash_equals()for timing-safe comparison - Include a timestamp to prevent replay attacks
- Use HTTPS to protect the signature in transit
- Rotate secrets periodically
Intelligent Rate Limiting
Basic rate limiting (X requests per minute) is a start, but production APIs need more sophisticated approaches.
Sliding Window Algorithm
Instead of fixed windows that reset abruptly, I use sliding window rate limiting:
class SlidingWindowRateLimiter
{
public function attempt(string $key, int $maxAttempts, int $windowSeconds): bool
{
$now = microtime(true);
$windowStart = $now - $windowSeconds;
// Remove old entries
Redis::zremrangebyscore($key, '-inf', $windowStart);
// Count current entries
$currentCount = Redis::zcard($key);
if ($currentCount >= $maxAttempts) {
return false;
}
// Add new entry
Redis::zadd($key, $now, $now . ':' . Str::random(8));
Redis::expire($key, $windowSeconds);
return true;
}
}Tiered Rate Limits
Different endpoints deserve different limits:
class TieredRateLimiter
{
private array $tiers = [
'read' => ['limit' => 1000, 'window' => 3600], // 1000/hour
'write' => ['limit' => 100, 'window' => 3600], // 100/hour
'expensive' => ['limit' => 10, 'window' => 3600], // 10/hour
];
public function limitForRoute(string $route): array
{
return match(true) {
str_starts_with($route, 'api/reports') => $this->tiers['expensive'],
in_array(request()->method(), ['POST', 'PUT', 'DELETE']) => $this->tiers['write'],
default => $this->tiers['read'],
};
}
}User-Based Limits
Rate limit by user, not just IP (IPs can be shared):
public function resolveRequestSignature(Request $request): string
{
if ($user = $request->user()) {
return 'user:' . $user->id;
}
return 'ip:' . $request->ip();
}Idempotent Operations
Idempotency ensures that repeating the same request produces the same result. This is critical for payment processing, order creation, and any operation that shouldn't happen twice.
Idempotency Keys
Clients include a unique key with each request:
class IdempotencyMiddleware
{
public function handle(Request $request, Closure $next)
{
if (!in_array($request->method(), ['POST', 'PUT', 'PATCH'])) {
return $next($request);
}
$key = $request->header('Idempotency-Key');
if (!$key) {
return $next($request);
}
// Check for existing response
$cached = Cache::get("idempotency:{$key}");
if ($cached) {
return response()->json(
$cached['body'],
$cached['status'],
['X-Idempotent-Replayed' => 'true']
);
}
$response = $next($request);
// Cache the response for 24 hours
Cache::put("idempotency:{$key}", [
'body' => $response->getData(),
'status' => $response->status(),
], 86400);
return $response;
}
}Database-Level Idempotency
For critical operations, store idempotency at the database level:
class PaymentService
{
public function processPayment(string $idempotencyKey, array $data): Payment
{
return DB::transaction(function () use ($idempotencyKey, $data) {
// Try to find existing payment with this key
$existing = Payment::where('idempotency_key', $idempotencyKey)
->lockForUpdate()
->first();
if ($existing) {
return $existing;
}
// Create new payment
$payment = Payment::create([
'idempotency_key' => $idempotencyKey,
'amount' => $data['amount'],
'status' => 'pending',
]);
// Process with gateway...
return $payment;
});
}
}Putting It All Together
A production API combines all these patterns:
Route::middleware([
'auth:api',
'hmac.verify',
'throttle:api',
'idempotency',
])->group(function () {
Route::post('/orders', [OrderController::class, 'store']);
Route::post('/payments', [PaymentController::class, 'store']);
});Summary
- HMAC signing prevents request tampering and ensures authenticity
- Sliding window rate limiting provides smoother, fairer throttling
- Idempotency keys prevent duplicate operations and improve reliability