Repository navigation
Config Rate Limiting and WebAuthn
This page covers two configuration sections. [server.rate_limit] limits how often passwords and recovery codes can be tried, and [webauthn] sets the hostname and origins that two-factor sign-in with security keys is bound to. For each key it gives the default and what it does. It also explains what each limit counts, what a refused request gets back, and how to set rpid and origins for a real deployment. For more depth, see RATELIMIT.md and WEBAUTHN.md.
The limiter is on by default. Without a [server.rate_limit] section, every key keeps its default. The limiter covers the sign-in form and every other request that checks an account's password or recovery code:
- signing in (
POST /session), including a sign-in restored from the login token; - linking another account (
POST /accounts); - signing in with a recovery code (
POST /webauthn/verify/recovery); - the current-password confirmation asked when removing a security key, turning on trust for linked accounts, and generating new recovery codes;
- the password plugin's current-password check (see Setup-Password-Change);
- the mail-password confirmation asked before a CalDAV or CardDAV connection in Settings sends the mail password to a server the user entered (see Config-Plugins).
All of these share the same counts for each address and each account, so wrong recovery codes and wrong passwords add up together. The full table of endpoints and their answers is in RATELIMIT.md, Protected Endpoints.
The section is a sub-table of [server]. A top-level [rate_limit] table is ignored, like any other unknown section, and ALPS logs a warning at startup for the table and for each key in it, such as Unknown configuration key: rate_limit.ip_requests_per_minute (ignored).
| Key | Default | What it counts | What happens at the limit |
|---|---|---|---|
enabled |
true |
Turns the whole limiter on or off. |
enabled = false turns every limit off, on every protected request listed above. Nothing else turns a limit off. |
global_requests_per_second |
100 |
Attempts recorded in the last second across all accounts and addresses: successes, refused credentials and errors. In a cluster, this includes the attempts the other nodes report. | Every protected request is refused with a wait of 1 second. The sign-in page tells the user to try again in a moment, not that they tried too often. |
ip_requests_per_minute |
5 |
Every attempt from one address in the last minute, whatever its outcome. | Further attempts from the address are refused with a wait of 60 seconds. This limit locks nothing. |
ip_requests_per_hour |
20 |
Attempts from one address in the last hour whose credentials were refused. A success from the address clears this count. | The next attempt from the address that passes the per-minute limit is refused and locks the address for lockout_minutes. |
username_fails_per_quarter |
5 |
Refused attempts for one account in the last 15 minutes, from any address. | Further attempts for the account are refused with a fixed wait of 15 minutes. This limit locks nothing. |
username_fails_per_hour |
10 |
Refused attempts for one account in the last hour, from any address. | The next attempt for the account that passes the 15-minute limit is refused and locks the account for lockout_minutes. A success for the account clears its failures. |
lockout_minutes |
15 |
How long a lockout of an address or an account lasts, in minutes. |
Every key except enabled takes a positive integer. Leaving a key out, or setting it to 0 or below, keeps its default, so a single tier cannot be turned off by itself. A value of the wrong type stops ALPS at startup, for example:
Error loading config file: failed to parse TOML config: toml: line 2 (last key "server.rate_limit.ip_requests_per_minute"): incompatible types: TOML value has type string; destination has type integer
Requests the limiter refuses are not counted anywhere. Retrying during a wait or a lockout therefore does not extend it. A sign-in request that ends up without both a username and a password is not counted either. Every other protected request that reaches the credential check is recorded with one of three outcomes:
| Outcome | Global count | Address, per minute | Address, per hour | Account failures |
|---|---|---|---|---|
| Credentials accepted | +1 | +1 | cleared | cleared, and any lockout of the account lifted |
| Credentials refused | +1 | +1 | +1 | +1 |
| Error (the mail server cannot be reached or cannot check the credentials, the account is at its connection limit, or the account's security-key record cannot be read) | +1 | +1 | unchanged | unchanged |
The error row means that a user who keeps trying during a mail server outage does not lock their own account. A success never lifts a lockout of the address, and while an account is locked, even its correct password is refused until the lockout ends.
The limiter checks the global limit first, then the address, then the account. With the defaults:
- One address gets at most 5 attempts a minute, whatever their outcome. After 20 refused attempts within the hour with no success in between, the next attempt that passes the per-minute limit is refused and locks the address for 15 minutes.
- One account gets 5 refused attempts. After that, its attempts are refused with a 15-minute wait until the oldest of those failures is more than 15 minutes old. Once the account has 10 refused attempts within the hour, the next attempt that passes the 15-minute limit is refused and locks the account for 15 minutes.
- When a lockout is armed, the counts behind it are cleared, so the lockout does not arm again by itself when it ends.
Users who share one address, such as an office behind NAT, share the address's counts. Their successful sign-ins count towards the per-minute limit but never lock the address.
A refusal is HTTP 429 with a Retry-After header in whole seconds, rounded up, so a lockout with less than a second left still says 1. The retry_after field in the body carries the same number. The body depends on the request:
| Request | 429 body |
|---|---|
Signing in (POST /session) |
{"error": "<English reason>", "retry_after": <seconds>}, plus "code": "server_busy" for a refusal by the global limit |
Linking an account (POST /accounts) |
{"error": "<English reason>", "retry_after": <seconds>} |
| Recovery codes, and the password confirmations in the two-factor settings | {"error": "rate_limited", "retry_after": <seconds>} |
| The password plugin's current-password check | {"error": "<English reason>", "code": "rate_limited", "retry_after": <seconds>} |
| The mail-password confirmation of a CalDAV or CardDAV connection | {"error": "rate_limited", "reason": "<English reason>", "retry_after": <seconds>} |
The wait depends on what refused the request:
| Refused by | Wait |
|---|---|
| Global limit | 1 second |
| Address, per-minute limit | 60 seconds |
| Account, 15-minute limit | 900 seconds |
| Lockout of the address or the account | the time the lockout has left: the full lockout_minutes on the request that arms it |
The sign-in page counts the wait down and keeps Sign In disabled until it ends. For a refusal by the global limit, it says "The service could not sign you in just now. Please try again in a moment." For every other refusal, it says "Too many login attempts".
A sign-in that the mail server refuses because the account already has as many connections as it allows is not a rate-limit refusal. ALPS answers it with HTTP 503 {"error": "connection_limit"} and counts it as an error. See Mail-Server-Requirements.
The address limits count the address ALPS sees. Behind a reverse proxy, ALPS reads X-Forwarded-For (and X-Real-IP) only from peers listed in [server] trusted_proxies. Without that list, every request carries the proxy's own address, and all users share one address budget: by default 5 attempts a minute for the whole site, and a lockout of everyone after 20 refused attempts in an hour with no success in between.
- List only the addresses of your own proxies in
trusted_proxies. A server that browsers reach directly needs no entries. - Have the proxy write
X-Forwarded-Foritself, so that ALPS receives a single header that ends with the address the proxy saw. The proxy either appends the client's address to the header (nginx:proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;) or replaces the header with it (HAProxy:http-request set-header X-Forwarded-For %[src]). - The same list decides which address the logs record, and whether ALPS trusts
X-Forwarded-ProtoandX-Forwarded-Host. It matters whether or not rate limiting is on.
The key itself is described on Config-Server. Complete proxy examples are on Setup-Behind-a-Reverse-Proxy.
Counts and lockouts are kept in the memory of each ALPS process and never written anywhere. Restarting a node clears them, lockouts included.
Without [cluster], each node counts on its own. With [cluster] enabled = true, every node sends the attempts it records to the others, and each node counts them as its own. A success sent this way clears the account's failures and lifts its lockout on the receiving node too, and it leaves a lockout of the address as it is. Lockouts are not sent: each node arms its own lockout when the shared counts reach its limits. Each node applies its own [server.rate_limit] values to those counts, so give every node the same values. See Config-Cluster and RATELIMIT.md, Cluster Synchronization.
At startup ALPS logs the values in effect. The lockout duration is not shown:
Rate limiting enabled: IP=5/min, 20/hour | User=5/15min, 10/hour | Global=100/sec
The line is missing when enabled = false. While running, the limiter logs:
Rate limit: IP <ip> exceeded per-minute limit (<n>/<limit>)IP <ip> locked out for <duration> after <n> failed attempts in 1 hourUsername <user> locked out for <duration> after <n> failed attempts in 1 hour-
sign-in failedat warn level, withreason=rate limitedand the refusal indetail, for each refused sign-in -
Linked accounts: linking <user> rate limited,WebAuthn: password confirmation rate limited for <user>,WebAuthn: recovery code rate limited for <user>andPassword change: current password check rate limited for <user>for the other protected requests. The mail-password confirmation of a CalDAV or CardDAV connection logs no refusal of its own.
Log formats and levels are set in [logging] (see Config-Logging-and-Cache).
config.example.toml suggests three sets of values. "Standard" is the same as the defaults.
| Key | Strict | Standard | Relaxed |
|---|---|---|---|
ip_requests_per_minute |
3 | 5 | 10 |
ip_requests_per_hour |
10 | 20 | 40 |
username_fails_per_quarter |
3 | 5 | 10 |
username_fails_per_hour |
5 | 10 | 20 |
lockout_minutes |
30 | 15 | 5 |
[server]
# Only your own reverse proxies; leave it out when browsers connect to ALPS directly
trusted_proxies = ["127.0.0.1/32", "::1/128"]
[server.rate_limit]
enabled = true
ip_requests_per_minute = 5
ip_requests_per_hour = 20
username_fails_per_quarter = 5
username_fails_per_hour = 10
global_requests_per_second = 100
lockout_minutes = 15Keep enabled = true in production.
Once an account has a security key, signing in to the webmail asks for it after the password. Keys only work when rpid and origins match the address users open the webmail at. The defaults suit local development only.
| Key | Default | Meaning |
|---|---|---|
rpid |
"localhost" |
The relying party ID: the hostname users reach the webmail at, such as "webmail.example.com". Write it with no scheme, port or path. When it is unset, ALPS logs a warning at startup. |
display_name |
"Alps Webmail" |
The name of the service that ALPS gives the browser when a key is added. |
origins |
["http://localhost:1323", "http://localhost:5173"] |
The browser origins keys may be used from. Each origin is the scheme and the host, plus the port when it is not the default, such as "https://webmail.example.com". |
Each key falls back to its default on its own. An empty string or an empty list also keeps the default. Setting rpid alone therefore keeps the localhost origins.
Set both to the hostname that browsers use:
[webauthn]
rpid = "webmail.example.com"
origins = ["https://webmail.example.com"]
display_name = "Example Webmail"-
Behind a reverse proxy, set both by hand. Neither is derived from the request or from forwarded headers, so
trusted_proxiesdoes not help here. -
On a port other than 443, include the port in the origin:
"https://webmail.example.com:8443". ALPS compares the scheme, the host and the port. -
When the same hostname is reached through several origins, for example with HTTPS on two ports, list each origin in
origins. ALPS has a singlerpid, and the browser refuses a key on any page whose hostname is neitherrpidnor a subdomain of it (see When they do not match). -
In a cluster, give every node the same
rpidandorigins, and list every origin the load balancer serves (see Config-Cluster). -
Choose
rpidbefore users add keys. A key is bound to therpidit was added under. Ifrpidchanges later, the browser finds no key for the new ID, and the key page tells the user the key was not used. Those users sign in with a recovery code, add a new key and remove the old one (see Security-Features). An account without codes needs the operator, as described in WEBAUTHN.md. -
[server] trusted_originsis a separate list for the origin check on requests that change something (see Config-Server). It does not affect security keys, and[webauthn] originsdoes not affect that check. When a deployment needs both lists, they often hold the same origin, and neither one fills in the other.
Whatever the cause, adding a key in Settings fails with "There was an error registering your security key. Please try again." The log tells the causes apart:
| Cause | What happens | What the log shows |
|---|---|---|
rpid is neither the page's hostname nor a parent domain of it |
The browser refuses to create the key with a SecurityError, so ALPS never receives it |
No error from ALPS. The access log (on by default) has POST /settings/2fa/begin with status 200 and no POST /settings/2fa/finish after it |
The page's origin is not in origins, for example because origins was left at the defaults or lacks the port |
ALPS refuses the new key with HTTP 500 |
Request error: failed to finish registration: Error validating origin at error level, then POST /settings/2fa/finish with status 500 in the access log |
ALPS catches the commonest mismatch at startup: an origin whose host name is neither rpid nor under it. When rpid is set and origins is left at the localhost defaults, for example, it starts and warns once for each origin (see Startup checks), and adding a key fails as in the second row. An origin with the right host but the wrong scheme or port gets no warning. Always set origins together with rpid. After changing either key, add a test key from the real hostname before telling users about two-factor sign-in.
When rpid is unset, ALPS starts and logs at warn level:
WebAuthn: [webauthn] rpid is unset, defaulting to "localhost" — 2FA will not work unless served from localhost; set rpid and origins to your public hostname
For each entry of origins whose host name is neither rpid nor a subdomain of it, ALPS also starts and logs at warn level, for example with rpid = "webmail.example.com" and the default origins:
WebAuthn: origin "http://localhost:1323" is outside rpid "webmail.example.com"; security-key enrolment will fail; set [webauthn] origins to the browser-facing origin
An entry with no host name to compare, such as "webmail.example.com" written without a scheme, gives WebAuthn: origin "webmail.example.com" has no usable hostname; check [webauthn] origins. Host names are compared without regard to case or a trailing dot, and ports are not compared.
An rpid with a scheme, a port or a path, or a single name with no dot other than localhost, stops ALPS at startup with exit status 1, for example:
Failed to initialize WebAuthn: error occurred validating the configuration: field 'RPID' is not a valid domain string: the scheme component must be empty
The same error ends with the port component must be empty for "webmail.example.com:443", with the path component must be empty for "webmail.example.com/", and with the domain component must actually be a domain for "webmail".
ALPS keeps each account's security keys in the account's own store, so it needs one of these:
- an IMAP server that advertises METADATA or METADATA-SERVER (see Mail-Server-Requirements and Storage-in-IMAP-METADATA);
- the maildir provider (see Setup-Maildir). ALPS keeps them in the account's maildir. For a new account whose maildir does not exist yet, ALPS creates the directory, with mode 0700, at the first save, so the user ALPS runs as must be able to create it.
On an IMAP server without METADATA, a key added is forgotten when the session's IMAP connection closes.
Recovery codes are offered on every store when [cluster] is off. With [cluster] on, they are offered only on IMAP METADATA. The sign-in flow, recovery codes and trust between linked accounts are described on Security-Features and Sessions-and-Sign-in.
The default origins cover ALPS on http://localhost:1323 and the Vite dev server on http://localhost:5173. Browsers allow security keys on http://localhost without HTTPS, so the defaults work there as they are. Open the webmail as http://localhost:1323, not as http://127.0.0.1:1323: the hostname must match rpid. If you run ALPS locally on another port, set that origin, for example origins = ["http://localhost:8080"], and leave rpid at localhost. The list replaces the defaults, so keep "http://localhost:5173" in it if you also use the Vite dev server. See Setup-Local-Try-Out.
- Configuration-Overview: how the configuration file is read
-
Config-Server:
trusted_proxies,trusted_originsandlogin_key - Security-Features: what users see with security keys, recovery codes and lockouts
- Running-in-Production: the settings to check before going live
- RATELIMIT.md, WEBAUTHN.md and CONFIGURATION.md in the repository
These pages describe ALPS as it is on the main branch; the reference documents live in the repository's docs/ directory.
Architecture
- Architecture Overview
- Mail Server Requirements
- Sessions and Sign-in
- Storage in IMAP METADATA
- Mail Providers
- Plugins
- Features
- Security Features
- Clustering
Tests
Configuration
- Configuration Overview
- Server
- Providers and SMTP
- TLS
- Cluster
- Rate Limiting and WebAuthn
- Logging and Cache
- Plugins
- Branding
Common Setups