Rate Limits & Lockout
Two separate mechanisms protect the server:
- Rate limiting caps how many requests one client IP may send, across every protocol and API endpoint.
- Brute-force protection caps password guesses per IP and per identifier, and soft-locks an account after repeated failures.
Both identify the caller by client IP, resolved through security.trusted_proxies. Set
that to your ingress or load balancer, or every request appears to come from the proxy
and one noisy client throttles everybody.
Rate limiting
| Key | Default | Meaning |
|---|---|---|
rate_limit.enabled | true | |
rate_limit.rps | 50 | Requests per second per IP |
rate_limit.burst | 100 | Burst size (memory backend only) |
rate_limit.backend | memory | memory or redis |
rate_limit.window | 1m | Window length (redis backend only) |
The two backends behave differently:
| Backend | Algorithm | Scope |
|---|---|---|
memory | Token bucket: rps per second, bursts up to burst | Per replica. The effective limit across the fleet is rps × replicas. |
redis | Fixed window: rps × window requests per window (3,000 a minute by default). burst is not used. | Shared by every replica. Requires cache.enabled. |
The production Helm profile uses redis.
One budget per IP. Every rate-limited endpoint draws from the same per-IP budget:
/authorize, /token, the sign-in API, the admin API and the hosted UI's pages all
count together. Discovery and the JWKS are not rate-limited, and neither are the RFC 7592
management calls on /register/{client_id}.
When the limit is hit, the server answers 429 with a problem document and these
headers. OAuth endpoints answer this way too, not with an OAuth error object:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
X-RateLimit-Limit: 3000
X-RateLimit-Remaining: 0
Retry-After: 12
{"type":"urn:problem-type:rate_limited","title":"Too Many Requests","status":429,
"code":"rate_limited","detail":"rate limit exceeded","instance":"/token","request_id":"…"}Retry-After is in whole seconds, at least 1.
When the backend fails. The protocol and API endpoints fail closed: if the limiter
cannot answer, they return 429. With the redis backend, a Redis outage therefore
refuses every one of those requests until Redis is back. The hosted UI's pages fail open,
so the sign-in screen still loads.
Brute-force protection
| Key | Default | Meaning |
|---|---|---|
auth.brute_force.ip_max_attempts / ip_window | 50 / 15m | Failed sign-ins allowed from one IP per window |
auth.brute_force.identifier_max_attempts / identifier_window | 10 / 15m | Failed sign-ins allowed against one username or email per window |
auth.brute_force.max_failed_attempts | 5 | Failures that lock the account |
auth.brute_force.lockout_duration | 15m | How long the first lock lasts |
auth.brute_force.lockout_backoff | true | Double the lock for each further failure |
auth.brute_force.lockout_max | 1h | Longest lock |
auth.brute_force.reveal_lockout | false | Whether a locked account is told apart from a throttled one |
auth.brute_force.max_concurrent_verifies | 4 | Password hashes verified at once per replica. Each argon2id verification uses about 64 MiB. |
auth.brute_force.verify_acquire_timeout | 2s | How long a sign-in waits for a free slot before it is refused |
The per-IP and per-identifier throttles are held in the cache, so with Redis they are shared by every replica. The account lock is stored on the user record in the database.
Account lockout
After max_failed_attempts failures, the account is locked for lockout_duration. With
backoff on, each failure after that doubles the next lock, up to lockout_max. While the
account is locked, every attempt is refused, including one with the right password, and
refused attempts neither count as failures nor extend the lock. So a third party cannot
keep an account locked longer by guessing at it.
A successful sign-in clears the failure count. Setting the user's password through the
admin API (PUT /v1/admin/orgs/{slug}/users/{id}/password) clears the count and the
lock at once. That is the only way to unlock an account early; a dedicated unlock action
is planned (#584).
What the sign-in screen sees
| Situation | Response from POST /v1/auth/sign-in |
|---|---|
| Wrong username or password | 401, code: invalid_credentials |
A throttle tripped, every verification slot busy, or the account is locked while reveal_lockout is off | 429, code: too_many_requests |
The account is locked and reveal_lockout is on | 429, code: account_locked; the hosted UI shows /account-locked |
By default a locked account looks the same as a throttled one, so the response does not confirm that the account exists. Answering identically for registered and unknown identifiers in every case is still open work (#293).
Second factors
TOTP codes have their own lockout, independent of the account lock: after
mfa.max_failures (5) wrong codes the factor is locked for mfa.lock_duration (15m).
See Multi-Factor Authentication.
The device flow's code entry allows five wrong user codes per signed-in user, after
which entry is refused with too_many_attempts.
Monitoring
nauthera_auth_logins_totalcounts sign-in outcomes, includingthrottledandlocked.nauthera_auth_account_lockouts_totalcounts accounts locked.auth.rate_limit.triggeredaudit events record tripped throttles. Recording the moment an account locks, once per lock, is tracked in #521.