Skip to content

Authentication and identity foundation

This capability note describes the authentication and identity policy boundary used before full Matrix Client-Server login, registration, and device APIs are production-gated.

Included now

  • Matrix-shaped user ID validation.
  • Server-name validation for local identity handling.
  • Device ID validation.
  • Password policy shape for future local-password authentication.
  • Account state model for active, locked, and suspended users.
  • Login policy decisions that fail closed for invalid, locked, or password-disabled accounts; suspended accounts MAY still log in per spec v1.19 §"Account suspension" (the new session is itself suspended and enforced by the request-path gate).
  • Access-token record shape bound to user and device identity.
  • Token hash persistence validation.
  • Token expiry and revocation policy decisions.
  • Constant-time string comparison helper for token-hash checks.
  • Token redaction helper for safe logs.
  • Runtime-wired server-blind E2EE key API route shapes for device keys, one-time keys, fallback keys, cross-signing, signatures, and room-key backup routes.
  • Durable E2EE storage for device keys, one-time keys, fallback keys, cross-signing keys, signatures, backup versions, and backup sessions.
  • /keys/query returns persisted device keys, and /keys/claim consumes one-time keys before reusing fallback keys.
  • Redacted key payload summaries in runtime records and audit events.
  • Client-server registration, password login, refresh-token rotation, logout, global logout, whoami, device listing, single-device fetch, device update, and device delete routes use runtime token validation.
  • POST /refresh does not require access-token authentication — spec: "this endpoint does not require authentication via an access token. Authentication is provided via the refresh token." client_auth_endpoint_requires_access_token excludes login, register_account, and refresh_token.
  • Account 3PID email and MSISDN flows are implemented only through a trusted identity server (AUTH-5). This server cannot send email or SMS, so it cannot validate an address itself and never claims to: the four requestToken endpoints (/account/3pid/{email,msisdn}/requestToken, /register/{email,msisdn}/requestToken) answer 400 M_THREEPID_MEDIUM_NOT_SUPPORTED and create no session unless both id_server (operator-trusted) and id_access_token are supplied, in which case the request is delegated to that identity server and a RegistrationValidationSession keyed by the IS-issued sid is stored. That session starts unvalidated. POST /account/3pid/add, POST /account/3pid/bind and the deprecated POST /account/3pid ask the identity server whether the session was validated (getValidated3pid) and refuse 400 M_SESSION_NOT_VALIDATED unless it reports the same medium and address; see "3PID ownership is proven by the identity server" below. POST /account/3pid/add also enforces password UIA, and the bind/list/unbind/delete endpoints maintain per-account 3PID records including added_at / validated_at metadata plus, for IS-bound 3PIDs, the stored client_secret and sid (part of the v1 schema) needed to drive a mode-2 remote unbind.
  • Access-token hashes are durable and hydrate back into runtime sessions after restart, with their expiry (before 0.12.13 hydration dropped expires_at, so every access token was valid forever after a restart).
  • Refresh rotation follows the spec (0.12.13, ADR-0074). POST /refresh does not revoke the presented refresh token: "The old refresh token remains valid until the new access token or refresh token is used, at which point the old refresh token is revoked", so a client whose response was lost can retry. The new access and refresh tokens record the refresh token they replaced (predecessor_refresh_hash / predecessor_hash, part of the v1 schema); the first successful use of either revokes it (authenticated_user for the access token, refresh_local_session for the refresh token). A retry with the same token supersedes the unused earlier pair. Presenting a refresh token that has been invalidated — by a completed rotation, logout, or a password change — revokes the device's whole session (spec SHOULD) and writes an auth.refresh.reuse_detected audit row.
  • Refresh-token hashes are persisted, rotated, and revoked on single-device logout, global logout, device deletion, or password change with logout_devices: true (spec default) without storing plaintext token material; the caller's own device is preserved. Single-device POST /logout revokes the device's refresh token as well as its access token (fixed 0.12.7). Before that fix, logout revoked only the access token and flipped the in-memory session, leaving the refresh token usable — refresh_local_session admits any refresh row that is merely unrevoked, so the client that had just logged out, or anyone holding a stolen refresh token, could immediately mint a fresh access token and logout was cosmetic. logout_all, device deletion, and password change already revoked refresh tokens; single-device logout was the one path that did not. Password change with logout_devices: true revokes every other device directly, rather than revoking everything and restoring one (fixed 0.12.7). The prior sequence — revoke every token for the user, then UPDATE access_tokens SET revoked = false WHERE user_id AND device_id for the caller's own device — could not distinguish a token it had just revoked from one revoked days earlier by a logout, an admin action, or a previous password change, so it resurrected all of them. A password change is precisely the action a user takes after discovering a compromise, so this handed the attacker's revoked token back. database::revoke_tokens_for_ user_except_device replaces the revoke-then-restore pair: the caller's device is never revoked in the first place, so there is nothing to reinstate. See ADR-0052 and docs/database-persistence.md — a credential that has ever been revoked must never authenticate again, and no store function may clear a revoked flag to make that true.
  • Account lock/suspend admin endpoints GET/PUT /_matrix/client/v1/admin/lock/{userId} and /_matrix/client/v1/admin/suspend/{userId} (admin-gated, anti-enumeration, locality and self/other-admin guards) set the persisted and in-memory account state. The request path enforces spec semantics without revoking sessions: a locked account gets 401 M_USER_LOCKED with soft_logout: true on all endpoints except /logout and /logout/all; a suspended account gets 403 M_USER_SUSPENDED on actions outside the spec allowlist. Locked takes precedence over suspended.
  • Login failures for unknown users, wrong passwords, and locked accounts collapse to the same external invalid login result while still recording the internal rejection reason in audit logs.
  • Newly issued access and refresh tokens require the current master-key-derived token-hash:v4: keyed digest. A missing, unreadable, insecure, invalid or unlockable master key, including failure to allocate a locked copy of a cached key, makes issuance return HTTP 503 without adding a session or refresh row. New issuance never falls back to unkeyed v2 or legacy v3. Legacy v2 validation and the existing master-key-derived v3 validation format remain separate; accepted legacy access tokens may still be upgraded to v4. Breaking (v0.10.9): pre-#322 token-hash:v3: hashes used the Ed25519 signing seed and cannot validate under the later, domain-separated master-derived v3 key. Those sessions must re-login. The 0.12.26 storage hardening does not change any of the existing derivation labels, BLAKE2b inputs or persisted digest formats; see ADR-0128.
  • The v3 and v4 HMAC keys are derived together and cached (token_hmac_keys, src/homeserver/auth_service.cpp). Cache identity includes path, device, inode, size, modification/change timestamps (with nanoseconds), mode and owner; an insecure or missing file cannot reuse a cache entry. Every returned copy has its own locked allocation. Allocation failure returns no keys rather than throwing through credential issuance.
  • redacted_token_for_log() (issue #437) discloses only a coarse size bucket (tiny/short/medium/long), never the exact byte length — the precise length was a minor side channel that let an observer of logs distinguish token versions and valid- from invalid-length presented tokens.
  • Client-server auth/device/key actions append durable audit rows without logging plaintext credentials, bearer tokens, or key payloads.
  • GET /_matrix/client/v1/auth_metadata (MSC2965 OIDC discovery) returns RFC 8414 / Matrix v1.19 authorisation server metadata when server.oidc.* is configured; returns 404 M_UNRECOGNIZED when OIDC is not configured. No OAuth 2.0 authorization-code / token / revocation flow is implemented yet.
  • Application Service API identity (Matrix v1.19, merovingian::appservice). Registration files (appservice.registration_files, parsed as JSON) build an in-memory AppserviceRegistry at startup; as_token/hs_token are held in a mlocked core::SecretBuffer and only ever compared via crypto::constant_time_equal_variable_length. A client-server request bearing a registered as_token authenticates as the appservice's own sender_localpart user by default, or as the user named by ?user_id= when that user falls within one of the appservice's users namespaces (403 otherwise); ?device_id= must name a device already known to belong to the asserted user (400 M_UNKNOWN_DEVICE otherwise). This masquerade is resolved exactly once, at the top of handle_client_server_request_impl, by substituting req.access_token with an internal token (appservice::encode_masquerade_token, appservice/masquerade_token.hpp) that authenticated_user/ authenticated_session/authenticated_admin_user in auth_service.cpp recognise and re-validate — never a real DB-backed session row, and never sent or logged. A raw client-presented token already in that internal shape is rejected before any auth logic runs, so the format cannot be forged from outside the process. POST /register and POST /login accept type: m.login.application_service (bearer-authenticated with as_token, restricted to the appservice's own namespace): registration is passwordless and bypasses UIA entirely per spec, and login mints an ordinary session with no password check, sharing the existing token-issuance path (complete_login) with m.login.password. An appservice's exclusive namespace blocks registration/alias creation by anyone else (M_EXCLUSIVE) — enforced in the registration and PUT /directory/room/{roomAlias} / POST /createRoom handlers. The same rule binds other appservices: /register with m.login.application_service into another service's exclusive users namespace is 400 M_EXCLUSIVE (the /register response table's status), /login with that type is 403 M_EXCLUSIVE, and asserting such a user via ?user_id= is 403 M_EXCLUSIVE (AUTH-7). Identity assertion also requires the asserted user to be registered and not deactivated — otherwise 403 M_FORBIDDEN (AUTH-12); a service creates its virtual users with /register first, and its own sender_localpart user is created at startup, so acting as the sender always works. Assertion does not apply to Account Management (AUTH-8): every /account/* endpoint except GET /account/whoami (password change and its requestToken calls, deactivate, and the whole 3PID family) answers 403 M_FORBIDDEN to an as_token request, with or without ?user_id= (appservice::is_account_management_endpoint, checked in handle_client_server_request_impl under the request lock). Outbound delivery to appservices is wired: appservice::AppserviceClient (src/appservice/appservice_client.cpp) implements send_transaction (PUT /_matrix/app/v1/transactions/{txnId}), query_user, query_room_alias, and the /thirdparty/* queries. Delivery is a durable queue (ADR-0163): enqueue_appservice_delivery (src/homeserver/appservice_delivery.cpp) writes one appservice_outbox row per namespace-interested appservice at event time and makes no network call; one dedicated thread (drain_appservice_queues, run by a PeriodicJob started in main.cpp) sends them oldest first, one event per transaction, tracked by the per-appservice persisted cursor. A transaction that fails is retried under the same txn_id with the same event, with exponential back-off (1 s doubling to 5 min), before anything newer is sent, even when no further room traffic arrives. query_user/query_room_alias are invoked from src/homeserver/client_server.cpp when a lookup misses local state.
  • Unit coverage for identity validation, account lock/suspension behavior, password policy, token activity, and log redaction.
  • Registration token verification using Argon2id (crypto_pwhash_str / crypto_pwhash_str_verify); only the password hash is retained, and the plaintext token is zeroised after hashing. The GET /_matrix/client/v1/register/m.login.registration_token/validity endpoint verifies the candidate through the same Argon2id hash (registration_token_matches) rather than comparing plaintext, so the token material never sits on the request path.
  • POST /register without username gets a generated localpart, as the spec requires: 16 lowercase hex characters from the CSPRNG, redrawn if reserved, already registered or inside an application service's namespace, then checked like a chosen name (0.12.38, conformance audit R12, ADR-0198). An empty username is still 400 M_INVALID_USERNAME, and m.login.application_service registration still requires one.
  • Server-side access and refresh token expiry is enforced. PersistentAccessToken, PersistentRefreshToken, and LocalSession carry an expires_at field set at issuance from configurable security.access_token_lifetime_ms (default 1h) and security.refresh_token_lifetime_ms (default 30d); 0 disables expiry for that kind. Configuration parsing and typed validation report field-specific findings for negative values and values above the project maxima: 2,592,000,000 ms (30 days) for access and 31,536,000,000 ms (365 days) for refresh. Zero and defaults retain their meanings; signed parsing preserves the exact configured value, including a representable value rejected by policy. Stored expiry values, refresh predecessor links and historical token digests remain unchanged. The upper-bound regression reproduced 20 failed rejection checks with 126 passing controls; focused upper-bound GREEN passes 146 assertions / 1 case and related normal configuration units pass 991 / 177, with 61 sources stable during execution. Independent source review passed without findings; parent checks bind the reviewed production, regression and registration to executed GREEN. Fresh full acceptance passed 56 targets with all return codes zero, no failures/timeouts and 170 stable files including all 61 focused sources. Parent independently inspected fresh archived JSON/text and current reviewed-source bindings before accepting the bounded upper-admission slice. Only status docs changed afterward. ADR-0141 records these project limits, not Matrix maxima. Checked timestamp arithmetic, direct typed runtime snapshot/reload admission and broader persisted-expiry hydration remain separate OPS-4 requirements. find_session and the refresh-token lookup reject expired tokens (failing closed toward re-login/refresh) even when the session is not revoked. For refresh-enabled /login and /refresh, a positive configured access lifetime is advertised as expires_in_ms; zero omits this optional field, whose absence means no expiry in Matrix v1.19. Actual SQLite/public-dispatcher conformance covers both responses, finite-lifetime controls, usable issued tokens and legacy non-refresh login. Bounded independent source review passed without findings. Current-source conformance again passed 223 / 1 and related auth 243 / 4; the fresh 56-target full checkpoint also accepts this bounded response repair. Registration-response expiry is not covered. Legacy/no-expiry rows (expires_at empty / nullopt) remain valid.
  • Token-hash lookups route through constant-time comparison (crypto::constant_time_equal / auth::constant_time_equal, backed by sodium_memcmp) on every fixed-length hash match — the access/refresh store lookups and the in-memory session match — not just the canonical policy helper.
  • Device-list stream tokens and cross-device key update semantics: build_device_list_arrays() populates /sync's device_lists.changed/left from store.device_list_changes filtered by since_sync_stream_id — through sync::collect_device_list_delta(), which reports each subject user once however many change rows the range covers, since both fields name users rather than change events — and broadcast_device_list_updates() emits m.device_list_update EDUs on cross-signing/key changes. Inbound m.device_list_update and m.signing_key_update EDUs record a change only for local users who share a joined room with the subject, one row per (observer, subject), written as one batch (audit FED-7). Per-algorithm device_one_time_keys_count is wired into /sync as well. A device_lists.changed self-notification is recorded at two trigger points so a user's own devices discover each other without relying on room co-membership: on POST /_matrix/client/v3/keys/upload (key upload) and on POST /_matrix/client/v3/login when a genuinely new device is created. The login trigger ensures a freshly-logged-in device's initial /sync lists its own user in device_lists.changed, prompting it to /keys/query and fetch the user's existing devices' keys before any m.key.verification.request can arrive — closing the cross-device verification gap where the new device otherwise dropped the request for lack of the sender's device keys.
  • Cross-signing across federation (0.12.10). A client shows a device as verified when it is signed by its owner's self-signing key, and a user as verified when the viewer's own user-signing key has signed that user's master key. Every link in that chain now crosses servers:
  • Signatures are merged in one place. federation/key_signatures.hpp merges the signatures uploaded through POST /keys/signatures/upload into a published key. Every path that serves keys uses it: client /keys/query, federation /user/keys/query and /user/devices/{userId}, and the keys of outbound m.device_list_update.
  • Visibility (ADR-0060). An upload by the key's owner is visible to everyone. Any other upload — a user-signing signature over someone else's master key — is visible only to the user who made it, and to no remote server. From each upload only the uploader's own signer entry is merged, so nobody can publish a signature under another user's name.
  • Key IDs follow the spec's upload form. A device key is addressed by its device ID; a cross-signing key by its unpadded base64 public key, with no ed25519: prefix (cross_signing_key_id()). Signatures on master keys used to be looked up with the prefix and were never found.
  • Remote users' keys are proxied, not cached (ADR-0061). /keys/query for a remote user calls the user's server's /user/keys/query and returns its device_keys, master_keys and self_signing_keys. accept_remote_key_query_response() keeps only the users that were asked about, and only keys that describe the user they are filed under; everything else is dropped and logged as remote_key_query.entries_dropped. The requester's own user-signing signature over the remote master key is then merged back in, since it was uploaded here and never reached the remote server.
  • m.signing_key_update, both ways. Uploading a master or self-signing key sends the EDU, carrying those two keys with the owner's signatures, to every server the user shares a room with. An upload of only the private user-signing key sends nothing. An inbound EDU whose user_id belongs to the sending server puts that user in local users' device_lists.changed, the same as m.device_list_update, so clients re-query them.
  • Key-backup retrieval and deletion: handle_key_api_route() implements get_key_backup_version, get_key_backup_version_by_id, get_room_key_backup (session/room/batch), get_room_key_backup_batch, delete_room_key_backup, delete_room_key_backup_room, and delete_room_key_backup_batch, all reading/writing real session rows from persistent_store.key_backup_sessions.

Federation worker request identity

For worker-relayed EDUs, encrypted IPC authenticates the worker, not the remote federation origin. Main retains an owned copy of its signature-verified origin on the exact channel's pending request. After taking the runtime lock, EDU admission requires that parent to remain live, non-ready and nonexpired on a running, healthy channel, with exact origin equality. Ordinary requests carry no such authority. A parent ID is a routing reference, not a capability or proof that an EDU belonged to the signed request body. Already admitted operations may finish after parent completion; no persisted identity or derivation changes. Normal focused regressions and the legitimate separate-worker control pass; independent review and fresh combined acceptance remain pending. Main and worker must restart together. See ADR-0142.

Identity server client (3PID invites)

A dedicated identity module (include/merovingian/identity/identity_client.hpp, src/identity/identity_client.cpp) owns the outbound Identity Service API client. IdentityServerClient exposes store_invite, lookup, bind, unbind, and requestToken methods plus pure helpers (parse_identity_server_url, build_*_body, parse_store_invite_response, parse_lookup_response). It talks to a remote identity server over http::OutboundClient combined with federation::CachedServerDiscovery, and is SSRF-safe: resolved addresses are pinned and private/loopback ranges are rejected via deny_ip_ranges.

The homeserver is a client of the identity server, not a federation peer: authentication uses a bearer id_access_token supplied by the calling client, never X-Matrix. See spec v1.19 Identity Service API.

POST /_matrix/client/v3/rooms/{roomId}/invite with id_server/medium/ address/id_access_token now delegates token and key generation to the IS via store-invite and builds the m.room.third_party_invite event from the IS-issued token (used as the state_key) plus the IS's ephemeral public key (top-level public_key/key_validity_url) and the full public_keys array. Per spec v1.19 client-server-api §"Third-party invites" and the SignedThirdPartyInvite shape, the token MUST be issued by the identity server at /store-invite: the join-side third_party_signed signature is verified against the IS's key, so the previous local-only token minting was non-conformant. The HS fails closed with 403 M_FORBIDDEN when the supplied id_server is not listed in server.identity_server.trusted_servers, and returns 502 on transport error or a malformed IS response.

The server.identity_server.* config block (trusted_servers, default_server, allowed_bind_domains, connect_timeout_seconds, total_timeout_seconds) is hot-reloaded and marked restart-required for trust-set changes. 3PID bindings are persisted in the account_threepids table (part of the v1 schema); IS-bound bindings additionally store the client_secret and sid needed for a remote unbind.

bind / unbind / requestToken IS delegation (v0.11.10)

The bind, unbind, and requestToken handlers now drive the remote IS when a trusted id_server is supplied, not just persist locally:

  • requestToken (/account/3pid/email/requestToken, /account/3pid/msisdn/requestToken, and the register/email, register/msisdn variants) — when both id_server and id_access_token are present and id_server is trusted, the HS resolves the trusted base URL, calls IdentityServerClient::request_email_token / request_msisdn_token, and on a 200 with an IS-issued sid stores a RegistrationValidationSession keyed by that sid (purpose register / account-3pid). The IS is the validation authority: it contacts the user by email/SMS and (optionally) redirects via next_link, so Merovingian has no client-facing submitToken. Absent id_server / id_access_token the request is refused 400 M_THREEPID_MEDIUM_NOT_SUPPORTED (before AUTH-5 it minted a local session that was already marked validated and sent nothing). Fail closed with 502 on transport error or a malformed IS response; 403 when id_server is not trusted; 400 M_BAD_JSON when only one of the pair is supplied. The client's send_attempt is forwarded in the IS request body (R09; the IS "will only send an email if the send_attempt is a number greater than the most recent one which it has seen"), and an id_access_token that is not printable ASCII without spaces of at most 4096 bytes is 400 M_INVALID_PARAM on this, the bind, the deprecated /account/3pid and the 3PID invite routes, before any IS call (identity::id_access_token_is_valid, sp02, ADR-0180): it becomes the Authorization header of the IS call.

  • bind (/account/3pid/bind, plus the deprecated /account/3pid) - after the identity server has confirmed the session validated (below) and the id_server is trusted, the HS calls IdentityServerClient::bind with the runtime mutex released for the network call, then persists bound=true, id_server, client_secret, and sid. A bind that names an id_server this server does not trust is refused 400 M_SERVER_NOT_TRUSTED; before AUTH-5 it was recorded locally as bound although no identity server had been asked. /account/3pid/add adds the 3PID to the account without an identity-server bind, but needs the same confirmation.

  • 3PID ownership is proven by the identity server (AUTH-5). confirm_validation_session (client_server.cpp) is the single gate for /account/3pid/add, /account/3pid/bind and /account/3pid. It calls IdentityServerClient::get_validated_3pid (GET /_matrix/identity/v2/3pid/getValidated3pid?sid=&client_secret=, bearer id_access_token) against the trusted identity server stored with the session, with runtime.mutex released, and only marks the session validated (and records the IS-reported validated_at) when the answer is a 200 whose medium equals the session's and whose address matches the session's (emails case-insensitively, MSISDNs by their digits). Everything else refuses and binds nothing: IS 400/404 -> 400 M_SESSION_NOT_VALIDATED; a different address or medium -> 400 M_SESSION_NOT_VALIDATED; a transport failure or non-200 -> 502 M_UNREACHABLE; a 200 that does not parse -> 502 M_UNRECOGNIZED. The session is looked up again once the lock is re-taken (it may have expired or been consumed meanwhile). A validated 3PID is added or bound, and the session is then erased, so a sid cannot be replayed. The session holds the identity server's base URL and the caller's id_access_token in memory only, for the session's lifetime (the 15-minute validation TTL, or until it is consumed); the token is overwritten when the session is dropped and is never persisted or logged. A MSISDN the identity server reports in a different numeral form than the client sent (for example with the country code expanded) does not match and is refused; that fails closed rather than guessing.

The registration flow has no identity stage: /register advertises only m.login.registration_token, so the register/*/requestToken sessions are recorded unvalidated and nothing consumes them.

  • unbind (/account/3pid/delete, /account/3pid) — the client sends no secret (spec-correct), so the HS recovers client_secret + sid from the stored binding and calls IdentityServerClient::unbind with an empty id_access_token. Per the IS API v2 this is unbind auth mode 2 (client_secret + sid, no bearer) — build_unbind_body carries no Authorization header. Transport failure fails closed with 502: silently removing the local binding while the IS still holds it would orphan the IS-side binding (the local client_secret/sid would be gone, making a retry impossible). A non-2xx IS response other than a recognised "no-support" shape also surfaces 502 so the user may retry; id_server_unbind_result reports "success" on 200 and "no-support" when the IS declines, removing the local record either way per spec (an operator trust-set change must not strand the 3PID). When the stored binding has no client_secret/sid, the handler falls back to the existing local-only threepid_unbind_result logic.

All three flows reach the IS through the test_forced_identity_resolution discovery seam (see docs/http-transport.md), which is empty in production — production traffic resolves via the SSRF-safe CachedServerDiscovery path.

OpenID tokens

POST /_matrix/client/v3/user/{userId}/openid/request_token (Matrix v1.19 CS API §OpenID) mints a narrow, short-lived bearer credential whose only valid use is GET /_matrix/federation/v1/openid/userinfo (SS API §OpenID), which a third-party service calls to learn the caller's Matrix user ID. This is a fundamentally different trust level from an ordinary access token — an access token authenticates the full client-server API; an OpenID token must authenticate nothing beyond "who is this user" to one federation endpoint — so it has its own, deliberately separate, lifecycle:

  • Separate table. OpenID tokens live in openid_tokens (part of the v1 schema), never access_tokens. See docs/database-persistence.md for the schema.
  • Separate mint path. homeserver::request_openid_token (src/homeserver/auth_service.cpp) is the only function that writes to openid_tokens. It reuses the same keyed-hash machinery (issue_token_hash, preferring the master-key-derived v4 HMAC, falling back to v3/v2) access tokens use — sharing a well-reviewed hash function is not the same as sharing a trust boundary, and reusing it avoids a second, less-reviewed hashing path.
  • Separate lookup path. homeserver::federation_openid_userinfo is the only function that reads openid_tokens. The ordinary client-server auth gate (authenticated_user, which backs every Authorization: Bearer check) only ever consults access_tokens/database.sessions and never looks at openid_tokens. Symmetrically, federation_openid_userinfo never consults access_tokens/sessions. Neither lookup path can accidentally accept the other token kind, because neither path ever reads the other table — the separation is structural, not a runtime check that could be bypassed or forgotten at a new call site.
  • Always finite expiry. One hour from mint, hardcoded (not operator-configurable, unlike access_token_lifetime_ms): a longer-lived OpenID token still cannot reach the client-server surface, so the usual "shorten this to reduce blast radius" tradeoff for access tokens does not apply the same way here.
  • Fail-closed, indistinguishable rejection. GET /openid/userinfo returns the identical 401 M_UNKNOWN_TOKEN / "Access token unknown or expired" body whether the presented token was never issued or has expired — matching the spec's own error shape — so a caller cannot use the response to distinguish the two cases.
  • Unauthenticated redeem endpoint. Per spec, GET /openid/userinfo requires no X-Matrix request signature and is not rate-limited: the caller may be any third-party service, not necessarily a homeserver. It is therefore dispatched outside the federation module's signed-request path entirely (src/homeserver/local_http_router.cpp, federation_openid_userinfo_response), alongside the same-shaped bypass for GET /_matrix/key/v2/server.
  • Retention. store_openid_token sweeps every already-expired row (across all users) on each insert, so openid_tokens cannot grow without bound — see docs/database-persistence.md.

See docs/threat-model.md ("OpenID token confusion") for the privilege-escalation risk this separation exists to prevent.

SSO login

Matrix v1.19 CS API §"Client login via SSO" describes a six-step flow: a client discovers m.login.sso via GET /login, sends the user's browser to GET /login/sso/redirect[/{idpId}], the homeserver redirects to an external SSO system, that system authenticates the user and hands control back to the homeserver, the homeserver mints a short-lived login token and redirects the browser to the client's redirectUrl with ?loginToken=..., and finally the client exchanges that token for an access token via POST /login with type: m.login.token.

Merovingian implements every homeserver-side piece of that flow except the external SSO protocol itself (CAS/SAML/OIDC) — integrating a specific external protocol is left to the operator's own SSO gateway, configured via server.sso.authorization_url. What is implemented and enforced:

  • Config surface (config::SsoConfig, server.sso.*): enabled (default false), authorization_url (the external SSO system's HTTPS entry point), identity_providers (a list of {id, name, icon?, brand?} — the spec's IdP shape, parsed from server.sso.identity_providers. <idpId>.<name|icon|brand> keys, <idpId> itself carrying the id), and redirect_url_allowlist (HTTPS URL prefixes a client's redirectUrl is validated against). Parse-time validation (config::validate_config) fails closed: enabled = true with an empty authorization_url or an empty redirect_url_allowlist is rejected outright, as is a duplicate or empty identity-provider id, an empty name, or a non-mxc:// icon.
  • GET /login flow advertisement: m.login.sso (with an identity_providers array when any are configured) is advertised only when homeserver::sso_is_configured holds — the same fail-closed condition the redirect endpoints check, shared through one function so the two paths cannot drift apart into "advertised but half-served" or vice versa.
  • GET /login/sso/redirect[/{idpId}] (homeserver::sso_redirect_target): validates redirectUrl against redirect_url_allowlist (see docs/threat-model.md, "Open redirect and login-token exfiltration via SSO redirectUrl" — this is the control that keeps the endpoint from being an open redirect), validates idpId against the configured identity_providers when present (404 M_NOT_FOUND for an unrecognised IdP, per spec), and on success responds 302 with Location set to authorization_url carrying idp/action/redirectUrl query parameters through to the operator's external SSO system.
  • Completing the round trip (homeserver::complete_sso_login): the integration point an operator's external SSO adapter calls once it has authenticated the user and mapped them to a local Matrix user id (spec steps 1-2 of "Handling the callback from the Authentication server" — identity mapping and, where applicable, JIT registration — are the adapter's responsibility, not Merovingian's, since they are inherently specific to the external protocol in use). It re-validates redirectUrl against the allowlist (never trust a value handed across an integration boundary a second time without re-checking it), mints a login token via the same issue_token_hash machinery access tokens use, persists it to the dedicated login_tokens table (never access_tokens — see "OpenID tokens" above for why that separation matters, and docs/database-persistence.md for the schema) with a fixed ~30-second expiry (spec: "SHOULD be limited to around five seconds" — Merovingian uses a slightly larger fixed window to tolerate real-world redirect latency without weakening the single-use guarantee that actually bounds the exposure), and returns redirectUrl with any pre-existing loginToken query parameters stripped (per spec step 4) and the new one appended.
  • POST /login with type: m.login.token (homeserver::redeem_login_token): consults only the login_tokens table — deliberately independent of authenticated_user/find_session, mirroring federation_openid_userinfo's structural separation from the access-token store — and consumes the token atomically (database::consume_login_token) so it cannot be redeemed twice. On success, session issuance (device-id validation, the account lock/suspend gate, access-token minting and persistence) is shared with password login via homeserver::login_local_user_by_id, the second half of login_local_user factored out so both callers get the exact same account-state enforcement — SSO login cannot bypass a locked or suspended account's gate just because it skipped the password check. Unknown, expired, and already-used tokens are indistinguishable to the caller (403 M_FORBIDDEN), matching how password login already collapses "unknown user" and "bad password" into the same external result.

Account deactivation

POST /_matrix/client/v3/account/deactivate (Matrix v1.19 CS API §"Account deactivation") is implemented. Previously this endpoint had no handler at all — the path appeared only as a literal in the suspended-user allowlist — so a user whose credentials were compromised had no way to close their own account; only an admin-initiated lock or suspend existed, and both of those are reversible where deactivation is not.

  • UIA, exactly as POST /account/password: the request must carry a completed m.login.password auth stage proving current knowledge of the password. A missing, incomplete, or wrong-credential auth block returns the 401 UIA challenge, never 403.
  • homeserver::deactivate_local_user (src/homeserver/auth_service.cpp) marks the account permanently deactivated, revokes every access and refresh token for the user — including the caller's own, unlike a password change, since there is no session left worth preserving — and overwrites the password hash with the literal !deactivated, a value auth::password_matches can never match, so a credential leak predating deactivation cannot be replayed even if a future change were to soften the login gate.
  • The users row is retained, not deleted: deactivated is a column of users (part of the v1 schema — see docs/database-persistence.md), not a row removal, so registration's existing duplicate-user_id check keeps rejecting a re-registration of the same localpart.
  • Both login gates check it first. complete_login's shared tail and the token-login path in login_local_user_by_id (src/homeserver/ auth_service.cpp) each reject user.deactivated with 403 "account deactivated" before evaluating the reversible locked/suspended states — deactivation outranks them because it is permanent where they are not.
  • 3PIDs are unbound from their identity servers. unbind_threepid_for_deactivation (src/homeserver/client_server.cpp) walks every bound 3PID the account holds and drives IS unbind auth mode 2 with the stored id_server + client_secret + sid (stored in the v1 schema), through the same resolve_trusted_identity_base_url SSRF gate as POST /account/3pid/unbind. The local binding is cleared either way.
  • id_server_unbind_result, required in the 200 response body, is "success" only when every identifier was unbound — and when there were none to unbind — and "no-support" when any could not be: no identity server recorded against the binding, operator trust withdrawn for the one that holds it, or the identity server unreachable or refusing.
  • A failed unbind does not fail the deactivation. This is the opposite disposition to POST /account/3pid/unbind, which fails closed and keeps the local binding so the user can retry. Deactivation is irreversible and leaves no owner to retry with, so it completes and reports no-support rather than stranding the account half-closed. The spec models a partial unbind as a 200 carrying no-support, not as a failed deactivation.
  • Irreversible by design: there is no reactivate endpoint.

Not yet done, tracked in docs/todos/capability-gaps.md: the request body's erase parameter is parsed as part of the JSON body but never inspected, so it has no effect either way — the spec's erasure behaviour is a SHOULD, not a MUST, so this is not a conformance violation, but a client that explicitly asks for erasure gets the same silent no-op as a client that does not. The account's rooms are also not left on its behalf.

Argon2id admission

Argon2id is memory-hard, so one request can pin a core and tens of MiB for tens of milliseconds. Every place a client request reaches it shares one concurrency budget, HomeserverRuntime::argon2id_admission (auth::Argon2idAdmission), and none of them holds runtime.mutex while it hashes:

Call site Admission Re-validated after the lock is re-taken
/login (login_local_user) slot, else 429 user still exists and stored hash unchanged
Ordinary and appservice registration (make_user) slot, else 429 username still free, else the duplicate-username error
Registration token check slot, else 429 none: no runtime state is used
POST /account/password (change_local_user_password) slot, else 429 account exists, stored hash unchanged since the request started, access token still valid; otherwise refused with 403 and nothing written
UIA password check (verify_local_user_password) slot, else 429 account exists, stored hash unchanged, access token still valid; otherwise not verified

A refusal for lack of a slot is 429 M_LIMIT_EXCEEDED carrying retry_after_ms, answered before any hash work. It changes nothing and is not a failed attempt: it counts toward neither the login throttle nor the UIA throttle. A stale result (the password changed while it was being verified) is also not counted as a wrong guess, since the guess may well have been right for the hash it was checked against. Registration checks the username before taking a slot, so a request that cannot succeed never costs a hash.

Failed-login throttle

/login is throttled per source IP by the runtime rate limiter, but that limiter's per-user tier keys on the authenticated user, who does not exist before a login succeeds (see docs/http-transport.md "Rate-limit policy"). Guesses against one account spread across many source IPs would therefore accumulate against nothing. login_local_user (src/homeserver/auth_service.cpp) counts failed password logins against the claimed user ID, whether or not that user exists, so the throttle cannot be used to probe which accounts are real.

What is counted, and what is refused

Failures are counted twice, in two bounded tables on the runtime:

Counter Keyed on Default limit Refuses
per source (account, client source) 5 failures in 15 minutes password logins for that account from that source only
per account ceiling account 50 failures in 15 minutes password logins for that account from every source

The client source is the same key the HTTP rate limiter uses (rate_limit_client_key): it honours server.trusted_proxies and groups IPv6 clients by server.http.ipv6_client_prefix_length. Behind a reverse proxy that is not listed in server.trusted_proxies every client shares the proxy's address, so the per-source counter degrades to a per-account counter: configure trusted_proxies.

Consequences, which are the point of the design:

  • A stranger who fails five logins as @alice locks @alice out from the stranger's address, not from her own. Alice, logging in from another address, is not affected.
  • Guessing spread over many addresses stops at the per-account ceiling. Past it, every password login for that account is refused, the account owner's included, until the window ends. This is the standard account-lockout trade-off, now reachable only by an attacker who can make 50 failed logins inside one window rather than 5.
  • A refused login is 429 M_LIMIT_EXCEEDED carrying retry_after_ms. It is refused before the password is checked, so a refused attempt neither counts nor spends Argon2id time, and a correct password during a lockout is still refused.
  • A refusal that comes from load shedding (the Argon2id admission limit) is not a failed login and is not counted.

A window opens at a key's first failure. When the count reaches the limit the window restarts at that failure, so the key stays refused for one full window from the failure that tripped it. A key whose window has elapsed forgets its failures. A successful login clears only the per-source counter for the source it came from. It does not clear the per-account ceiling: otherwise an attacker could spend 49 guesses, wait for the owner to log in, and start again.

All three numbers are configuration, and a change is reloadable: security.login_throttle.max_failures_per_source (default 5, 1 to 1000), security.login_throttle.max_failures_per_account (default 50, 1 to 100000, not below the per-source limit) and security.login_throttle.window (default 15m, 1s to 1440m). See docs/user-manual.md.

Bounded memory (AUTH-10)

Each table (auth::FailureWindowTable, include/merovingian/auth/failure_window_table.hpp) holds at most 100 000 entries. A key is a fixed-size BLAKE2b-256 digest of the counter kind and its parts, so a user ID chosen to be 60 KiB costs the same 32 bytes as a short one, and the account is length-prefixed so two different (account, source) pairs cannot hash alike. Entries sit in a list ordered by the time their window opened, which is also the order they expire in, so expiry pops the front: each failure and each check is amortised O(1), never a scan of the table. When a table is full, a new key evicts the oldest entry. The cap bounds memory against a flood of distinct claimed user IDs; it means a flood of more than 100 000 distinct IDs inside one window can push an earlier counter out early, which the per-IP HTTP rate limit (20 requests a minute at the auth tier) makes expensive. The tables are guarded by HomeserverRuntime::mutex.

User-interactive auth is separate

verify_local_user_password (src/homeserver/auth_service.cpp) is the password stage of every UI-Auth (UIA) flow: password change, account deactivation, adding a 3PID, cross-signing key upload, and single/bulk device deletion. It is reached only with a valid access token, so it has its own counter, keyed on (account, device) with the same per-source limit and window. Two properties:

  • Unauthenticated login failures never touch it. A stranger failing logins as @alice cannot stop her changing her password, deactivating, or deleting a device from a session she is already signed in on. (Sharing the counter, as an earlier version did, allowed exactly that.)
  • UIA failures never feed the login counters. A stolen access token still has a bounded password-guessing budget through UIA (the fifth failed check on that device refuses the sixth with 429 M_LIMIT_EXCEEDED and Retry-After), but spending it does not lock the owner out of logging in.

A correct password clears the device's UIA counter.

Single-device management selects the device ID from the HTTP path before ?; unrelated query text cannot become part of that identity. Password-UIA DELETE retains the same authorization requirement and revokes the selected device's sessions while preserving other devices. Real SQLite regressions exercise public device login, the missing-proof challenge, successful deletion and reopened device/session state.

Audit

A refused login writes login.throttled, a failed one login.rejected. Both can be triggered by anyone, so both pass through observability::AuditRateGate (10 durable rows per kind per 60 seconds; the rest are counted and reported as suppressed=<n> on the next row, and the diagnostic log line is still written for every attempt). See docs/observability-audit.md.

Security posture

The core auth policy module deliberately stays free of cryptographic password hashing, token generation, and random number generation. The local homeserver runtime now performs those operations through the reviewed LibSodium-backed boundary rather than storing plaintext credentials or bearer tokens.

The boundary establishes these guarantees:

  • Plaintext tokens are not a persistable representation.
  • Token logging emits only redacted metadata.
  • Revoked and expired tokens fail closed. Server-side token expiry is enforced on both access and refresh tokens via the configurable lifetimes above, distinct from revocation: an expired-but-not-revoked session is rejected with audit reason token expired.
  • Revocation is one-way: a credential that has ever been revoked never authenticates again, and no database function may clear a revoked flag once set. See ADR-0052.
  • Locked accounts cannot pass the login policy gate; suspended accounts may still log in, and their new session is itself suspended and gated by the request-path M_USER_SUSPENDED check.
  • The suspension allowlist matches on the action segment — the path segment immediately after the room ID — never as a substring of the whole path. The gate sees the raw undecoded target, and several room endpoints end in a segment the client chooses ({txnId} on send, {stateKey} on state), so a substring test let a suspended user name their transaction redact and send arbitrary messages. Anything added to this gate must be anchored to a segment the client cannot choose.
  • The allowlist does not go beyond the actions the spec's permitted list names. Where the spec names specific endpoints, they are listed exactly rather than admitted by a related-looking prefix: the key endpoints a suspended account may use are POST /keys/query, POST /keys/device_signing/upload, POST /keys/signatures/upload and the /room_keys/ backup subtree — not the whole /keys prefix, which also covers key upload and one-time-key claiming (participation, not verification).
  • PUT /rooms/{roomId}/redact/{eventId}/{txnId} is implemented and admitted by the suspension allowlist. The endpoint checks the target event's sender and rejects a suspended user attempting to redact someone else's event with 403 M_USER_SUSPENDED. It composes an m.room.redaction through the local event path and records the transaction ID for idempotent retries. The request-level suspension gate alone cannot enforce target ownership; that check belongs in the endpoint.
  • Token rotation is gated as well as token issue. POST /refresh authenticates with a refresh token, so it never reaches the request-path moderation gate — refresh_local_session therefore carries the locked-account check itself and answers 401 M_USER_LOCKED with soft_logout: true. A suspended account may still refresh, deliberately: see ADR-0058.
  • A refresh token must name a device that still exists in the persistent store. A token that outlives its device row is refused rather than honoured; the runtime device map is a cache and is never repopulated from a refresh alone.
  • Password login can be disabled per account.
  • Device IDs reject whitespace/control-shaped values, and also :, /, ? and #. A device ID is a component of other identifiers — key IDs are <algorithm>:<device_id>, and the ID is a path segment in /devices/{deviceId} — so those characters make the composed identifier ambiguous or unaddressable.
  • UIAA challenges carry a random per-attempt session id, scoped to the endpoint that issued it, held in memory and TTL-pruned. A client-supplied auth.session is honoured only if this server issued it for that endpoint; omitting it is still served, because the spec permits completing a single-stage flow in one shot. See ADR-0057.
  • Key API runtime records store route metadata and redacted payload summaries, not uploaded key material.
  • Persisted key material is represented as server-blind sensitive JSON payloads and is not logged through prepared-statement summaries.
  • Registration tokens are verified with Argon2id and only the hash is retained; plaintext tokens are zeroised with sodium_memzero after hashing. The hash is cached against the version of the token file it was derived from (device, inode, size, sub-second mtime), so rotating the token on disk takes effect without a restart; an entry is only cached when the file did not change between the two stats bracketing the read.
  • OpenID tokens (POST /user/{userId}/openid/request_token) are hashed and persisted in a table structurally disjoint from access_tokens, with disjoint mint and lookup paths, so they can never authenticate an ordinary client-server request — see "OpenID tokens" above.

Deliberately not included

These remain deferred:

  • Full Matrix UI-auth fallback flows and account recovery endpoints.
  • An HTTP admin bootstrap endpoint (the CLI flags --bootstrap-admin-localpart / --bootstrap-admin-password-file in src/main.cpp, backed by homeserver::bootstrap_admin_user in src/homeserver/auth_service.cpp, cover the bootstrap need at process start-up).

Profile updates

Changing a display name or avatar updates the stored profile, sends updated membership events to joined rooms, and refreshes presence. If any room update or the presence write fails, the profile endpoint returns HTTP 500 M_UNKNOWN rather than claiming the full update succeeded. The profile and some rooms may already be updated. Retrying the same PUT repairs pending updates, skipping rooms whose membership already carries the current profile; it does not duplicate their joins. There is no automatic background retry queue for these failures.

Every m.room.member join this server signs for a local user carries only that user's own profile. On a federated join the resident server's make_join template supplies the membership, but any displayname or avatar_url it contains is dropped before the local profile is added, so an unset local field is left out rather than taking the resident server's value.

Next starting points

  1. Preserve the reviewed libsodium boundary for random token generation and password hashing as auth flows expand.
  2. Extend conformance fixtures beyond the beta auth/device/key happy paths into UI auth, interactive auth, and negative-device-list cases.

/keys/upload validates that every one-time and fallback key carries a signature by the device's own Ed25519 identity key, rejecting unverifiable keys with 400 M_INVALID_SIGNATURE. This prevents stale device rows from leaving behind signed keys that no peer can verify at /keys/claim time, which would block the Olm session for the whole room's Megolm distribution.