Skip to content

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:

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:

  1. Block list enforcement — anyone on the list gets 403 Forbidden, full stop.
  2. 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:

  1. SQL-injection patterns in any query parameter value — ' OR '1'='1, ' OR 1=1, '; DROP TABLE, UNION SELECT, plus <script> and javascript: for good measure.
  2. Path-traversal patterns in the URL path — ../, ..\, %2e%2e%2f, %2e%2e/, ..%2f.
  3. 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

  1. Add a preset method to RateLimiter if none of the existing presets fit.
  2. Pass the limiter into the domain's route registrar as a func(http.Handler) http.Handler gate (routes.go never imports middleware directly), then attach it in internal/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())
  1. 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.