GoTrue

Client IP & Rate Limits

Recover the real client IP behind a load balancer and throttle sensitive auth endpoints with Redis.

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.

List only proxies you actually control. Every hop you trust is a hop that can forge the client address. If your load balancer appends to X-Forwarded-For rather than replacing it, that is exactly the behaviour this walk expects.

What the resolved IP is used for

UseDetail
ForwardingSent to GoTrue as X-Forwarded-For and X-Real-IP on every proxied call
Rate limitingPart of the limiter key
AuditRecorded 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
}
The limiter fails open. If Redis is unreachable, the request is allowed rather than rejected, so a cache outage does not take down sign-in. If you would rather fail closed, wrap the limiter and return the error path you want.

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

Copyright © 2026