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 sitting in front of every route, 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
DisabledPaths: 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 "METHOD /path", or "METHOD /path/*" with a * standing in for one path segment (see How a request is matched to a limit). A Rate{Requests: 0} entry disables limiting for that specific route without touching DisabledPaths. |
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. |
DisabledPaths | []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.
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
For each request, the key checked against Routes is "METHOD /exact/path" first. If nothing matches, every key containing a * is tried as a segment-wildcard match: the key and the request path are split on /, and it's a match only when both have the same number of segments and every non-* segment is equal (e.g. "POST /auth/orgs/*/invites" matches POST /auth/orgs/abc123/invites but not POST /auth/orgs/abc123/invites/xyz/resend — that's a different, deeper route with its own key). If more than one wildcard key matches — not the case in the built-in table, which has one key per route, but possible in a replacement Routes map — the one with the fewest * segments wins. If still nothing matches, Default applies.
* always stands for exactly one path segment — never "the rest of the path" — so a wildcard entry only ever covers the one route it's shaped for, not an entire subtree beneath it. Each route that needs its own limit gets its own key.
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 string only has to be stable and one-per-route; it isn't parsed. To give the route its own limit as well as its own counter, add the same string to the table with WithRateLimitRoute.
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 route below has a limit unless you override it; anything not listed falls back to Default (60/min).
| 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 |
POST /auth/orgs | 5/hour |
GET /auth/orgs/* | 60/min |
PUT /auth/orgs/* | 10/min |
DELETE /auth/orgs/* | 5/min |
GET /auth/orgs/*/members | 60/min |
DELETE /auth/orgs/*/members/* | 5/min |
PATCH /auth/orgs/*/members/*/role | 10/min |
POST /auth/orgs/*/leave | 30/min |
PUT /auth/orgs/active | 10/min |
DELETE /auth/orgs/active | 5/min |
POST /auth/orgs/*/invites | 20/hour |
POST /auth/orgs/invites/accept | 30/min |
GET /auth/orgs/*/invites | 30/min |
POST /auth/orgs/*/invites/*/resend | 30/min |
DELETE /auth/orgs/*/invites/* | 10/min |
GET /admin/users | 60/min |
POST /admin/users | 10/min |
PATCH /admin/users/*/role, PATCH /admin/users/*/ban, PATCH /admin/users/*/unban | 30/min |
DELETE /admin/users/* | 10/min |
GET /admin/users/*/sessions | 60/min |
DELETE /admin/users/*/sessions, DELETE /admin/users/*/sessions/* | 20/min |
POST /admin/invites | 10/min |
GET /admin/invites | 60/min |
DELETE /admin/invites/* | 10/min |
DELETE /admin/invites/*/hard | 10/min |
POST /admin/invites/*/resend | 5/min |
The full table is generated from the library's internal route list via a helper that turns each {param} path segment into *, so it can never drift from the routes Auth.Mount actually registers.
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
# Fire past the 5/min login limit and inspect the headers on the response that trips it
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" \
-d '{"email":"ada@example.com","password":"wrong"}' \
| grep -E "HTTP|Retry-After|X-RateLimit"
doneThe first five responses come back 401 invalid_credentials; the sixth is 429 with Retry-After and X-RateLimit-Remaining: 0.
Programmatic (Go)
Calling a service method directly (auth.Services.Auth.Login(ctx, ...)) skips the HTTP layer entirely — rate limiting is middleware wrapped around Handlers, 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