Skip to content

Security Features

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

This page covers the protections ALPS puts around signing in to the webmail, and the settings for each. These are two-factor sign-in with security keys and recovery codes, trust between linked accounts, login rate limits and lockouts, the origin check on requests that change something, the forwarded headers ALPS accepts from a reverse proxy, and the cookies ALPS sets. Each section links the repository doc that has the full detail.

At a glance

Protection What it does What the operator sets
Two-factor sign-in Once an account has a security key, the webmail asks for it after the password [webauthn] rpid, origins, display_name; a mail server with METADATA, or the maildir provider
Recovery codes Ten single-use codes that sign in in place of the key Nothing without [cluster]; with it, an IMAP METADATA store
Trust between linked accounts Lets a switch to a linked account skip that account's key prompt [server] login_key (linking needs it); each user decides per account
Login rate limits Limits how often passwords and recovery codes can be tried, with lockouts [server.rate_limit]
Origin check Refuses state-changing requests whose Origin (or Referer) names another origin [server] trusted_proxies, trusted_origins
Client addresses Decides which address the limits and the logs see [server] trusted_proxies
Cookies SameSite=Strict on every cookie, HttpOnly on those that hold a token, Secure over HTTPS; an encrypted login token [server] login_key, trusted_proxies
Other protections Response headers, remote content, sender verdicts, the calendar and contacts servers users enter, debug traces [provider.imap] authserv_ids, [server] dav_allow_private_servers, [server] debug

The keys themselves are listed on Config-Server and Config-Rate-Limiting-and-WebAuthn.

Two-factor sign-in with security keys

ALPS supports a second factor through WebAuthn. Platform authenticators (Touch ID, Face ID, Windows Hello) and roaming keys such as a YubiKey both work. The second factor protects the webmail only: IMAP and SMTP clients that sign in to the mail server directly are not affected.

What users see

  • Adding a key. In Settings > 2FA / WebAuthn, the user presses Add Security Key, uses the key, and names it in the dialog that follows. The pane says, and the naming dialog says again before the key is saved, that signing in to the webmail asks for the key from then on. The first key turns the second factor on and comes with recovery codes.
  • Signing in. After the password, the sign-in page moves to the key page, which asks for the key by itself. ALPS asks only that the user is present at the key (a touch). It does not require the key's PIN or a biometric check. The key step must be finished within 5 minutes. After that, the page goes back to the password form and says "Your sign-in timed out before the security key was checked."
  • Other sessions. When a key is added, the account's other webmail sessions are asked for it too. On the same ALPS node, their next request signs them out, and signing in again asks for the key. Sessions on other ALPS nodes are asked within about a minute of use, or at their next request when the nodes form a cluster.
  • Adding more keys, removing keys. Once an account has a key, its keys can be changed only from a session that has given the second factor. Removing a key also asks for the account's password. Removing the last key turns the second factor off again, and removes the recovery codes with it.

The settings pane refuses these changes with a message the user can act on. For example, a session that has not used the key gets "Sign in with your security key to change this setting.", and a wrong password gets "That password is not right."

What the operator configures

The [webauthn] section binds keys to the hostname browsers use:

[webauthn]
rpid = "webmail.example.com"               # the browser-facing hostname
origins = ["https://webmail.example.com"]  # exact origins: scheme, host, and port when not the default; no trailing slash
display_name = "Example Webmail"           # shown by the browser while a key is used (default "Alps Webmail")
  • The defaults (rpid = "localhost" and the localhost origins) work only for local development. ALPS warns at startup while rpid is unset, and for each origin whose host is neither the rpid nor a name under it (WebAuthn: origin "..." is outside rpid "..."), which is what setting rpid and leaving origins out gives. Set both keys: each falls back to its own default, and neither is taken from a reverse proxy's forwarded headers.
  • A key is bound to the rpid it was added under. If rpid changes, for example because the webmail moves to another hostname, the keys added before stop working. Recovery codes are not bound to rpid, so where they are offered, users can sign in with a code, add their keys again and remove the old ones.

The keys, their defaults, the startup checks and what a mismatch looks like in the log are on Config-Rate-Limiting-and-WebAuthn.

Where the key record is kept. ALPS has no database. It keeps each account's key record in the account's own store, beside its settings (see Storage-in-IMAP-METADATA):

  • With an IMAP server, the server must advertise METADATA or METADATA-SERVER. Without METADATA, ALPS keeps the record only for the life of the session's IMAP connection, so a key that a user adds is forgotten when that connection closes. No setting hides two-factor sign-in, so use a server without METADATA only for trying ALPS out (see Mail-Server-Requirements).
  • With the maildir provider, the record goes in the account's maildir. When the maildir does not exist yet (for example, nothing has been delivered to the account), the first save creates it, so a key can be added before the first delivery.
  • ALPS reads the record at every sign-in. When the mail server cannot return it, the sign-in does not go ahead: ALPS answers 503, and the sign-in page says the service could not sign the user in. This holds for accounts without a key too, since ALPS cannot tell from a record it could not read whether the account has one.

Several nodes. The pending sign-in that waits for the key lives in the memory of the node that accepted the password, so the key page's requests must reach that node. Use session affinity (see Clustering). Upgrade every ALPS instance together before it serves traffic, because mixed versions must not share accounts. docs/WEBAUTHN.md explains why.

Log lines. Sign-ins and key changes are logged at info level, with the user, the client address and a short session hash:

Message When
sign-in awaits second factor The password was accepted and the key is asked for
sign-in with 2fa: true and factor: "security key" or factor: "recovery code" The second factor was given
sign-in failed with reason: "second factor refused" or reason: "recovery code refused" (warn) The key or the code was refused
security key added, security key removed A key changed; the line names the key and how many the account has afterwards
recovery codes created New codes were issued; the codes themselves are never logged
account switch awaits second factor, account switch with 2fa_trusted A switch to a linked account; see below
security key may have been copied (warn) A key's signature count did not go up (keys that always report 0 are exempt). The sign-in still goes ahead; follow it up with the user

The full flow, with every request and answer, is in docs/WEBAUTHN.md.

Recovery codes

A recovery code signs in once in the key's place, for when the key is not at hand. For example, the passkey is in another browser, or the hardware key was left at home or lost. The session a code opens has given the second factor, so the user can add a new key there and remove the old one, without the operator's help.

  • Where they come from. The first key comes with ten codes, where codes are offered (see below). ALPS shows them once, in a dialog with copy and download buttons, and says to keep them somewhere safe. Keys added later come with none.
  • What a code looks like. A code is two groups of five letters and digits, such as 7k2m9-x4hqr. It may be typed as it was written down: in capitals, with a space instead of the dash, an O for a 0, an I or L for a 1.
  • Using one. On the key page, the user presses Use a recovery code and types a code. A code that worked is spent. A used or wrong code gets "That recovery code is not right, or it has been used already."
  • New codes. Settings > 2FA / WebAuthn shows how many codes are left. Create New Codes replaces them all with ten new ones, shown once. It asks for the account's password and works only from a session that has given the second factor.
  • Rate limits. Each code tried counts against the login rate limits, as a password does.
  • A lost reply. If a code was accepted but the sign-in did not complete (a lost connection, for example), that code is spent. Use another one.

Where they are offered. Without [cluster], ALPS always offers recovery codes. With [cluster] enabled, ALPS offers them only when the account's store is IMAP METADATA. The backend must give consistent answers across nodes, which ALPS cannot check for itself; docs/WEBAUTHN_COORDINATION.md lists what it needs. Where codes are not offered, the first key comes without them. The settings pane then says "Recovery codes are unavailable on this installation. Use your security key or contact your administrator."

When the key is lost and the codes are gone. The user cannot pass the second factor, and the operator has to reset the account's key record. docs/WEBAUTHN.md has the procedure for IMAP METADATA and for maildir. After the reset, the next sign-in asks for the password alone, and the user can add a new key, which comes with new codes where codes are offered.

Trust between linked accounts

Users can link several accounts (Settings > Linked Accounts) and switch between them without typing their passwords. Linking needs [server] login_key. See Sessions-and-Sign-in for how linking and switching work.

An account with a security key still asks for its own key when someone switches to it. The current session ends at once, and the key page asks for the target account's key or one of its recovery codes. If the user leaves the key page, the browser goes back to the sign-in page, not to the account it came from.

Trust Linked Accounts lets a switch skip that prompt:

  1. Sign in to the target account with its key.
  2. In Settings > 2FA / WebAuthn, under Trust Linked Accounts (shown once the account has a key), tick Allow switching to this account without 2FA.
  3. Confirm with the account's password.

The prompt is skipped only when both of these hold:

  • the target account has trust turned on, and
  • the session being switched from has itself given its second factor: its own key or recovery code, or a trusted switch.

So with trust on in the linked accounts, the key is asked for once, at the sign-in to the first account. A session that signed in with a password alone, to an account without a key, is still asked for the target's key.

Turning trust on needs a key on the account, a session that has given the second factor, and the password. The password confirmation counts against the login rate limits. Turning trust off needs only the session. Trust that was turned on under an older ALPS version is read as off after the upgrade, and the account's owner turns it on again if they want it.

Login rate limits and lockouts

ALPS limits how often passwords and recovery codes can be tried, per address, per account and across the whole server. The limits are on by default.

What is limited

The same counts and lockouts cover every request that checks a password or a recovery code:

  • signing in at POST /session, including a sign-in request sent without a username and password, which signs in with the browser's login token;
  • linking another account (POST /accounts), counted for the account being linked;
  • a recovery code on the key page;
  • the current password asked for when removing a key, turning on trust for linked accounts, and creating new recovery codes;
  • the password plugin's current-password check (see Setup-Password-Change);
  • the mail password asked for before a CalDAV or CardDAV connection in Settings sends it to a server the user entered (see Plugins).

The limits

With the defaults:

  • One address gets 5 attempts a minute, successful ones included, and then waits 60 seconds. After 20 refused attempts from it within an hour, with no successful sign-in from it in between, the next attempt locks the address out for 15 minutes.
  • One account that gets 5 refused attempts within 15 minutes, from any address, waits until the oldest of them is 15 minutes old. After 10 refused attempts within an hour, the next attempt locks the account out for 15 minutes.
  • The whole server takes 100 attempts a second. Beyond that, every protected request waits a second, and the sign-in page says the service could not sign the user in just now.

How the counting works:

  • Only a refused password or code is a failure. An attempt that ends in an error, such as a mail server that cannot be reached or that says the account has too many connections, adds no failure. A user who keeps trying through a mail-server outage does not lock their account.
  • A success clears the account's failures and lifts its lockout, on every node in a cluster.
  • While an account waits or is locked, its attempts are refused before the password is checked. The user has to wait, even with the right password. Other accounts are not affected.
  • Users who share one address share its counts. Successful sign-ins from an office or a NAT add to that address's per-minute count, so raise ip_requests_per_minute where many users sign in from one address. Successful sign-ins never lock an address out.

Each refusal is HTTP 429 with a Retry-After header in whole seconds, rounded up, so a wait with less than a second left still says 1. The sign-in page counts the wait down and keeps Sign In disabled until it ends.

[server.rate_limit] enabled = false turns all of the limits off; a single limit cannot be turned off by itself. Counts live in each ALPS process's memory, and a restart clears them. With [cluster] enabled, nodes share their attempts, so the limits apply to the cluster as a whole; give every node the same values. Every key with its default, what each limit counts, the answer each kind of request gets, and the log lines are on Config-Rate-Limiting-and-WebAuthn, and docs/RATELIMIT.md has the full rules.

Two refusals look similar but come from the mail server, not from these limits. A sign-in that the server refuses because the account holds too many connections is answered 503 connection_limit, and the sign-in page says so instead of asking for the password again. A search the server rate-limits is answered 429 search_rate_limited. See Mail-Server-Requirements.

Client addresses behind a proxy

The rate limits and the logs both use the client's address. ALPS reads forwarded headers (X-Forwarded-For, then X-Real-IP) only when the connection comes from an address listed in [server] trusted_proxies, and ignores them from every other peer. List only your own proxies there, every hop in front of ALPS included. A server that browsers reach directly needs no entries. How ALPS picks the client's address from the headers, and the accepted forms of an entry, are on Config-Server.

Warning

Behind a proxy that is not listed, every request carries the proxy's address, and all users share one address's limits. By default, the whole site then gets 5 sign-in attempts a minute. After 20 refused passwords within an hour, with no success in between, the next attempt locks everyone out for 15 minutes.

Have each proxy set X-Forwarded-For itself, by appending the client's address (nginx $proxy_add_x_forwarded_for) or by replacing the header. The same list also decides whether ALPS accepts the scheme and host that the proxy forwards, for the origin check and for the Secure flag on cookies.

The origin check

ALPS checks every POST, PUT, DELETE and PATCH request against the origin it expects. It compares the browser's Origin header, or its Referer when the browser sends no Origin, with that origin:

  • The scheme is https when ALPS terminated TLS itself, otherwise http. The host is the request's Host.
  • From a peer listed in trusted_proxies, X-Forwarded-Proto and X-Forwarded-Host replace the scheme and the host. When a header carries several values, ALPS takes the first one, so have the proxy set these headers and replace any value the client sent (nginx proxy_set_header, HAProxy http-request set-header), not append to them.
  • An origin listed in [server] trusted_origins is accepted as well. Case does not matter, and a trailing slash is ignored.

A request whose Origin or Referer names any other origin is refused with 403 {"error":"Invalid origin","code":403,"status":"Forbidden"} (or Invalid referer). The SameSite=Strict cookies are a second protection beside this check.

Behind a TLS-terminating proxy

The proxy speaks HTTPS to the browser and plain HTTP to ALPS. Unless the proxy forwards the browser's scheme and host, ALPS expects http:// while the browser sends https://, and every sign-in, send and delete is refused. Forward both headers and list the proxy:

# inside the server block that terminates TLS
location / {
    proxy_pass http://127.0.0.1:1323;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $http_host;   # keeps the port; $host drops it
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Real-IP         $remote_addr;
}
[server]
addr = "127.0.0.1:1323"
trusted_proxies = ["127.0.0.1/32", "::1/128"]
  • On a port other than 443, X-Forwarded-Host must carry the port (webmail.example.com:8443), because the browser's Origin includes it.
  • Pass the browser's Origin header through unchanged. Do not rewrite it in the proxy.
  • If the proxy cannot set the forwarded headers, list the browser-facing origin in trusted_origins = ["https://webmail.example.com"] instead. This covers only the origin check. Without X-Forwarded-Proto from a listed proxy, cookies are still issued without Secure, so prefer the forwarded headers.

HAProxy and Caddy examples, and the other proxy settings ALPS needs (body size, the event stream), are on Setup-Behind-a-Reverse-Proxy and in docs/TLS.md.

Reading a refusal in the log

Each refusal is logged as CSRF: Origin mismatch: got "<origin>", expected "<origin>", or CSRF: Referer mismatch: ... when the browser sent only a Referer. When the cause looks like a proxy setup problem, ALPS adds a hint in parentheses:

Hint What to fix
scheme differs and the peer <ip> is not in trusted_proxies; ... Add the proxy's address to trusted_proxies, and forward X-Forwarded-Proto
scheme differs; the trusted proxy forwards X-Forwarded-Proto "http" instead of "https" ... The proxy sends the wrong scheme. Check where it takes X-Forwarded-Proto from
scheme differs; the trusted proxy is not sending X-Forwarded-Proto ... Set X-Forwarded-Proto in the proxy
hosts differ only by port; forward the browser-facing host with its port ... Forward the host with its port (nginx $http_host, not $host)
X-Forwarded-* headers ignored: peer <ip> is not in trusted_proxies The proxy's real address is missing from trusted_proxies

A line without a hint names an origin that is not the webmail's own.

Cookies

ALPS's cookies are described one by one on Sessions-and-Sign-in. What protects them:

  • Every cookie has Path=/ and SameSite=Strict, a second protection beside the origin check.
  • The cookies that hold a token (alps_session, alps_login_token and alps_2fa_pending) are HttpOnly, so the page's scripts cannot read them.
  • Every cookie carries Secure when the browser's connection is HTTPS: either ALPS terminates TLS itself, or the connection comes from a proxy listed in trusted_proxies that sends X-Forwarded-Proto: https. Behind a TLS-terminating proxy that is not listed, all of them are sent without Secure.
  • A sign-in to an account with a key issues no session cookie and no login token until the key or a recovery code has been given. Until then, ALPS sets only alps_2fa_pending, for 5 minutes.
  • The logs never carry a session token. They carry a short hash of it (session=), which ties a session's lines together.

The login token and login_key. With [server] login_key set, ALPS writes the alps_login_token cookie at each sign-in, so that a session whose in-memory copy is gone, after a restart for example, can be opened again. The token is encrypted and signed with login_key, so it cannot be read or changed in the browser, and ALPS refuses it 30 days after it was written. The same key encrypts the passwords stored for linked accounts, and the separate CalDAV and CardDAV passwords users save in Settings.

  • Keep login_key secret: it protects the passwords inside login tokens, stored linked accounts and saved CalDAV and CardDAV connections. Give every node that serves the same hostname the same key.
  • Changing the key makes every login token unreadable, so every user signs in again. It breaks every account link until users remove the linked account and add it again, and users enter their separate CalDAV and CardDAV passwords again.

How to generate the key, how a session is restored from the token, and session lifetimes (idle time, the absolute ceiling, session caps) are on Sessions-and-Sign-in and in docs/SESSIONS.md.

Other protections

  • Response headers. Every answer carries a Content-Security-Policy that allows only ALPS's own bundled scripts (script-src 'self') and lets only ALPS frame its pages (frame-ancestors 'self'). Every answer also carries X-Content-Type-Options: nosniff and X-DNS-Prefetch-Control: off. Proxied remote content, BIMI logos and a deployment's own icons get a stricter sandboxed policy. A reverse proxy should pass these headers through and not replace them.
  • Remote content in messages. Remote images and other resources stay blocked until the user clicks Load remote content, unless they chose Always load under Settings > Reading & Composing > Show Remote Content. Allowed resources are fetched through ALPS, only from public addresses, and served sandboxed. See docs/OPERATIONS.md.
  • Sender verdicts. The sender badge, the warning above a message that failed authentication, and BIMI logos are based on the message's Authentication-Results field. Set [provider.imap] authserv_ids to your receiving mail servers' authserv-ids (for example ["mx.example.com"]), so that ALPS reads your server's verdict and not a field the sender wrote. See Config-Providers-and-SMTP.
  • Calendar and contacts servers users enter. ALPS connects to a CalDAV or CardDAV server a user enters in Settings over HTTPS only, and only at public addresses, unless [server] dav_allow_private_servers = true. Requests to such a server stay on its own origin. See docs/CDAV.md.
  • Debug traces. [server] debug = true turns on IMAP, CalDAV, CardDAV and ManageSieve traces, and [provider.imap] debug the IMAP trace. They go through [logging] as protocol trace lines at debug level, so they reach the log only while its level is debug, which [server] debug makes the default when [logging] level is not set. Passwords and the HTTP authorization and cookie headers are redacted, but message, contact and calendar content is not, so keep both off in production, and keep the log readable only by administrators.

Checklist for production

  • Set [webauthn] rpid and origins to the browser-facing hostname, and check that the startup log has no rpid is unset or is outside rpid warning.
  • If users are to add security keys, use a mail server with METADATA, or the maildir provider.
  • Behind a proxy, list only your own proxies in trusted_proxies, and forward X-Forwarded-Proto, X-Forwarded-Host (with the port) and X-Forwarded-For.
  • Sign in once through the proxy and check that the cookies carry Secure and that the log shows the client's address, not the proxy's.
  • Set login_key, keep it secret, and give every node the same one.
  • Keep the rate limiter on, and give every node the same [server.rate_limit] values.
  • Set authserv_ids, and keep debug off.

Running-in-Production has the rest of the production setup.

Clone this wiki locally