Rate Limiting (Implementation)¶
This page covers the implementation of rate limiting: the middleware, Redis-backed counters, IP blocker, and suspicious-activity heuristics. For the public-facing limits and 429 responses, see API → Auth Endpoints.
Source files:
backend/internal/middleware/rate_limiter.gobackend/internal/middleware/ip_blocker.gobackend/internal/platform/cache/cache.go(theRateLimitLua script)
Middleware chain¶
internal/wiring/router.go (SetupRouter) wires the security middlewares onto the global chi router with r.Use, in order:
r.Use(middleware.SecurityHeaders(cfg.Server.Env))
r.Use(app.IPBlocker.BlockMiddleware())
r.Use(app.IPBlocker.SuspiciousActivityMiddleware())
The /api/v1 group then applies app.RateLimiter.APILimit() as a baseline. The tighter auth limiters are passed into the auth domain's route registrar as func(http.Handler) http.Handler params, keeping routes.go free of any middleware import:
auth.RegisterPublicRoutes(r, app.Handlers.AuthHandler, app.Handlers.SessionHandler,
app.RateLimiter.RegisterLimit(), app.RateLimiter.LoginLimit(), app.RateLimiter.ResetPasswordLimit())
RateLimiter¶
RateLimiter.Limit(RateLimitConfig) is a generic per-IP limiter. The config:
type RateLimitConfig struct {
KeyPrefix string // e.g. "login_ip"
MaxRequests int // e.g. 10
Window time.Duration // e.g. 15*time.Minute
ErrorMessage string // surfaced in the 429 body
}
For every request, the limiter builds {KeyPrefix}:{client_ip} and calls cache.RateLimit(ctx, key, max, window), which runs the following Lua script atomically:
local current = redis.call("INCR", KEYS[1])
if tonumber(current) == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return current
RateLimit returns allowed = current <= max. When not allowed, the middleware sets Retry-After and returns 429 Too Many Requests via api.TooManyRequests, without calling the next handler. If Redis returns an error, the limiter fails open: next.ServeHTTP(w, r) is called and the request proceeds. This is intentional, since a Redis outage shouldn't take the API down.
Preset limits¶
RateLimiter exposes four named presets:
| Method | Limit | Use |
|---|---|---|
APILimit() |
600 / 1m | Baseline ceiling on every /api/v1/* route |
RegisterLimit() |
5 / 1h | Registration, OTP send |
LoginLimit() |
10 / 15m | Login (all variants), OTP verify, WebAuthn login |
ResetPasswordLimit() |
5 / 1h | Reset-password submission |
Targeted limiters stack on top of APILimit() — a registration request consumes from both the 600/min baseline and the 5/hour register bucket.
Add new presets in rate_limiter.go rather than passing raw RateLimitConfig from each handler — it keeps the limits centrally tunable.
IPBlocker¶
Two responsibilities:
- Block list enforcement — anyone on the list gets
403 Forbidden, full stop. - Suspicious activity detection — pattern-match dangerous request shapes and auto-block.
Block list¶
blocked_ip:{ip} is a Redis string with a TTL. The value encodes the reason (blocked:exceeded rate limits 5 times). BlockMiddleware checks every request and returns:
{
"error": "Your IP address has been temporarily blocked",
"reason": "...",
"message": "If you believe this is a mistake, please contact support"
}
Auto-block on violations¶
violations:{ip} is a counter incremented every time a rate limit is hit (when wired via AutoBlockOnRateLimit, which is available but not currently the default). Defaults from DefaultBlockConfig:
| Setting | Value |
|---|---|
ViolationThreshold |
5 violations |
BlockDuration |
24 hours |
CheckWindow |
1 hour (sliding) |
When the threshold is crossed, the IP is added to blocked_ip and the violation counter resets.
Suspicious activity heuristic¶
SuspiciousActivityMiddleware runs three checks on every request:
- SQL-injection patterns in any query parameter value —
' OR '1'='1,' OR 1=1,'; DROP TABLE,UNION SELECT, plus<script>andjavascript:for good measure. - Path-traversal patterns in the URL path —
../,..\,%2e%2e%2f,%2e%2e/,..%2f. - Excessively long query strings — > 2000 characters of raw query.
A match triggers an immediate 48-hour block on the source IP and a 403 Forbidden response.
False positives
The heuristic is conservative — a user accidentally typing ' OR '1'='1 in a search bar will trip it. Operators can unblock via:
redis-cli DEL blocked_ip:<ip>
redis-cli DEL violations:<ip>
There is no admin UI for this today; it's a runbook action.
Adding a new rate-limited endpoint¶
- Add a preset method to
RateLimiterif none of the existing presets fit. - Pass the limiter into the domain's route registrar as a
func(http.Handler) http.Handlergate (routes.go never importsmiddlewaredirectly), then attach it ininternal/wiring/router.go:
// in the domain's routes.go
func RegisterPublicRoutes(r chi.Router, h *Handler, newThingLimit func(http.Handler) http.Handler) {
r.With(newThingLimit).Post("/new-thing", h.NewThing)
}
// in wiring/router.go
foo.RegisterPublicRoutes(r, app.Handlers.FooHandler, app.RateLimiter.LoginLimit())
- Document the limit on API → Auth Endpoints.
Authenticated routes¶
Routes behind JWTAuth aren't rate-limited per-route today. They rely on:
- The auth endpoints' rate limits keeping brute-force off the doorstep
- The global IP blocker for repeated offenders
- The user being identifiable, so abuse can be tied to an account and dealt with via admin action
If you need a per-user limit on an authenticated route, build a small wrapper that keys on user_id from the JWT context rather than on IP.