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 → Rate Limiting.
Source files:
backend/internal/middleware/rate_limiter.gobackend/internal/middleware/ip_blocker.gobackend/internal/services/redis_service.go(theRateLimitLua script)
Middleware chain¶
cmd/server/main.go wires three security middlewares onto the global Gin engine, in order:
r.Use(middleware.SecurityHeaders(cfg.Server.Env))
r.Use(app.IPBlocker.BlockMiddleware())
r.Use(app.IPBlocker.SuspiciousActivityMiddleware())
After those run for every request, the rate limiter attaches as per-route middleware on the auth endpoints (auth.POST("/login", app.RateLimiter.LoginLimit(), ...) and friends).
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 redisService.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
If current > max, the middleware returns 429 Too Many Requests and aborts the request. If Redis returns an error, the limiter fails open — c.Next() is called and the request proceeds. This is intentional: 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. - Attach it to the route in
cmd/server/main.go:
auth.POST("/new-thing", app.RateLimiter.LoginLimit(), handler.NewThing)
- Document the limit on API → Rate Limiting.
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.