Skip to content

Login rate limiting & brute-force lockout

Purpose. Stop an attacker guessing passwords and two-factor codes, on every way in — the admin console, webmail, IMAP, POP3, ManageSieve and SMTP submission — and give an administrator a way to see and undo a block. A block is enforced on all of them, and a failure on any of them counts towards one — though the control plane and the mail daemons keep separate counts, so a failure at the web login and one over IMAP do not add up.

Behaviour.

  • Failures are counted in three separate budgets, over a rolling window (authLimits.windowMinutes, default 15), by the control plane — and in the two password budgets for the mail daemons, which have no second factor to count:
    Budget Default Catches
    passwordAttempts — failed passwords for one identity 10 somebody working on one account
    ipAttempts — failed passwords from one IP, across all identities 50 a spray, which no per-identity budget sees
    totpAttempts — failed two-factor codes for one identity 5 an attacker who already has the password
  • Changing one’s own password spends the same password budget. Account → Password asks for the current password; a wrong one counts as a failed sign-in for that account and that address, and once the budget is spent the change is refused 429 like a sign-in. Otherwise a session that is not its owner’s could guess the password without limit and then sign the owner out everywhere. The webmail’s own password change (PUT /mail/account/password) counts the same way, on the mailbox’s address.
  • The two-factor budget is separate and much tighter, and that is the point. A six-digit code with a one-step window is ~500k guesses; if password attempts could spend that budget the tighter ceiling would mean nothing. A test fails if the two counters are ever merged. A wrong recovery code — a mailbox’s or, since, an admin-console account’s — spends this same budget: it is a guess at the same factor, so a separate counter would double the tries (App passwords and mailbox two-factor authentication).
  • Spending a budget creates a ban in auth_ban, for authLimits.banMinutes (default 60). It is enforced in two places from that one row: the control plane refuses POST /auth/login with 429 too_many_attempts, and Dovecot’s password_query returns no row for a banned identity or a banned IP.
  • A 429 says nothing about whether the identity exists. The ban is keyed on the string that was tried, so a banned address that never existed is refused identically to a real one.
  • Dovecot’s own failures reach the same table, over the same window. The worker tails dovecot.log on the shared maillog volume every minute (AUTH_BAN_INTERVAL_MINUTES, a worker setting compose/compose.yaml does not pass, so setting it in .env has no effect), adds what it read to a count per identity and per IP per minute, and bans whatever is at or over budget across the whole window — so nine guesses a minute against one account is banned in its second minute, where a sweep that only saw the last minute never banned it. The count survives a worker restart, and neither a crash nor two sweeps overlapping counts a line twice. A ban lands up to about a minute after the failure that earned it; Dovecot’s auth_failure_delay = 2 s slows an attacker in between.
  • Where Dovecot’s log goes, and how it is kept. Dovecot writes to /var/log/mail/dovecot.log on the maillog volume, mirrored to the container’s stdout so docker logs mailserver-dovecot-1 still shows it. The Dovecot container’s config-watcher.sh rotates it once a day, or sooner past 64 MiB. It renames the file to dovecot.log.<YYYYMMDDhhmmss> and runs doveadm log reopen. It is a rename, never a copy-and-truncate: the renamed file keeps its inode, so the sweep finds the part it had not read yet, finishes it, then starts the new file. A rotation loses no failure and counts none twice. The Dovecot container deletes rotated files older than 7 days (maillog.sh; the worker only reads that directory), so a worker stopped for a week loses the rotation it had not finished. The worker reads the log as gid 2525: the Dovecot container hands it 0640 to that group at start and after each rotation, and a sweep that finds it unreadable reports unreadable and tries again next minute from the same cursor. One sweep reads at most 8 MiB (MAX_READ_BYTES), so a lost or reset cursor on a large log is worked through a sweep at a time rather than loaded into memory at once. If the cursor’s file is gone altogether (pruned, or truncated in place), the sweep restarts at the top of the live log. The cost is at most a delayed ban.
  • IMAP, POP3, ManageSieve and SMTP submission are all counted. The first three from Dovecot’s *-login disconnect line, which carries Dovecot’s own attempt count. A failed SMTP AUTH on 587 or 465 writes no such line — Postfix asks Dovecot’s auth service directly — so it is counted from Dovecot’s passdb line, one attempt each. Postfix authenticates through the same password_query, so a ban earned on any surface refuses submission too. A guess at the master user’s password is counted against its IP too. That passdb is not filtered by the ban, and no longer needs to be: it accepts logins only from the stack’s own networks, so from any other address the master password is refused even when it is right.
  • A ban expires by itself, and retrying against it does not extend it. A banned login is refused before its password is checked, so Dovecot logs every retry — the right password included — as a failure; the sweep does not count a failure logged while a ban on its IP or identity was in force, or a person’s phone polling in the background would keep them banned indefinitely. The exception is a guess at the master user’s password, which a ban does not refuse — from outside the stack the network refuses it instead — so it is still counted, over submission, IMAP, POP3 and ManageSieve alike, so guessing it keeps extending that IP’s ban. Over IMAP, POP3 and ManageSieve Dovecot logs only the master user’s name, which the sweep recognises from DOVECOT_MASTER_USER. Bans and their clearing are audited without a tenant, so they are in audit_log but not on the Audit log screen. Blocked sign-ins (UI: admin nav; GET /auth-bans) lists what is live — what was blocked, why, which surface counted it, how many attempts, and when it expires. Clear (DELETE /auth-bans/{id}) releases one early and is audited (auth.ban.clear). A ban is audited as auth.ban whichever surface created it; one from the worker’s sweep has no actor and carries source: dovecot.

Configuration. ServerConfig.authLimits, set through PUT /api/v1/server/config (Settings has no fields for it yet; applying any section keeps the current values). enabled: false stops bans being created. The control plane keeps counting, so turning it back on during an attack does not restart that attacker from zero at POST /auth/login. The worker’s sweep does the same: it keeps counting while disabled, and the first sweep after the feature is turned back on bans whatever is already at or over budget in the window.

Limits & edge cases.

  • An IP ban blocks IMAP for everyone behind that address. A NATed office sharing one address can be locked out by one misconfigured client; ipAttempts is deliberately loose and Blocked sign-ins is how you undo it.
  • An IP ban on an IPv6 client covers its whole /64. One host can use any address in its /64, so counting and banning the exact address would refuse nobody who rotates. Failures from anywhere in a /64 spend one IP budget, and the ban row shows the network (2001:db8:1:2::/64). A provider that puts several customers in one /64 means one customer’s guessing can lock out the others until the ban expires or is cleared; that is the same trade a shared IPv4 address makes. IPv4 bans stay on the single address, and an IPv4-mapped address (::ffff:203.0.113.5) is treated as the IPv4 address it carries, in the control plane and in Dovecot alike.