Rate Limiting and REST API Protection: Redis-Based Strategies with Go and Laravel Examples
1. Why Rate Limiting Matters in 2026
Any public REST API without request rate restrictions is an open target. In 2026, application-layer (L7) attacks account for the majority of incidents: credential stuffing, scraping, abuse of free tiers, and DDoS through legitimate endpoints. Network filters are powerless here — the traffic looks like ordinary HTTP requests.
Rate limiting solves several problems at once: it protects the backend from overload, reduces computation and outbound traffic costs, ensures fair resource distribution among users, and makes API monetization predictable. For microservices architectures, this is especially critical — one overloaded service can cascade and bring down the entire system.
2. Rate Limiting Algorithms: Comparing Approaches
Before writing any code, it's important to choose the right algorithm. Each one offers its own trade-offs between accuracy, memory usage, and implementation simplicity.
Fixed Window
The counter resets every N seconds. For example, 100 requests per minute. Implementation is trivial, but there's a serious flaw: a double burst is possible at window boundaries — 100 requests in the last seconds of one window and 100 requests in the first seconds of the next.
- Pros: minimal memory usage, simple implementation.
- Cons: boundary effect that doubles traffic.
Sliding Window Log
For each client, a list of timestamps for all requests is stored. On a new request, outdated timestamps are removed and the list length is checked. Absolutely precise method.
- Pros: no boundary effect, 100% accuracy.
- Cons: memory usage proportional to the number of requests in the window.
Sliding Window Counter
A hybrid approach: the current window counter and a portion of the previous window counter — proportional to elapsed time — are combined. Formula: count = current_count + prev_count * (window - elapsed) / window. Good accuracy-to-memory trade-off.
- Pros: O(1) memory, good accuracy, no boundary effect.
- Cons: an approximation, not an exact value.
Token Bucket
The bucket fills with tokens at a fixed rate up to a maximum. Each request consumes one token. If there are no tokens — the request is rejected. Supports bursts: clients can accumulate tokens and spend them all at once.
- Pros: natural burst support, intuitive to understand.
- Cons: requires storing two values (token count + last refill timestamp).
Leaky Bucket
Requests are queued and processed at a constant rate. Ideal for traffic shaping, but not suitable for interactive APIs — requests may stall in the queue.
- Pros: smooth outbound traffic.
- Cons: latency, queue requires memory.
Practical takeaway: for most public REST APIs, the optimal choice is Sliding Window Counter (balance of accuracy and resources) or Token Bucket (when burst support is needed). Fixed Window is acceptable for internal services with low accuracy requirements.
3. Redis as the Foundation for Distributed Rate Limiting
In-memory counters inside the application process don't work with horizontal scaling: each instance counts independently, and the real limit is multiplied by the number of pods. Redis solves this with a centralized store and atomic operations.
Key Redis capabilities for rate limiting:
- INCR + EXPIRE: atomically increment a counter and set TTL in one operation.
- Lua scripts: executed atomically on the Redis side, allowing complex logic without race conditions.
- ZADD / ZRANGEBYSCORE: sorted sets for Sliding Window Log.
- Pipelines: batch command sending to reduce latency.
Lua is critically important: without it, the sequence "read counter → check → increment" involves three separate requests, between which a race condition can occur. A Lua script is atomic by definition.
4. Implementing Sliding Window Counter with Redis + Go
Let's look at a complete rate limit middleware implementation for Go using Redis. The algorithm uses two keys per window (current and previous), and the counter is calculated as a weighted sum.
// ratelimit/sliding_window.go
package ratelimit
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
const slidingWindowLua = `
local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local elapsed = now % window
local weight = (window - elapsed) / window
local prev_count = tonumber(redis.call('GET', previous_key) or 0)
local curr_count = tonumber(redis.call('GET', current_key) or 0)
local estimated = math.floor(prev_count * weight + curr_count)
if estimated >= limit then
return {0, estimated, limit}
end
local new_count = redis.call('INCR', current_key)
if new_count == 1 then
redis.call('EXPIRE', current_key, window * 2)
end
return {1, estimated + 1, limit}
`
type SlidingWindowLimiter struct {
client *redis.Client
limit int
windowSecs int
script *redis.Script
}
func NewSlidingWindowLimiter(client *redis.Client, limit, windowSecs int) *SlidingWindowLimiter {
return &SlidingWindowLimiter{
client: client,
limit: limit,
windowSecs: windowSecs,
script: redis.NewScript(slidingWindowLua),
}
}
func (l *SlidingWindowLimiter) Allow(ctx context.Context, key string) (allowed bool, remaining int, err error) {
now := time.Now().Unix()
windowStart := now / int64(l.windowSecs)
currentKey := fmt.Sprintf("rl:%s:%d", key, windowStart)
previousKey := fmt.Sprintf("rl:%s:%d", key, windowStart-1)
res, err := l.script.Run(ctx, l.client,
[]string{currentKey, previousKey},
l.limit, l.windowSecs, now,
).Slice()
if err != nil {
return false, 0, err
}
allowed = res[0].(int64) == 1
current := int(res[1].(int64))
remaining = int(math.Max(0, float64(l.limit-current)))
return allowed, remaining, nil
}
// Middleware for net/http
func (l *SlidingWindowLimiter) Middleware(keyFn func(r *http.Request) string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := keyFn(r)
allowed, remaining, err := l.Allow(r.Context(), key)
if err != nil {
// On Redis error — pass through (fail-open), log the error
next.ServeHTTP(w, r)
return
}
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(l.limit))
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
w.Header().Set("X-RateLimit-Window", strconv.Itoa(l.windowSecs))
if !allowed {
retryAfter := l.windowSecs - int(time.Now().Unix())%l.windowSecs
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage example
func main() {
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
limiter := NewSlidingWindowLimiter(rdb, 100, 60) // 100 req/min
mux := http.NewServeMux()
mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
keyFn := func(r *http.Request) string {
// Rate limit by IP
return "ip:" + r.RemoteAddr
}
http.ListenAndServe(":8080", limiter.Middleware(keyFn)(mux))
}
The Lua script computes the weighted sum and increments the counter atomically. The Go code wraps the logic in an HTTP middleware, adds X-RateLimit-* headers, and returns 429 Too Many Requests with a Retry-After header.
5. Implementing Token Bucket with Redis + Laravel
Laravel has a built-in RateLimiter facade, but for production workloads with precise control, it's more convenient to implement Token Bucket directly via Redis. Let's create a middleware with burst support.
<?php
// app/Http/Middleware/TokenBucketRateLimit.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class TokenBucketRateLimit
{
/**
* Lua Token Bucket script for Redis.
* KEYS[1] = bucket key
* ARGV[1] = capacity (max tokens)
* ARGV[2] = refill_rate (tokens/sec)
* ARGV[3] = now (unix timestamp float)
* ARGV[4] = cost (request cost, usually 1)
*/
private string $luaScript = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens proportional to elapsed time
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens < cost then
-- Save state without changes
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
local wait = (cost - tokens) / refill_rate
return {0, math.floor(tokens), math.ceil(wait)}
end
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 10)
return {1, math.floor(tokens), 0}
LUA;
public function handle(Request $request, Closure $next, int $capacity = 60, int $refillRate = 1): Response
{
$key = $this->resolveKey($request);
$now = microtime(true);
$result = Redis::eval(
$this->luaScript,
1,
"tb_rl:{$key}",
$capacity,
$refillRate,
$now,
1
);
[$allowed, $remaining, $retryAfter] = $result;
$response = $allowed
? $next($request)
: response()->json(['error' => 'Too Many Requests'], 429);
$response->headers->set('X-RateLimit-Limit', $capacity);
$response->headers->set('X-RateLimit-Remaining', max(0, $remaining));
if (!$allowed) {
$response->headers->set('Retry-After', $retryAfter);
}
return $response;
}
private function resolveKey(Request $request): string
{
// Priority: API key > authenticated user > IP
if ($apiKey = $request->header('X-API-Key')) {
return 'apikey:' . hash('sha256', $apiKey);
}
if ($user = $request->user()) {
return 'user:' . $user->id;
}
return 'ip:' . $request->ip();
}
}
Register the middleware in app/Http/Kernel.php or via Route::middleware:
// routes/api.php
use App\Http\Middleware\TokenBucketRateLimit;
// 60 tokens, refill 1 token/sec (burst up to 60)
Route::middleware([TokenBucketRateLimit::class . ':60,1'])
->group(function () {
Route::get('/data', [DataController::class, 'index']);
});
// Premium endpoint: 300 tokens, 5 tokens/sec
Route::middleware([TokenBucketRateLimit::class . ':300,5'])
->group(function () {
Route::get('/premium/data', [PremiumController::class, 'index']);
});
The Redis configuration in Laravel (config/database.php) should use phpredis or predis. phpredis is recommended for production due to lower serialization overhead.
6. Granularity of Rate Limits
Effective REST API protection requires multi-layered restrictions. A practical hierarchy:
- By IP: basic protection against anonymous attacks. Unreliable behind NAT (office networks), but necessary as the first layer. Key:
rl:ip:1.2.3.4. - By API key: for B2B integrations. Allows assigning individual quotas. Key:
rl:apikey:sha256(key). Never use the raw key in a Redis key name. - By user: for authenticated requests. Independent of IP, works when the network changes. Key:
rl:user:42. - By endpoint: different limits for
POST /login(5/min) andGET /catalog(1000/min). Key:rl:user:42:POST:/login. - Global service limit: protection against overload regardless of source. Key:
rl:global:service-name.
In practice, a combination is used: the global limit is checked first, then by IP, then by user/key. If any one is exceeded — a 429 is returned.
7. Distributed Rate Limiting in Kubernetes
In Kubernetes, each pod has its own process. Local counters are useless: with 10 replicas, the real limit becomes 10 times higher than declared. Redis Cluster solves this by centralizing state.
Key deployment considerations:
- Redis Sentinel or Redis Cluster: for HA. Sentinel is suitable for most use cases; Cluster is for volumes exceeding 100K operations/sec.
- Hash tags in keys: in Redis Cluster, keys for the same client must land in the same slot. Use
rl:{user:42}:endpoint— curly braces ensure hashing byuser:42. - Connection pooling: in Go, use
go-rediswith a configured connection pool; in Laravel — phpredis with persistent connections. - Fail-open vs fail-closed: if Redis is unavailable, decide in advance: pass requests through (fail-open, risk of overload) or block them (fail-closed, risk of service outage). For public APIs, fail-open with an alert is the typical choice.
# kubernetes/redis-rate-limiter.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: rate-limiter-config
data:
REDIS_ADDR: "redis-cluster.default.svc.cluster.local:6379"
RATE_LIMIT_DEFAULT: "100"
RATE_LIMIT_WINDOW_SEC: "60"
RATE_LIMIT_BURST: "20"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
replicas: 5 # All 5 pods share a single Redis instance
template:
spec:
containers:
- name: api
envFrom:
- configMapRef:
name: rate-limiter-config
8. Proper HTTP Headers and Bypass Prevention
Standard rate limiting headers are important for clients and compatibility:
X-RateLimit-Limit: maximum number of requests in the window.X-RateLimit-Remaining: remaining number of requests.X-RateLimit-Reset: Unix timestamp of counter reset (for Fixed/Sliding Window).Retry-After: seconds until the next attempt (required with 429, specified in RFC 6585).
Additional protection mechanisms:
- Whitelist: internal services, monitoring, CI/CD — excluded from rate limiting by IP or a special header. Implement via a check before the main logic.
- Burst allowance: Token Bucket naturally supports bursts. For Sliding Window, a separate burst counter with a short TTL can be added.
- IP spoofing protection: do not trust
X-Forwarded-Forwithout validation. Configure trusted proxies explicitly (in Laravel —TrustProxiesmiddleware; in Go —X-Real-IPonly from known load balancers). - Jitter on retry: recommend that clients use exponential backoff with jitter to avoid a synchronized storm of retries after a block is lifted.
9. Monitoring and Alerts
Rate limiting without monitoring is blind protection. You need to track:
- Rate limit trigger frequency (429 responses): a sharp spike signals an attack or a client-side bug. Export the metric
rate_limit_exceeded_total{key_type, endpoint}to Prometheus. - Top offenders: keys with the highest number of blocks in the past hour.
- Latency added by the rate limiter: the Redis call should take < 1ms. If it takes longer — there's a network or Redis issue.
- Redis memory usage: under high traffic, rate limiting keys can consume significant memory. Monitor
redis_memory_used_bytes.
// Go: prometheus metrics for rate limiter
var (
rateLimitHits = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "rate_limit_exceeded_total",
Help: "Total number of rate limit exceeded events",
},
[]string{"key_type", "endpoint"},
)
rateLimitLatency = prometheus.NewHistogram(
prometheus.HistogramOpts{
Name: "rate_limit_check_duration_seconds",
Help: "Duration of rate limit check",
Buckets: []float64{0.0001, 0.0005, 0.001, 0.005, 0.01},
},
)
)
func init() {
prometheus.MustRegister(rateLimitHits, rateLimitLatency)
}
Set up alerts: if the share of 429 responses exceeds 5% of total traffic over 5 minutes — immediately notify the team. If Redis is unavailable for more than 30 seconds — trigger a critical alert.
10. Choosing the Right Algorithm for Your Use Case
Let's summarize algorithm selection for real-world scenarios:
- Public REST API with tiered plans: Token Bucket — flexible burst support, easy to configure individual per-API-key limits.
- Protecting an authentication endpoint (brute force): Sliding Window Counter — accuracy without boundary effects, low memory usage.
- Internal microservices: Fixed Window — simplicity, minimal overhead; boundary effect is not critical.
- Streaming or webhooks: Leaky Bucket — uniform load on downstream services.
- Multi-layered protection (recommended): Sliding Window Counter by IP (coarse protection) + Token Bucket by user (precise quota).
Regardless of the algorithm chosen, three rules remain constant: centralized state storage (Redis), atomic operations (Lua), and proper HTTP headers for clients. Rate limiting in 2026 is not optional — it's a baseline requirement for any production REST API, especially in a Kubernetes environment with horizontal scaling.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →