Rate Limiting
WithRateLimit and its narrower variants, the built-in per-route limit table, the 429 response shape, and how to handle one client-side.
Rate Limiting
Rate limiting is on by default — an in-memory store plus a built-in table of per-route limits on the sensitive endpoints (login, register, password reset, invites, admin actions, ...). Unlike everything else in these guides, there's no single "endpoint" here: this is cross-cutting middleware wrapping the applicable routes, so this page is organized around configuring it and handling the response it produces, rather than curl/Go/client per endpoint.
Configuration
// Replace the whole table:
goauth.WithRateLimit(ratelimit.Config{
Enabled: true, // optional, default true
Default: ratelimit.Rate{Requests: 60, Window: time.Minute}, // optional, default 60/min
Routes: map[string]ratelimit.Rate{ // optional, default: the built-in table below
"POST /auth/login": {Requests: 10, Window: time.Minute},
},
Store: nil, // optional, default: a bounded in-memory store
DisabledRoutes: nil, // optional, default none
TrustedIPs: []string{"10.0.0.0/8"}, // optional, default none — required if IPAddressHeader is set
IPv6Subnet: 64, // optional, default 64
IPAddressHeader: "CF-Connecting-IP", // optional, default "" (trusts RemoteAddr only)
})
// Or adjust one piece without replacing the rest:
goauth.WithRateLimitRoute("POST /auth/login", ratelimit.Rate{Requests: 10, Window: time.Minute})| Field | Type | Default | Notes |
|---|---|---|---|
Enabled | bool | true | Disabling it logs a warning at startup — it's flagged as insecure for production. |
Default | ratelimit.Rate | 60/min | Applied to any route not listed in Routes (and not matched by a prefix pattern — see below). |
Routes | map[string]ratelimit.Rate | the built-in table | Keyed by Go ServeMux patterns, such as "POST /widgets/{id}" or "GET /files/{rest...}". A Rate{Requests: 0} entry disables limiting for that specific route without touching DisabledRoutes. |
Store | ratelimit.Store | a bounded in-memory store | Leave it nil and one is created for you (and closed by Auth.Close()). See A note on the default store below. |
DisabledRoutes | []string | none | Exact path matches, exempted entirely — no counting, no headers. |
TrustedIPs | []string | none | CIDR ranges. Required for IPAddressHeader to have any effect — see below. |
IPv6Subnet | int | 64 | IPv6 addresses are masked to this prefix length before being used as a rate-limit key, so a client rotating addresses within their own /64 still shares one bucket. |
IPAddressHeader | string | "" | e.g. "CF-Connecting-IP", "X-Forwarded-For" — only ever read when the immediate peer's address is in TrustedIPs; otherwise ignored, so an untrusted client can't spoof it to pick its own rate-limit key. |
Narrower options — each lazily starts from the same defaults if you haven't called WithRateLimit directly first:
| Option | Adjusts |
|---|---|
WithRateLimitEnabled(bool) | Just Enabled. |
WithRateLimitDefault(ratelimit.Rate) | Just the fallback rate. |
WithRateLimitRoute(pattern, ratelimit.Rate) | Adds or overrides one route's rate without replacing the table. |
WithRateLimitStore(ratelimit.Store) | Swaps the backing store without touching rates. |
WithTrustedIPs([]string) | Just TrustedIPs. |
WithIPv6Subnet(int) | Just IPv6Subnet. |
WithIPAddressHeader(string) | Just IPAddressHeader. |
A note on the default store
ratelimit.NewMemoryStore() is a fixed-window counter in a sharded, process-local map. It needs no configuration for one instance. It is per-instance, so two application instances behind a load balancer enforce an effective limit of N × instance count.
NewConfig leaves the default store unallocated and starts no cleanup goroutine. Each successful goauth.New creates its own store after startup checks, even when you reuse the same configuration. Disabled rate limiting allocates no default store. Auth.Close stops only that instance's owned store; a store explicitly supplied through WithRateLimit or WithRateLimitStore is shared as supplied and remains yours to close.
For multi-instance deployments, implement ratelimit.Store with Allow(ctx, key, Rate) (Result, error) against Redis or another shared backend and pass it to WithRateLimitStore. The store makes the allow/deny decision, allowing a distributed backend to increment and decide atomically. See Architecture → Extension points.
New() logs a one-time startup warning when rate limiting is enabled and still on this default store — it's a reminder, not a diagnosis (the library has no way to detect "multiple instances" directly), so it fires in single-instance deployments too. Pass any other ratelimit.Store implementation via WithRateLimitStore to silence it.
It is bounded. The store holds at most 100,000 counters. Once full, an insert drops one existing counter: an expired one if the bounded sample it looks at contains one, otherwise the sampled counter with the most of its budget left. That ranking is deliberate — a counter close to tripping is the expensive one to lose, and a flood of one-hit counters is made to evict itself rather than the login limit it would rather you forgot. Raise the ceiling if a single instance legitimately serves more distinct clients than that inside one window:
goauth.WithRateLimitStore(ratelimit.NewMemoryStore(ratelimit.WithMaxEntries(500_000)))Treat that number as a ceiling, not an occupancy target. The cap is enforced per shard (32 of them, keys assigned by hash), so a value that isn't a multiple of 32 is rounded down, and in practice a hot shard starts evicting while others still have room — steady-state occupancy sits below the ceiling. It can never go above it, which is the direction that matters for a memory bound. Stats().Capacity reports what's actually enforced rather than what you asked for.
Eviction is otherwise silent, so the store logs a warning (at most once a minute) the first time it evicts a live counter, and exposes a snapshot:
store := ratelimit.NewMemoryStore()
// ...
if s, ok := store.(interface{ Stats() ratelimit.Stats }); ok {
st := s.Stats() // Entries, Capacity, Evictions, Expired
}How a request is matched to a limit
Routes, DisabledRoutes, router registrations and RateLimitWithPattern use the same Go ServeMux pattern language. {id} matches one escaped path segment; {rest...} matches the remaining subtree; /health/{$} matches that trailing-slash path exactly. GET also matches HEAD, and methodless patterns match all methods. The most specific matching registration wins. Ambiguous or malformed patterns fail NewConfig; direct construction of middleware panics during setup, just as invalid ServeMux registration does.
Exemptions are checked first, then the route table, then Default. The selected table entry supplies both the rate and counter identity. Canonical-path redirects do not count as matching entries. Old * globs are rejected: change POST /auth/orgs/*/invites to POST /auth/orgs/{orgID}/invites. Change the DisabledPaths field to DisabledRoutes; its values now use this same pattern language.
What the counter is keyed on
method + matched route pattern + client IP, with every part of that bounded — the pattern, not the request path; a normalized IP, never a raw header value; and the method folded to the nine standard HTTP methods, with anything else sharing one OTHER bucket (net/http doesn't limit how long a method can be, and an entry cap is only a memory bound if entries are a bounded size). POST /auth/orgs/abc/invites and POST /auth/orgs/xyz/invites share one counter, because they're the same route. The same IP hitting two different routes, or two different IPs hitting the same route, never share one.
This matters beyond tidiness: fourteen of the built-in rate-limited routes carry a path parameter, so a counter keyed on the raw path would hand any caller a brand-new counter on every request just by varying that segment — the limit would never trip, and the store would grow for as long as the requests kept coming.
For a route that isn't in Routes, the pattern comes from http.Request.Pattern, which net/http's ServeMux fills in with the registered pattern (Go 1.23+). Routers that don't populate it — chi, gorilla/mux, echo — leave those routes sharing a single fallback counter per client. That's a tighter limit, not a looser one, but if you want per-route granularity back, name the route:
r.Post("/widgets/{id}/publish",
auth.RateLimitWithPattern("POST /widgets/{id}/publish")(publishHandler).ServeHTTP)The declaration is validated and must match the actual request. A mismatch receives 404 before any counter is charged; an invalid declaration panics when the middleware is constructed. Use the same canonical pattern in WithRateLimitRoute to give it a custom rate. Third-party routers still need to populate path parameters for the application handler.
What the client IP is
RemoteAddr by default. If IPAddressHeader is set and the immediate connection is from an address in TrustedIPs (e.g. your own reverse proxy or CDN), the header is used instead — otherwise it's ignored outright, specifically so a direct, untrusted client can't set CF-Connecting-IP: 1.2.3.4 itself to dodge the limit.
When the header carries a chain (X-Forwarded-For: a, b, c), the address used is the rightmost entry that isn't itself in TrustedIPs. Appending proxies add the address they saw to the end, so everything to the right of that entry was written by infrastructure you trust and everything to the left is whatever the client sent — a client opening with an X-Forwarded-For of its own prepends a hop you have no reason to believe. If the header can't be parsed as an IP at all, or every hop in it is one of your own proxies, the transport address is used; a header value is never used as a key as-is.
IPv6 addresses are masked to IPv6Subnet bits (default 64) before being used, so a client rotating within its own /64 still shares one counter. Note the ceiling on that: a routed /48 is 65,536 distinct /64s from one host, and a /32 is four billion. If you're being targeted rather than merely crawled, WithIPv6Subnet(48) — or lower — buys real headroom at the cost of bucketing unrelated customers of the same ISP allocation together.
The default rate table
Every rate-limited route below has a limit unless you override it. A route wrapped in rate-limit middleware without a specific entry falls back to Default (60/min). Routes mounted without that middleware, such as logout, name change and session listing/revocation, have no built-in route limit.
| Route | Limit |
|---|---|
POST /auth/register | 3/min |
POST /auth/login | 5/min |
POST /auth/admin/login | 3/min |
POST /auth/forgot-password | 3/hour |
POST /auth/reset-password | 5/min |
POST /auth/verify-email | 10/min |
POST /auth/verify-email/resend | 3/min |
POST /auth/resend-verification | 3/min |
POST /auth/set-password/request | 3/15min |
POST /auth/set-password/confirm | 5/10min |
POST /auth/account/delete/request | 3/hour |
POST /auth/account/delete/confirm | 3/hour |
POST /auth/invite/register | 10/min |
GET /auth/invite/info | 30/min |
POST /auth/refresh | 3/min |
POST /auth/2fa/verify | 5/min |
POST /auth/2fa/resend | 3/min |
POST /auth/2fa/enable | 5/min |
POST /auth/2fa/disable | 5/min |
GET /auth/oauth/{provider} | 10/min |
POST /auth/oauth/{provider}/link | 10/min |
GET /auth/oauth/{provider}/callback | 10/min |
POST /auth/oauth/{provider}/callback | 10/min |
POST /auth/orgs | 5/hour |
GET /auth/orgs/{orgID} | 60/min |
PUT /auth/orgs/{orgID} | 10/min |
DELETE /auth/orgs/{orgID} | 5/min |
GET /auth/orgs/{orgID}/members | 60/min |
DELETE /auth/orgs/{orgID}/members/{userID} | 5/min |
PATCH /auth/orgs/{orgID}/members/{userID}/role | 10/min |
POST /auth/orgs/{orgID}/leave | 30/min |
PUT /auth/orgs/active | 10/min |
DELETE /auth/orgs/active | 5/min |
POST /auth/orgs/{orgID}/invites | 20/hour |
POST /auth/orgs/invites/accept | 30/min |
GET /auth/orgs/{orgID}/invites | 30/min |
POST /auth/orgs/{orgID}/invites/{inviteID}/resend | 30/min |
DELETE /auth/orgs/{orgID}/invites/{inviteID} | 10/min |
GET /admin/users | 60/min |
POST /admin/users | 10/min |
PATCH /admin/users/{id}/role, PATCH /admin/users/{id}/ban, PATCH /admin/users/{id}/unban | 30/min |
DELETE /admin/users/{id} | 10/min |
GET /admin/users/{id}/sessions | 60/min |
DELETE /admin/users/{id}/sessions, DELETE /admin/users/{id}/sessions/{sessionId} | 20/min |
POST /admin/invites | 10/min |
GET /admin/invites | 60/min |
DELETE /admin/invites/{id} | 10/min |
DELETE /admin/invites/{id}/hard | 10/min |
POST /admin/invites/{id}/resend | 5/min |
The table uses the library's canonical route constants directly, so parameter syntax stays identical to Auth.Mount.
Hitting a limit
Two distinct outcomes both return 429, and it matters which one you're looking at:
| Code | Cause |
|---|---|
rate_limit_exceeded | Normal case — the caller genuinely exceeded their limit. |
rate_limit_error | The rate-limit store itself failed (e.g. Redis unreachable). This is fail-closed, not fail-open — a store outage rejects the request rather than letting it through, since the alternative is silently disabling rate limiting under exactly the conditions (infrastructure trouble) when abuse is most likely. |
Every rate-limited response carries X-RateLimit-Limit and X-RateLimit-Remaining, not just the ones that fail, so a client can back off before it trips. Both 429 cases also set Retry-After in seconds, rounded up and never below 1.
{ "error": "rate_limit_exceeded", "message": "Too many requests, please try again later" }curl
# Initialize the mandatory anonymous browser binding; requires jq.
csrf_token=$(curl -fsS https://api.myapp.com/auth/csrf-token \
-b cookies.txt -c cookies.txt | jq -r .token)
# Fire past a fresh 5/min login bucket and inspect the response headers.
for i in $(seq 1 6); do
curl -s -o /dev/null -D - https://api.myapp.com/auth/login \
-H "Content-Type: application/json" \
-H "Origin: https://myapp.com" \
-H "X-CSRF-Token: $csrf_token" \
-b cookies.txt -c cookies.txt \
-d '{"email":"ada@example.com","password":"wrong"}' \
| grep -E "HTTP|Retry-After|X-RateLimit"
doneWith a fresh login bucket and an allowed origin, the first five responses come back 401 invalid_credentials; the sixth is 429 with Retry-After and X-RateLimit-Remaining: 0. curl maintains the HttpOnly cookies in its jar; the CSRF token comes from bootstrap JSON. No browser JavaScript reads authentication or CSRF cookies.
Programmatic (Go)
Calling a service method directly (auth.Services().Auth.Login(ctx, ...)) skips the HTTP layer entirely — rate limiting is part of each applicable route's wrapped handler, not something the service layer enforces itself. If you're driving go-auth programmatically rather than over HTTP, you're responsible for your own throttling.
Client
The shared apiRequest helper (see Client → Setup) attaches retryAfter (parsed from the Retry-After header, in seconds) onto the thrown error whenever the response is 429, so you don't need to reach for headers yourself:
try {
await apiRequest(API_BASE, "POST", "/auth/login", { email, password });
} catch (err) {
if (err.error === "rate_limit_exceeded" || err.error === "rate_limit_error") {
showError(`Too many attempts — try again in ${err.retryAfter ?? 60}s`);
return;
}
throw err;
}Next
- Configuration — every other config block
- Architecture → Extension points — implementing
ratelimit.Storeagainst Redis - Error Handling — the full error code list
Built-in Redis store
Use WithRedis for shared counters together with session/token snapshots and 2FA notification counters, or use redisstore.New with the existing WithRateLimitStore for a standalone counter adapter. Both preserve fixed windows. See Redis.