Rate Limiting Guide
Overview
Rate limiting is implemented to protect authentication endpoints from brute force attacks. The system uses per-IP rate limiting with token bucket algorithm via Go's golang.org/x/time/rate package.
Features
- Per-IP tracking: Each IP address has its own rate limiter
- Token bucket algorithm: Allows bursts while maintaining average rate
- Automatic cleanup: Periodic cleanup of inactive limiters to prevent memory leaks
- Real IP extraction: Properly handles proxies and load balancers
- HTMX support: Custom error responses for HTMX requests
- Logging: Violations are logged with IP, path, and user agent
Configuration
Authentication Endpoints
Default settings for login and registration:
- Rate: 5 requests per minute (average)
- Burst: 10 requests
- Calculation: One request every 12 seconds on average
// Located in: app/gojang/http/middleware/ratelimit.go
func AuthRateLimiter() *IPRateLimiter {
return NewIPRateLimiter(rate.Every(12*time.Second), 10)
}
How It Works
- Initial burst: A new IP can make up to 10 requests immediately (burst size)
- Token regeneration: After burst is used, tokens regenerate at 1 per 12 seconds
- Sustained rate: Over time, allows ~5 requests per minute
Example Scenarios
Scenario 1: Normal User
- User tries to login 3 times quickly → All allowed (within burst)
- User waits 1 minute → Can try 5 more times
Scenario 2: Attack Attempt
- Attacker makes 10 rapid requests → All initially allowed (burst)
- Next request → Rate limited (429 error)
- Must wait ~12 seconds per additional attempt
IP Address Extraction
The system correctly extracts the real client IP, even behind proxies:
Priority order:
1. X-Forwarded-For (first/leftmost IP)
2. X-Real-IP
3. RemoteAddr
Security Considerations
- ✅ Takes first IP from X-Forwarded-For (original client)
- ✅ Validates IP format before using
- ✅ Prevents header spoofing by proper ordering
- ✅ Falls back to RemoteAddr if headers invalid
Usage
Applying to Routes
Rate limiting is applied to specific routes using middleware:
authLimiter := middleware.AuthRateLimiter()
// Apply to specific POST endpoints
r.With(middleware.RateLimit(authLimiter)).Post("/login", authHandler.LoginPOST)
r.With(middleware.RateLimit(authLimiter)).Post("/register", authHandler.RegisterPOST)
Starting Cleanup Routine
The cleanup routine prevents memory leaks by periodically removing inactive limiters:
cleanupDone := make(chan struct{})
defer close(cleanupDone)
// Cleanup every 5 minutes
go authLimiter.StartCleanupRoutine(5*time.Minute, cleanupDone)
Custom Rate Limiters
You can create custom rate limiters for different endpoints:
// API rate limiter: 100 requests per minute
apiLimiter := middleware.NewIPRateLimiter(rate.Every(600*time.Millisecond), 20)
// Strict rate limiter: 1 request per minute
strictLimiter := middleware.NewIPRateLimiter(rate.Every(60*time.Second), 1)
// Apply to routes
r.With(middleware.RateLimit(apiLimiter)).Get("/api/data", handler)
Response Behavior
Standard Requests
When rate limit is exceeded:
- Status Code: 429 Too Many Requests
- Header:
Retry-After: 60(seconds) - Body: "Too many requests. Please try again later."
HTMX Requests
For HTMX-enhanced forms:
- Status Code: 429 Too Many Requests
- Header:
HX-Reswap: innerHTML - Header:
Retry-After: 60 - Body: HTML alert div with user-friendly message
<div class="alert alert-error">
Too many requests. Please wait a moment and try again.
</div>
Logging
Rate limit violations are automatically logged:
[WARN] rate_limit_exceeded [ip 203.0.113.1 method POST path /login user_agent Mozilla/5.0...]
Log fields:
ip: Client IP addressmethod: HTTP methodpath: Request pathuser_agent: User agent string
Testing
Comprehensive tests are available in app/gojang/http/middleware/ratelimit_test.go:
# Run all rate limit tests
go test ./app/gojang/http/middleware -v -run RateLimit
# Run specific test
go test ./app/gojang/http/middleware -v -run TestRateLimit_BlocksExcessRequests
Test coverage includes:
- ✅ Creating rate limiters
- ✅ Per-IP tracking
- ✅ Request allowing/blocking
- ✅ IP extraction from headers
- ✅ HTMX request handling
- ✅ Cleanup routine
- ✅ Independent IP tracking
Monitoring
Checking Rate Limit Violations
Monitor your logs for rate_limit_exceeded warnings:
# Search for rate limit violations
grep "rate_limit_exceeded" logs/*.log
# Count violations per IP
grep "rate_limit_exceeded" logs/*.log | grep -oP 'ip \K[0-9.]+' | sort | uniq -c | sort -nr
Signs of Attack
Watch for:
- Multiple
rate_limit_exceededwarnings from same IP - Many different IPs hitting rate limit simultaneously
- Rate limits during off-peak hours
Production Considerations
Behind a Reverse Proxy
Ensure your proxy (Nginx, Caddy, etc.) forwards real IP:
Nginx:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
Caddy:
reverse_proxy localhost:8080
(Caddy automatically sets X-Forwarded-For)
Adjusting Limits
For stricter security:
// 3 attempts per minute
authLimiter := NewIPRateLimiter(rate.Every(20*time.Second), 5)
For more lenient limits:
// 10 attempts per minute
authLimiter := NewIPRateLimiter(rate.Every(6*time.Second), 15)
Whitelisting IPs
To whitelist specific IPs (e.g., monitoring systems):
func RateLimit(limiter *IPRateLimiter, whitelist []string) func(next http.Handler) http.Handler {
whitelistMap := make(map[string]bool)
for _, ip := range whitelist {
whitelistMap[ip] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := getRealIP(r)
// Skip rate limiting for whitelisted IPs
if whitelistMap[ip] {
next.ServeHTTP(w, r)
return
}
// Normal rate limiting...
})
}
}
Troubleshooting
Issue: Legitimate users being rate limited
Causes:
- Office/building sharing single public IP
- VPN exit nodes
- Mobile carrier NAT
Solutions:
- Increase burst size
- Increase rate limit
- Implement user-based rate limiting (after authentication)
Issue: Rate limits not working
Checks:
- Verify middleware is applied to route
- Check IP extraction in logs
- Verify cleanup routine is running
- Test with curl/httpie
Issue: All requests from same IP
Causes:
- Not behind proxy (using RemoteAddr of proxy)
- Proxy not sending X-Forwarded-For
- X-Forwarded-For validation failing
Solution:
- Configure proxy to forward real IP
- Check proxy logs
- Add debug logging to
getRealIP()