Skip to content

Config Rate Limiting and WebAuthn

Dejan Strbac edited this page Nov 12, 2026 · 1 revision

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.

Rate limiting: [server.rate_limit]

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.

Keys

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

What counts as an attempt

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.

How the tiers combine

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.

What a refused request gets

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.

Client addresses behind a proxy

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-For itself, 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-Proto and X-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.

Restarts and clusters

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.

What to check in the log

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 hour
  • Username <user> locked out for <duration> after <n> failed attempts in 1 hour
  • sign-in failed at warn level, with reason=rate limited and the refusal in detail, 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> and Password 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).

Example values

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 = 15

Keep enabled = true in production.

Security keys: [webauthn]

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.

Keys

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.

rpid and origins in production

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_proxies does 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 single rpid, and the browser refuses a key on any page whose hostname is neither rpid nor a subdomain of it (see When they do not match).
  • In a cluster, give every node the same rpid and origins, and list every origin the load balancer serves (see Config-Cluster).
  • Choose rpid before users add keys. A key is bound to the rpid it was added under. If rpid changes 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_origins is a separate list for the origin check on requests that change something (see Config-Server). It does not affect security keys, and [webauthn] origins does not affect that check. When a deployment needs both lists, they often hold the same origin, and neither one fills in the other.

When they do not match

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.

Startup checks

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".

What else two-factor sign-in needs

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.

Local development

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.

Related pages

Clone this wiki locally