<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Signing in: passwords, second factor, passkeys, single sign-on, recovery, devices]]></title><description><![CDATA[<p dir="auto"><em>Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) refuses a state-changing REST request that carries a credential - HTTP Basic included, which a browser keeps once it has answered the sign-in box - when the browser reports it as cross-site (<code>Sec-Fetch-Site</code>, or an <code>Origin</code> that is not this server's), and holds the password-reset request to the same rule, so another site cannot have a current browser drive it; the webmail and the Control Deck act on their own origin, and scripts, <code>hmctl</code> and the PowerShell module send no such header, so none of them is affected. SMTP, POP3 and ManageSieve now refuse a SASL PLAIN sign-in that asks to act as another account. See <a href="/topic/28277">the 6.3.7 announcement</a>. hMailServer 6.3.8 (2 November 2026) signs a name in to an account only when it is the account's stored address, compared in lower case after a domain alias and the default domain, on every protocol, the webmail, the REST API and COM, and for an OpenID Connect or SAML sign-in, so that the per-name lockout and the compromised-account hold can no longer be got round with another spelling of the name: a client that signs in with another spelling, in more than the case of the letters A to Z, or with white space at either end, may no longer sign in, since such a name fails as a name that does not exist fails. A failed account sign-in at the REST listener through a reverse proxy on the same machine, like a failed CalDAV, CardDAV or Autodiscover sign-in from loopback or an address in <code>WebServicesTrustedProxies</code>, is counted against the name alone (<code>AccountLockoutThreshold</code>, off by default), never towards a ban of the proxy's address. See <a href="/topic/28278">the 6.3.8 announcement</a>.</em></p>
<p dir="auto">This topic covers how people prove who they are to hMailServer 6.3.4: what 6.3.3 already had, and what 6.3.4 adds. The one thing to know first is that there are two front doors. The <strong>mail protocols</strong> - IMAP, POP3, SMTP submission, CalDAV and CardDAV - take a password: the account's own, an app password, or an OAuth2 bearer token where that is switched on. The <strong>two pages</strong> - the webmail at <code>/portal</code> and the Control Deck at <code>/</code> - take the password as well and, since 6.3.4, a passkey or a sign-in at the organisation's identity provider. Operators upgrading should read the note on passwords with characters above U+00FF first.</p>
<h3><a class="anchor-offset" name="what-6.3.3-already-had"></a>What 6.3.3 already had</h3>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Feature</th>
<th>What it is</th>
</tr>
</thead>
<tbody>
<tr>
<td>A second factor for an account</td>
<td>A TOTP secret on the mailbox. Once one exists, the account's password stops being a credential wherever a code cannot be presented alongside it - every mail client, and the webmail's sign-in - and app passwords are what those use</td>
</tr>
<tr>
<td>App passwords</td>
<td>One per device, shown once, revocable one at a time; they work for directory-linked accounts too and are untouched by password expiry</td>
</tr>
<tr>
<td>A second factor for the administrator</td>
<td>Enforced by the server on every surface - COM, REST, the Control Panel</td>
</tr>
<tr>
<td>The password policy</td>
<td>Length, mixed case, digits, symbols, a common-password list, history and maximum age</td>
</tr>
<tr>
<td>LDAP and Active Directory</td>
<td>Passwords checked against the directory; provisioning from it on a preview-then-apply</td>
</tr>
<tr>
<td>OAuth2 bearer tokens on the protocols</td>
<td>SASL XOAUTH2 and OAUTHBEARER validated against an identity provider's published keys</td>
</tr>
</tbody>
</table>
<p dir="auto">There is no self-service enrolment of an authenticator app in the webmail: an administrator sets and clears the second factor, over COM and the Windows Control Panel. Create the app passwords first and enrol the secret second, or every configured client stops working with no way back in.</p>
<p dir="auto"><strong>The breach check (since 6.3.4).</strong> A password being chosen, never at logon, can be refused because it is in the published corpus of breached passwords; the check is <strong>off by default</strong>, because it sends five characters of the password's SHA-1 hash off the server, and its settings are in <a href="/topic/28241">Hardening a public mail server</a>.</p>
<p dir="auto"><strong>Four more changes in 6.3.4.</strong> The administrator password, which the Windows installer wrote as an unsalted MD5 digest, is written again as PBKDF2 at the first successful administrator sign-in (a file the service cannot write keeps the old value and says so once, <code>HM7494</code>). An account's TOTP secret is sealed at rest once the installation has a stored-secrets key. A mailbox encrypted at rest takes its own password only: no TOTP secret can be enrolled on it, and its app passwords are refused. And a room or a piece of equipment cannot be signed into by any credential.</p>
<h3><a class="anchor-offset" name="upgrading-to-6.3.4-passwords-with-characters-above-u-00ff"></a>Upgrading to 6.3.4: passwords with characters above U+00FF</h3>
<p dir="auto">Before 6.3.4 every password hash was made over a conversion that gave NUL bytes on Windows from the first character above U+00FF on, and <code>?</code> for each such character on Linux, so a password of Cyrillic, Greek or CJK letters could be opened by any other password of its length. Hashes are now made over UTF-8, which leaves every ASCII password's hash as it was. The consequence, item 24 of the CHANGELOG's upgrade list: <strong>a password hash stored before 6.3.4 of a password with a character above U+00FF is refused</strong>, to its own password too, and the application log names the account. An administrator sets such a password again, and soon: until then <code>SCRAM-SHA-256</code>, whose client derives its key itself, still opens such a PBKDF2 hash to the NUL bytes (Windows) or question marks (Linux) it was made over, which the server cannot tell from the password; and on Linux such a hash also still opens to the password written with <code>?</code> for those characters. Every ASCII and Latin-1 password signs in as before.</p>
<p dir="auto">The same release fixed SCRAM-SHA-256 having accepted the account's own password for an account with a second factor enrolled, and a password past <code>PasswordPolicyMaximumAgeDays</code>; each is now a forced failure of the exchange. SCRAM is always the account's own password, since an app password's hash is not one SCRAM can derive from, so such an account cannot sign in over SCRAM at all.</p>
<h3><a class="anchor-offset" name="passkeys"></a>Passkeys</h3>
<p dir="auto">A passkey (schema 6077) is a WebAuthn credential held by the person's own device, such as a phone or a security key. The private half never leaves it; the server keeps the public half. Passkeys are offered on a stock installation, and nobody has one until they enrol it.</p>
<p dir="auto"><strong>Enrolling.</strong> The webmail's <strong>Security</strong> page has a Passkeys card, and the Control Deck has a Passkeys view for the administrator's own. Both ask for the owner's password again, with the one-time code in <code>X-hMailServer-OTP</code> where a second factor is enrolled, or for an account one of its recovery codes in <code>X-hMailServer-Recovery-Code</code>; a request that was itself authenticated with an app password is refused outright. At most ten per owner.</p>
<pre><code>POST   /api/v1/passkeys/options    what the browser needs to make one
POST   /api/v1/passkeys            the credential it made
GET    /api/v1/passkeys            the ones this credential holds
DELETE /api/v1/passkeys/{id}       remove one
</code></pre>
<p dir="auto">Listing and removing are answered while <code>passkeys_enabled</code> is off as well.</p>
<p dir="auto"><strong>Signing in.</strong> <code>POST /api/v1/passkeys/challenge</code> hands out thirty-two random bytes, kept in memory for five minutes and spent on first use; the assertion goes to <code>POST /api/v1/session</code>, the one place this server mints a browser session, so the sign-in gets the same ceilings and a device-list entry marked <code>passkey</code>. Nobody types an address first. <strong>A passkey stands in for the one-time code</strong>: user verification - a PIN, a fingerprint or a face - is required at registration and on every assertion. It carries nobody past an inactive account or domain, the compromised-account hold or the domain's country policy.</p>
<p dir="auto"><strong>Behind several names.</strong> By default the relying party id is the <code>Host</code> header the page was served on. <code>passkey_relying_party</code> set to a registrable parent (<code>example.com</code>) lets a passkey made at <code>mail.example.com</code> work at <code>webmail.example.com</code>. With it set, <strong><code>passkey_allowed_origins</code> must be set too</strong>, comma separated as the browser writes them (<code>https://mail.example.com, https://webmail.example.com</code>); until it is, no passkey is registered or taken.</p>
<p dir="auto"><strong>Not verified:</strong> the attestation statement, so an installation cannot say "only hardware keys of this make". <strong>Not in the backup:</strong> enrol the device again after a restore elsewhere. Registering and removing a passkey is on the audit trail.</p>
<h3><a class="anchor-offset" name="single-sign-on"></a>Single sign-on</h3>
<p dir="auto">Since 6.3.4 a person can sign in at the organisation's identity provider instead, by OpenID Connect or by SAML 2.0 (for a provider such as ADFS that speaks only SAML). Once it is set up, both sign-in pages draw a button: <strong>Sign in with</strong> and the issuer's host name, or <strong>Sign in with SAML</strong>. It opens the webmail and the Control Deck and nothing else: IMAP, POP3 and SMTP still take the password or an app password. <strong>An identity provider that says somebody is an administrator is not obeyed</strong>; the Deck opens as the administrator only for an address listed in <code>oidc_administrators</code> or <code>saml_administrators</code>. There is no logout in either direction, so a person disabled at the provider keeps a session already started here until its own ceiling ends it, which is what the session policies below are for. The settings, the Entra ID, Okta and ADFS entries and the flows are in <a href="/topic/28267">SCIM provisioning and single sign-on</a>.</p>
<h3><a class="anchor-offset" name="account-recovery"></a>Account recovery</h3>
<p dir="auto">Three independent parts (schema 6076): a recovery address and ten one-time codes, which the person sets on the webmail's Security page, and the forgotten-password flow on the sign-in card. Setting either asks for the account's own password again - never an app password - and the second factor where there is one.</p>
<p dir="auto"><strong>What it needs.</strong> <code>account_recovery_enabled</code> is on by default and inert on a stock installation: links are written against the server's public base URL - <code>confidential_base_url</code>, falling back to <code>quarantine_digest_base_url</code> and then the import sign-in's <code>import_oauth_redirect_base_url</code> - and with none set <code>POST /api/v1/password-reset</code> answers 503. <code>recovery_reset_link_minutes</code> (30; 5 to 1440) is how long a reset link lives, and <code>recovery_verify_link_hours</code> (24; 1 to 168) how long the confirmation link lives.</p>
<p dir="auto"><strong>The recovery address</strong> counts for nothing until the link mailed to it is followed, on a page with a button rather than on the GET, which a mail scanner would follow first. It is never answered in full again, to anybody: <code>other@elsewhere.example</code> reads as <code>o....@e.........example</code>. The confirmation is metered at one a minute and six an hour for the address, and again for the asking account, answering 429 with <code>retry_after</code>.</p>
<p dir="auto"><strong>The ten one-time codes</strong> are made together, shown once and stored hashed. A code goes in <code>X-hMailServer-Recovery-Code</code> wherever an authenticator's code is asked for: <code>POST /api/v1/session</code>, <code>POST /api/v1/me/password</code>, <code>POST /api/v1/me/app-passwords</code>, <code>PUT /api/v1/me/recovery</code> and <code>POST /api/v1/me/recovery/codes</code>. The password is verified before the code is looked at, and the code is spent only once the whole attempt has been accepted. Every owner has a budget that needs no setting: <strong>five wrong codes in fifteen minutes, and for the next fifteen every code for that owner is refused</strong> - the right one too - with 429 and <code>code</code> <code>second_factor_locked</code>. And a code is taken once (RFC 6238, section 5.2): an accepted TOTP code is refused if it comes again within its window, without being counted as wrong. A script that sent the administrator's password and a code with every request signs in once instead, or uses an API key.</p>
<p dir="auto"><strong>Forgotten your password?</strong> on the sign-in card posts the address to <code>POST /api/v1/password-reset</code>, which takes no credentials and answers <strong>always the same</strong> - 202 and one sentence, in the same time - whether or not the address is a mailbox with a confirmed recovery address. Since 6.3.7 a request a browser reports as cross-site (<code>Sec-Fetch-Site</code>, or an <code>Origin</code> that is not this server's) is refused, so another site cannot have a current browser drive it. The limits are one request a minute and six an hour per address, and twenty an hour per client address; a loopback peer, such as a reverse proxy on the same machine, is held to thirty in any sixty seconds instead. The link goes to the confirmed recovery address and nowhere else, is good once, and dies after <code>recovery_reset_link_minutes</code>. Completing it applies the password policy, the reuse history and the breach check, <strong>ends every browser session of the account</strong>, tells the mailbox and the recovery address, and raises the <code>account.password-reset</code> alert. A used, expired or unknown token answers 410.</p>
<p dir="auto"><strong>The administrator</strong> can see whether an address is set and confirmed, its masked spelling and how many codes are unused - over REST, on the Deck's <strong>Recovery</strong> card and the Windows Control Panel's <strong>Recovery</strong> tab - and can clear either: <code>DELETE /api/v1/accounts/{address}/recovery</code> and <code>DELETE /api/v1/accounts/{address}/recovery/codes</code>. The administrator cannot read the address in full, read a code, set an address on somebody's behalf or generate codes for them. <strong>A recovery address is exactly as strong as the mailbox that receives it</strong>: a convenience, not a second factor, and an installation that cannot accept that sets <code>account_recovery_enabled</code> to 0. Recovery does not enrol a new authenticator either. Nor does a reset link open encrypted mail: for a mailbox encrypted at rest the link is refused before it is spent and the person is sent to their administrator, and for a zero-access mailbox the password it sets does not open the key, so at the next sign-in the page asks for the previous password or the recovery key.</p>
<h3><a class="anchor-offset" name="devices-and-sessions"></a>Devices and sessions</h3>
<p dir="auto">Every client that has authenticated to an account is one entry of a list: the protocol (<code>imap</code>, <code>pop3</code>, <code>smtp</code>, <code>web</code>, <code>dav</code>, <code>autodiscover</code> or <code>managesieve</code>), the address it came from, the user agent where the protocol carries one, the credential it used, the app password's name where one was used, and when it was first and last seen. IMAP's <code>ID</code> command is deliberately not used, so two mail programs on one machine over IMAP are one entry. At most fifty entries are kept per account.</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Surface</th>
<th>Read</th>
<th>Revoke</th>
</tr>
</thead>
<tbody>
<tr>
<td>Webmail</td>
<td><em>Settings → Security</em>, the <strong>Devices</strong> card</td>
<td><em>Revoke</em> beside the entry; the box under the table adds the app password</td>
</tr>
<tr>
<td>REST, the account</td>
<td><code>GET /api/v1/me/devices</code></td>
<td><code>DELETE /api/v1/me/devices/{id}</code>, with <code>?credential=revoke</code> and <code>?forget=true</code></td>
</tr>
<tr>
<td>REST, an administrator</td>
<td><code>GET /api/v1/accounts/{address}/devices</code></td>
<td><code>DELETE /api/v1/accounts/{address}/devices/{id}</code></td>
</tr>
<tr>
<td>COM (Windows)</td>
<td><code>Account.GetDevices()</code></td>
<td><code>Account.RevokeDevice(id, revokeCredential)</code></td>
</tr>
<tr>
<td>Control Deck</td>
<td>The <strong>Devices</strong> view</td>
<td><em>Revoke</em>, with the app password box</td>
</tr>
<tr>
<td><code>hmctl</code></td>
<td><code>hmctl device ann@example.com list</code></td>
<td><code>hmctl device ann@example.com revoke ID [--credential] [--forget]</code></td>
</tr>
<tr>
<td>PowerShell</td>
<td><code>Get-HmDevice -Address ann@example.com</code></td>
<td><code>Revoke-HmDevice -Address ann@example.com -DeviceId ID [-Credential] [-Forget]</code></td>
</tr>
</tbody>
</table>
<p dir="auto"><strong>What revoking does, exactly.</strong> A browser session's token is invalidated, so the next request on that cookie is 401; a live IMAP, POP3 or SMTP connection is dropped where it stands; and with <code>credential=revoke</code> the app password is deactivated - not deleted, so the person can put it back if the phone turns up. CalDAV, CardDAV and ManageSieve clients have no connection to drop, so revoking one means revoking its app password. <strong>A revoke is not a ban</strong>: a client with a credential that still works signs in again and clears the mark. The account's own revoke is refused (403) to a request that came in on an app password, and an administrator's revoke is recorded in the audit trail. The list, <code>hm_signins</code>, is not in the backup.</p>
<p dir="auto"><strong>The session policies</strong> govern every signed-in browser - the webmail, the portal and the Deck - server-wide and per domain, and are re-read at every request. A mail program's connection is not a session in this sense.</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Policy</th>
<th>Setting</th>
<th>Unset means</th>
</tr>
</thead>
<tbody>
<tr>
<td>Idle timeout</td>
<td><code>session_idle_seconds</code></td>
<td>1800 seconds; at most 2592000 (thirty days)</td>
</tr>
<tr>
<td>Absolute lifetime</td>
<td><code>session_lifetime_seconds</code></td>
<td>43200 seconds, twelve hours; at most 2592000</td>
</tr>
<tr>
<td>One session per device</td>
<td><code>session_one_per_device</code> (a domain's: 0 the server's answer, 1 on, 2 off)</td>
<td>Off</td>
</tr>
<tr>
<td>Bound to the address it began on</td>
<td><code>session_bind_to_address</code> (0, 1 or 2 as above)</td>
<td>Off - it ends a phone's session when it moves from wi-fi to mobile</td>
</tr>
</tbody>
</table>
<pre><code class="language-bash">hmctl settings set --set session_idle_seconds=900 --set session_bind_to_address=true
hmctl domain update example.com --set session_lifetime_seconds=28800 --set session_one_per_device=1
</code></pre>
<p dir="auto">A domain's value wins over the server's where it is set. <strong>Nothing changes on the day you upgrade</strong>: every policy reads 0, which means the ceilings the server has always had. The cookie, <code>hmailsession</code>, is HttpOnly and <code>SameSite=Strict</code>, and <code>Secure</code> whenever the browser came over HTTPS; a proxy that terminates HTTPS in front of the loopback listener should set <code>X-Forwarded-Proto: https</code>. Browser sessions live in the server's memory, so a restart ends every one, and a password change ends every session and drops every connection of the account.</p>
<h3><a class="anchor-offset" name="what-none-of-this-does"></a>What none of this does</h3>
<ul>
<li>It does not reach the mail protocols. A phone's IMAP and SMTP still take the password or an app password, and an account that only ever signs in through its identity provider needs a password set before its person can mint an app password.</li>
<li>No password-less accounts: a passkey sits beside the password, and no policy forbids the password.</li>
</ul>
<h3><a class="anchor-offset" name="where-to-read-more"></a>Where to read more</h3>
<ul>
<li>Wiki: <a href="https://gitlab.com/hmailserver/hmailserver/-/wikis/Sign-in-and-Identity" rel="nofollow ugc">Sign-in and Identity</a>, <a href="https://gitlab.com/hmailserver/hmailserver/-/wikis/Connecting-a-Mail-Client" rel="nofollow ugc">Connecting a Mail Client</a>, <a href="https://gitlab.com/hmailserver/hmailserver/-/wikis/Security-Hardening" rel="nofollow ugc">Security Hardening</a>.</li>
<li>Documents: <a href="https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/PasskeysAndSingleSignOn.md" rel="nofollow ugc">PasskeysAndSingleSignOn.md</a>, <a href="https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/AccountRecovery.md" rel="nofollow ugc">AccountRecovery.md</a>, <a href="https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/DevicesAndSessions.md" rel="nofollow ugc">DevicesAndSessions.md</a>, <a href="https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/PasswordBreachCheck.md" rel="nofollow ugc">PasswordBreachCheck.md</a>, and item 24 of the 6.3.4 section of the <a href="https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/CHANGELOG.md" rel="nofollow ugc">CHANGELOG</a>.</li>
<li>The public documentation: <a href="https://www.progressiverobot.com/hmailserver-documentation/#172-modern-authentication" rel="nofollow ugc">17.2 Modern authentication</a>.</li>
<li>On this forum: <a href="/topic/28255">Connecting a mail client</a> for app passwords on a phone, <a href="/topic/28263">Active Directory and LDAP sign-in</a>, <a href="/topic/28233">encryption at rest</a>, <a href="/topic/28193">Upgrading to 6.3.4</a>, <a href="/topic/28192">The Control Deck</a>, <a href="/topic/28191">The webmail portal at /portal</a> and the <a href="/topic/28218">6.3.4 announcement</a>.</li>
</ul>
]]></description><link>https://www.hmailserver.co.uk/topic/28253/signing-in-passwords-second-factor-passkeys-single-sign-on-recovery-devices</link><generator>RSS for Node</generator><lastBuildDate>Fri, 06 Nov 2026 09:05:49 GMT</lastBuildDate><atom:link href="https://www.hmailserver.co.uk/topic/28253.rss" rel="self" type="application/rss+xml"/><pubDate>Sun, 27 Sep 2026 19:35:13 GMT</pubDate><ttl>60</ttl></channel></rss>