Skip to content

The v1 docs follow nauthera-server's main branch and its 0.x releases on the way to 1.0, so fields and APIs can still change.

v1

Rate Limits & Lockout

Docs version v1 (main)Nauthera is pre-1.0, in a design-partner programUpdated

Two separate mechanisms protect the server:

  • Rate limiting caps how many requests one client IP may send to the protocol endpoints, the HTTP APIs and the hosted UI's pages, with the few exceptions listed below. The gRPC control plane is not rate-limited.
  • Brute-force protection caps sign-in attempts per IP and failed guesses per identifier, soft-locks an account after repeated failures, and asks for a captcha once sign-ins start failing.

Both identify the caller by client IP, read as security.client_ip.mode says: the connecting peer, or a forwarded address from a peer in security.trusted_proxies. Behind an ingress or load balancer, list its addresses there, or every request appears to come from the proxy and one noisy client throttles everybody. The dashboard's overview tells platform administrators when traffic looks like it arrives through a proxy the configuration does not name. See Configuration.

Rate limiting

KeyDefaultMeaning
rate_limit.enabledtrue
rate_limit.rps50Requests per second per IP
rate_limit.burst100Burst size of the memory bucket, which the redis backend also falls back to
rate_limit.backendmemorymemory or redis
rate_limit.window1mWindow length (redis backend only)

The two backends behave differently:

BackendAlgorithmScope
memoryToken bucket: rps per second, bursts up to burstPer replica. The effective limit across the fleet is rps × replicas.
redisFixed window: rps × window requests per window (3,000 a minute by default).Shared by every replica. Requires cache.enabled. While Redis cannot answer, each replica falls back to its own memory bucket (rps, burst).

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. The main organization's discovery documents and JWKS are not rate-limited. An organization's own discovery documents and JWKS under /orgs/{slug} are rate-limited, and so are its discovery documents at the RFC 8414 path-inserted locations on the server root.

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":"https://nauthera.io/problems/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. The hosted UI's pages and an organization's own discovery documents and JWKS send the same status and headers, but their problem document has type: about:blank and no code. A browser navigation that hits the limit gets an HTML error page instead of a problem document, with the same status and rate-limit headers.

When Redis fails. With the redis backend, a request the shared limiter cannot answer is limited by a per-replica token bucket instead, with the same rps and burst, so the limit loosens to at most rps × replicas and nothing is refused for the outage alone. The replica asks Redis again at most once a second and goes back to the shared window as soon as it answers. Each switch is logged, and nauthera_rate_limit_degraded reads 1 while a replica limits on its own.

Brute-force protection

KeyDefaultMeaning
auth.brute_force.ip_max_attempts / ip_window50 / 15mSign-in attempts allowed from one IP per window, successful or not, across all organizations. Self-service registration, password-reset, email-verification and invitation requests count too.
auth.brute_force.identifier_max_attempts / identifier_window10 / 15mFailed sign-ins allowed against one email address in one organization per window
auth.brute_force.max_failed_attempts5Failures that lock the account
auth.brute_force.lockout_duration15mHow long the first lock lasts
auth.brute_force.lockout_backofftrueDouble the lock for each further failure
auth.brute_force.lockout_max1hLongest lock
auth.brute_force.reveal_lockoutfalseWhether a locked account is told apart from a throttled one
auth.brute_force.max_concurrent_verifies4Password-grade derivations at once per replica, shared by sign-in, setting a password, and redeeming or issuing recovery codes. Each argon2id derivation uses about 64 MiB.
auth.brute_force.verify_acquire_timeout2sHow long a request waits for a free slot before it is refused
auth.brute_force.failure_window24hHow long a run of failures is remembered after its last failure and the lock it set

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. An email address no account holds keeps the same count and lock, so it locks on the same attempt an account would and the answers do not reveal whether the account exists.

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) or from an emailed reset link, clears the count and the lock at once. To unlock without touching the password, POST /v1/admin/orgs/{slug}/users/{id}/unlock clears the failure count, the lock and the sign-in throttle on the user's address. It needs the users.lifecycle permission, a recent sign-in and a reason, which the audit record keeps; the account's status is left as it is.

What the sign-in screen sees

SituationResponse from POST /v1/auth/sign-in
The request names no client registered in the organization and does not continue the device flow404, code: not_found, before the password is checked
A captcha is due and the request carries no valid solution400, code: captcha_failed
Wrong email or password401, code: invalid_credentials
A throttle tripped, every verification slot busy, or the account is locked while reveal_lockout is off429, code: too_many_requests
The account is locked and reveal_lockout is on429, code: account_locked; the hosted UI shows /account-locked
The right password for an account an administrator suspended or blocked401, code: invalid_credentials; with reveal_lockout on, 403, code: account_unavailable

By default a locked account looks the same as a throttled one, so the response does not confirm that the account exists. Not every response is yet identical for registered and unknown identifiers.

Captcha

The hosted UI asks for a captcha where guessing is cheap for a script:

  • self-service registration and every request for an emailed link (password reset, resending a verification link) always ask;
  • sign-in asks once the address typed, or the caller's IP, has failed auth.captcha.sign_in_after (3) times recently. Until then a person signs in without one. 0 never asks at sign-in.
KeyDefaultMeaning
auth.captcha.provideraltchaaltcha, the built-in proof-of-work that needs no third party; a hosted provider, turnstile, hcaptcha or recaptcha (v2); or none
auth.captcha.altcha.max_number1000Difficulty: the browser makes half this many attempts on average. At most 100,000.
auth.captcha.altcha.validity5mHow long a challenge can be solved and sent back, 1m to 1h
auth.captcha.site_key, auth.captcha.secretemptyA hosted provider's keys, both required by one. The secret has a _FILE form.
auth.captcha.sign_in_after3Recent failures before sign-in asks

The UI fetches a challenge from GET /v1/auth/captcha and sends the solution with the request it guards. A missing, wrong, expired or already used solution answers 400 captcha_failed; each solution is single use. If a hosted provider cannot be asked, the request answers 503 unavailable. A sign-in the captcha refuses still counts toward the per-IP throttle. See Configuration.

Second factors

Second factors have their own lockout, independent of the account lock. After mfa.max_failures (5) wrong codes, TOTP verification is locked for mfa.lock_duration (15m); wrong recovery codes count toward that limit but never lock TOTP verification. After as many wrong recovery codes, recovery-code redemption is locked for the same time. Each lock leaves the other method open. 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_total counts password sign-in attempts by result: success, invalid, throttled, locked and unavailable. success means the password was right; a second factor may still follow. An attempt against a locked account counts as locked only when reveal_lockout is on, and as throttled otherwise.
  • nauthera_rate_limit_degraded is 1 while a replica limits on its own because the shared Redis limiter cannot answer.
  • nauthera_auth_account_lockouts_total counts soft locks applied, to an account or to an email address no account holds, each re-lock included.
  • auth.rate_limit.triggered audit events record every sign-in answered too_many_requests: a tripped throttle, every verification slot busy, or a locked account while reveal_lockout is off. Registration and email-link requests refused by the per-IP throttle record it too.
  • auth.account.locked is recorded once for each lock applied, by the failure that set it, with attempts and locked_until; limit_scope says whether an account or an identifier no account holds was locked.