Client IP & Rate Limits
The proxy needs to know who is calling, for three reasons: GoTrue's own security features want the real client IP, rate limiting is keyed by it, and audit records are worth little without it. Getting this wrong in either direction is a security problem, so the resolution rule is explicit.
Understand the two modes
Edge mode is the default, when TrustedProxies is empty. The connection peer is the client, and inbound X-Forwarded-For and X-Real-IP headers are ignored entirely. Use this when your process is directly exposed, because otherwise any client could claim any address.
Trusted proxy mode applies when you list the ranges of the proxies in front of you. If the connection peer is one of them, the X-Forwarded-For chain is walked from right to left, skipping trusted hops, and the first untrusted address is the client. If the peer is not trusted, its address is used and the headers are ignored.
gt := auth.NewGoTrue(auth.GoTrueConfig{
InternalURL: "http://gotrue:9999",
JWTSecret: secret,
TrustedProxies: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.1.10"},
})
Entries can be CIDR blocks or bare IPs, IPv4 or IPv6. A malformed entry panics at construction, so a typo is caught at startup.
X-Forwarded-For rather than replacing it, that is exactly the behaviour this walk expects.What the resolved IP is used for
| Use | Detail |
|---|---|
| Forwarding | Sent to GoTrue as X-Forwarded-For and X-Real-IP on every proxied call |
| Rate limiting | Part of the limiter key |
| Audit | Recorded as ip in the details of each audit event |
GoTrue only honours the forwarded address when it is configured to:
GOTRUE_SECURITY_SB_FORWARDED_FOR_ENABLED=true
Without that, GoTrue's own limits and logs see your service's address instead of the user's.
ctx.GetClientIP() on a regular ezz route is a simpler thing: it takes the first X-Forwarded-For value with no trust check. It is fine for logging, but do not use it for security decisions. The trusted-proxy logic described here applies to the GoTrue proxy only.Add Redis rate limiting
The proxy calls a limiter on sensitive endpoints only: sign-up, token, OTP, recovery, resend, OAuth token, dynamic client registration, and passkey authentication. Read-only routes such as /health, /settings, and /user are not throttled.
import (
"github.com/redis/go-redis/v9"
"github.com/sulv-io/ezz/auth/ratelimit"
)
rdb := redis.NewClient(&redis.Options{Addr: "redis:6379"})
gt := auth.NewGoTrue(auth.GoTrueConfig{
InternalURL: "http://gotrue:9999",
JWTSecret: secret,
RateLimiter: ratelimit.NewRedis(rdb, 10, time.Minute), // 10 requests per minute
})
NewRedis is a fixed-window counter. Each request increments ratelimit:<client-ip>:<route> and sets the key's expiry to the window, so every client gets its own budget per endpoint. It accepts a redis.UniversalClient, which means a single node, a sentinel setup, or a cluster all work.
Exceeding the limit returns:
{
"error": true,
"message": "too many requests",
"status": 429
}
Because the window is fixed rather than sliding, a client can send up to twice the limit across a window boundary. For login endpoints that is usually acceptable; if it is not, implement a sliding window or token bucket behind the same interface.
Write your own limiter
The interface is one method, and the auth package does not import any Redis code, so nothing forces you into a particular store:
type RateLimiter interface {
Allow(ctx context.Context, key string) (bool, error)
}
type postgresLimiter struct{ db *sql.DB }
func (l postgresLimiter) Allow(ctx context.Context, key string) (bool, error) {
var count int
err := l.db.QueryRowContext(ctx, `
INSERT INTO rate_limits (key, window_start, count)
VALUES ($1, date_trunc('minute', now()), 1)
ON CONFLICT (key, window_start)
DO UPDATE SET count = rate_limits.count + 1
RETURNING count`, key).Scan(&count)
if err != nil {
return false, err
}
return count <= 10, nil
}
To vary limits per endpoint, switch on the key, which is <ip>:<route>:
func (l tieredLimiter) Allow(ctx context.Context, key string) (bool, error) {
limit := 60
switch {
case strings.HasSuffix(key, ":/token"):
limit = 10
case strings.HasSuffix(key, ":/signup"):
limit = 5
}
return l.allowWithLimit(ctx, key, limit)
}
Rate limit your own routes too
The proxy limiter covers proxied auth endpoints. For the rest of your API, use the middleware:
app.Use(middleware.IPRateLimit(300))
That one is per process and in memory. See Middleware for the alternatives.
Next steps
- Deployment for the GoTrue side of the configuration.
- Audit logging for what the resolved IP ends up in.