Skip to content

App passwords and mailbox two-factor authentication

Purpose. A mailbox password used to be one credential for IMAP, SMTP submission and webmail together: it could not be scoped to a device, could not be revoked without changing it everywhere, and could not be protected by a second factor at all — because IMAP has no way to prompt for a code. App passwords fix all three, and they are what makes mailbox TOTP possible rather than merely available.

Behaviour.

  • App passwords are per-device credentials. The owner (or an administrator) names one — “iPhone”, “Thunderbird at home” — and gets a 20-character secret in groups of four. It works for IMAP and SMTP submission exactly as the account password does, and revoking it stops that one client without touching any other.
  • “Last used” is the last time a mail client signed in to read mail with it. Settings → Security and the administrator’s mailbox page show it beside each app password. It is the most recent IMAP, POP3 or ManageSieve (Sieve filters) sign-in with that password, recorded within about a minute of happening, and it reads “never” until the first one. A phone that has used its app password today shows today.
  • Shown once. Only the SHA-256 is stored, so the secret is in the creation response and nowhere else — the same contract as an API token, and the UI says so beside it.
  • TOTP attempts are rate-limited on their own, much tighter budget than passwords (Login rate limiting & brute-force lockout). This matters more here than anywhere else: a six-digit code with a one-step window is ~500k guesses, so until 2026-09-09 — when the product had no authentication rate limit at all — a stolen account password defeated the second factor by brute force. The budgets are separate by construction, and a test fails if they are ever merged.
  • Turning on TOTP makes app passwords mandatory for mail clients. A mailbox with a second factor has no account row in the Dovecot passdb at all, so IMAP and SMTP refuse the account password and accept only app passwords. The account password still signs in to webmail, which is the one place a code can be asked for. The UI says this before the switch is thrown, and the screen carries a standing banner afterwards.
  • The owner enrols; an administrator cannot. TOTP setup, confirmation and removal live only on the owner’s own webmail settings: GET /mail/security, POST /mail/security/app-passwords, DELETE /mail/security/app-passwords/{id} and POST /mail/security/totp/{setup,enable,disable,recovery-codes}. Turning TOTP off needs a current authenticator code, like generating new recovery codes. The administrator’s side is /mailboxes/{id}/security, its app-passwords routes and totp/clear-broken. An administrator can mint and revoke app passwords for a mailbox — the case that exists for is somebody on the phone who cannot get their client working — but an admin who could turn a second factor off would make it a formality.
  • Enrolment is two steps, reusing the admin flow: a secret is generated and held in memory, and only a code that validates against it turns TOTP on. An abandoned enrolment leaves nothing behind and a restart cancels it, which is the right failure direction for a factor that is not on yet.
  • Enabling TOTP does not revoke existing app passwords. They are what the owner’s clients are already using, and invalidating them at the exact moment the account password stops working on IMAP would break every device at once.
  • Turning TOTP on issues ten recovery codes, each ten letters and digits shown as xxxxx-xxxxx. They are shown once, on the screen that confirmed the code, with Copy and Download and a message to store them somewhere other than the phone; only a keyed hash of each is kept, so nobody — the owner, an administrator, the API — can show them again. They are the way back from a lost authenticator, which is otherwise a dead end: an administrator cannot turn a working factor off.
  • A recovery code signs in to webmail once, in place of the authenticator code. The sign-in form’s code field says so and takes either. Case, dashes and spaces do not matter, and the alphabet is Crockford base32, which leaves out the letters that are easy to misread (o is read as 0, i and l as 1) and has no u, so a typed u is refused. A used code never works again, even when two sign-ins race with it. A wrong recovery code counts against the same small budget as a wrong six digits, so guessing codes locks the mailbox’s sign-in out as quickly as guessing digits does. Each recovery-code sign-in is written to the audit log with how many codes remain.
  • Settings → Security shows how many are left, warns when two or fewer remain, and offers Generate new codes, which needs a current authenticator code — an open session is not enough — and replaces the whole set: every old code stops working, used or not. Turning TOTP off deletes them, and so does an administrator clearing a broken factor. An administrator sees the count on the mailbox’s page and has no way to mint or read codes.

Configuration. None. mailbox.totp_secret, the mailbox_app_password and mailbox_totp_recovery_code tables, plus v_dovecot_credentials — the passdb view. Recovery codes are API-only; Dovecot is granted nothing on their table, because IMAP cannot prompt for a second factor of any kind.

How Dovecot sees it. v_dovecot_users is unchanged and remains the userdb (home, uid, gid, quota) and the iterate source; only the passdb moved, to v_dovecot_credentials, which returns one row per usable credential:

row password app_token
the account password {ARGON2ID}… NULL
each app password {SHA256}… its SHA-256 hex

and the query picks exactly one:

SELECT "user", password, left(app_token, 16) AS app_password FROM v_dovecot_credentials
WHERE "user" = '%u' AND (app_token IS NULL OR app_token = encode(digest('%w','sha256'),'hex'))
AND NOT EXISTS (SELECT 1 FROM v_dovecot_auth_bans b
WHERE (b.scope = 'identity' AND b.value = '%u')
OR (b.scope = 'ip' AND b.value =
CASE WHEN lower('%r') LIKE '::ffff:%%.%%' THEN substr('%r', 8)
WHEN strpos('%r', ':') > 0 THEN network(set_masklen('%r'::inet, 64))::text
ELSE '%r' END))
ORDER BY (app_token IS NULL) LIMIT 1

(The shipped text is one line, in docker/dovecot/dovecot-sql.conf.ext.tmpl; it is wrapped here only to be readable.)

%w is the submitted password and only selects the row; Dovecot then verifies the hash it was given, so a WHERE clause that matched wrongly still could not authenticate. It is built this way rather than with nopassword, a multimap, or a Lua passdb that would put the control plane in the IMAP auth path.

The NOT EXISTS clause above is the brute-force lockout, not part of app passwords — see Login rate limiting & brute-force lockout. %r is the remote IP, and the CASE turns it into the key an IP ban is stored under: an IPv4 address as itself, an IPv4-mapped IPv6 address (::ffff:a.b.c.d) as the IPv4 address it carries, and any other IPv6 address as its /64. %% is Dovecot’s literal %.

How “last used” is recorded. Dovecot verifies app passwords itself, so the control plane never sees one used. The query above returns left(app_token, 16) — the first 16 hex characters (64 bits) of the matched app password’s token hash — as the passdb extra field app_password (NULL for the account password, which Dovecot then omits; it changes no row and authorises nothing), login_log_format_elements in dovecot.conf appends it to the login process’s success line, and the worker’s dovecot.log sweep — the same one that counts failed logins for the lockout — reads it with the same line’s user=<…> and moves last_used_at forward on the app password of that mailbox whose token_hash starts with the prefix: one update per app password per sweep, never backwards. The mailbox is part of the match, so another mailbox’s app password that happens to share the prefix is never stamped.

Why a prefix of a column the view already had: the whole hash is the verifier Dovecot checks and has no business in a log file, and the 64 bits that are logged, together with the mailbox, still name one app password; and because it needs no schema change, there is no upgrade window in which password_query names a view column the API has not yet migrated in — the dovecot service does not wait for api, and a query naming a missing column fails every login. Measured on Dovecot 2.3.19 (with the whole hash in the field at the time; the shipped prefix shown):

Oct 02 00:19:37 imap-login: Info: Login: user=<bob@example.test>, method=PLAIN, rip=192.168.0.1, lip=192.168.0.6, mpid=274658, session=<lHvUfNBcYunAqAAB>, app=<3f0a9c1d2b4e5f60>

An account-password login and the webmail’s own master-user connection log app=<>.

Limits & edge cases.

  • “Last used” does not see sending. SMTP submission (587/465) authenticates through Dovecot’s auth service with no login process, and Dovecot logs no success line for it, so a device that only sends mail with its app password — a printer, a monitoring script — never updates it and can read “never” while working. Every mail client that also reads mail is covered. It is as precise as the sweep interval (about a minute), and it stops moving silently if Dovecot’s login line changes shape, as the lockout’s counting does.

  • App passwords are selected inside the password query rather than returned as extra rows, and the ordering that does it is load-bearing. It is why the account password and any number of app passwords can coexist on one mailbox.

  • App passwords are stored unsalted. That is sound because the secret is generated with full entropy and is never user-chosen — the same reasoning as api_token.token_hash. Anyone who later lets a person choose their own app password silently invalidates it.

  • Revocation takes effect at the next authentication; there is no session kill, and a mail client already connected stays connected until it reconnects.

  • TOTP here is for the mailbox owner’s webmail session. Admin console accounts have their own, older TOTP on the Account page — same algorithm and the same two-step enrolment, a different table, and since its own recovery codes on the same design — see Admin UI → Account.

  • Recovery codes are a second factor, not a password. At about 50 bits each they are far shorter than an app password, which is sound only because a code is useless without the account password and online guesses spend the TOTP ban budget. Do not let one stand in for the account password anywhere.

  • Recovery codes are stored as an HMAC keyed from SECRET_ENCRYPTION_KEY, not a plain SHA-256. Unlike an app password, fifty bits hashed without a key could be searched offline from a database dump; with the key in .env rather than the database, a dump alone reveals nothing. The consequence: restoring a database without its .env invalidates every recovery code, along with the TOTP seeds they back up — an administrator’s clear removes both, and the owner enrols again for a new set.

  • An unreadable seed still refuses every sign-in with totp_unreadable, recovery code or not; an administrator’s clear is still the way back from that.

  • pgcrypto is required for digest(); migration 0023 creates the extension.

  • TOTP seeds are sealed with SecretBox, under separate HKDF purposes for the admin console (auth.user-totp) and for mailboxes (auth.mailbox-totp) — so a ciphertext lifted from one column into the other fails to open rather than authenticating somebody else’s factor. Seeds that predate this are sealed on boot by sealTotpSecrets(), beside syncDaemonRole() and for the same reason: a SQL migration cannot reach SECRET_ENCRYPTION_KEY.

  • If SECRET_ENCRYPTION_KEY is lost or rotated, an enrolled mailbox fails closed, with totp_unreadable rather than totp_invalid — the difference between “wrong six digits” and “no six digits will ever work”. It is not treated as “not enrolled”, because the column is still populated and the Dovecot passdb still omits the account row: reading it as unenrolled would open webmail while leaving mail clients strict, which is the wrong way round. An administrator clears it from the mailbox’s page, which is refused while the secret is readable.

Verifying it. The fastest check is Dovecot’s own, which needs no client:

Terminal window
docker exec mailserver-dovecot-1 doveadm auth test bob@example.test '<the app password>'
# passdb: bob@example.test auth succeeded

With TOTP on, the same command with the account password prints auth failed — that is the feature working, not a fault.