# Rate Limits & Lockout

> How nauthera-server limits request rates per client IP, throttles password guessing and soft-locks accounts: defaults, backends, responses and unlocking a user.

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](https://www.nauthera.io/docs/v1/configuration#the-client-address).

## Rate limiting

| Key | Default | Meaning |
|---|---|---|
| `rate_limit.enabled` | `true` | |
| `rate_limit.rps` | `50` | Requests per second per IP |
| `rate_limit.burst` | `100` | Burst size of the `memory` bucket, which the `redis` backend also falls back to |
| `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). | 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
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

| Key | Default | Meaning |
|---|---|---|
| `auth.brute_force.ip_max_attempts` / `ip_window` | `50` / `15m` | Sign-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_window` | `10` / `15m` | Failed sign-ins allowed against one email address in one organization 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-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_timeout` | `2s` | How long a request waits for a free slot before it is refused |
| `auth.brute_force.failure_window` | `24h` | How 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

| Situation | Response from `POST /v1/auth/sign-in` |
|---|---|
| The request names no client registered in the organization and does not continue the device flow | `404`, `code: not_found`, before the password is checked |
| A captcha is due and the request carries no valid solution | `400`, `code: captcha_failed` |
| Wrong email 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` |
| The right password for an account an administrator suspended or blocked | `401`, `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.

| Key | Default | Meaning |
|---|---|---|
| `auth.captcha.provider` | `altcha` | `altcha`, 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_number` | `1000` | Difficulty: the browser makes half this many attempts on average. At most 100,000. |
| `auth.captcha.altcha.validity` | `5m` | How long a challenge can be solved and sent back, 1m to 1h |
| `auth.captcha.site_key`, `auth.captcha.secret` | empty | A hosted provider's keys, both required by one. The secret has a `_FILE` form. |
| `auth.captcha.sign_in_after` | `3` | Recent 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](https://www.nauthera.io/docs/v1/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](https://www.nauthera.io/docs/v1/mfa#lockout-and-the-acceptance-window).
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.
