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/queryreturns persisted device keys, and/keys/claimconsumes 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 /refreshdoes 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_tokenexcludeslogin,register_account, andrefresh_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
requestTokenendpoints (/account/3pid/{email,msisdn}/requestToken,/register/{email,msisdn}/requestToken) answer400 M_THREEPID_MEDIUM_NOT_SUPPORTEDand create no session unless bothid_server(operator-trusted) andid_access_tokenare supplied, in which case the request is delegated to that identity server and aRegistrationValidationSessionkeyed by the IS-issuedsidis stored. That session starts unvalidated.POST /account/3pid/add,POST /account/3pid/bindand the deprecatedPOST /account/3pidask the identity server whether the session was validated (getValidated3pid) and refuse400 M_SESSION_NOT_VALIDATEDunless it reports the same medium and address; see "3PID ownership is proven by the identity server" below.POST /account/3pid/addalso enforces password UIA, and the bind/list/unbind/delete endpoints maintain per-account 3PID records includingadded_at/validated_atmetadata plus, for IS-bound 3PIDs, the storedclient_secretandsid(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 /refreshdoes 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_userfor the access token,refresh_local_sessionfor 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 anauth.refresh.reuse_detectedaudit 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-devicePOST /logoutrevokes 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_sessionadmits 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 withlogout_devices: truerevokes every other device directly, rather than revoking everything and restoring one (fixed 0.12.7). The prior sequence — revoke every token for the user, thenUPDATE access_tokens SET revoked = false WHERE user_id AND device_idfor 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_devicereplaces 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 anddocs/database-persistence.md— a credential that has ever been revoked must never authenticate again, and no store function may clear arevokedflag 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 gets401 M_USER_LOCKEDwithsoft_logout: trueon all endpoints except/logoutand/logout/all; a suspended account gets403 M_USER_SUSPENDEDon 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 loginresult 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-#322token-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 whenserver.oidc.*is configured; returns404 M_UNRECOGNIZEDwhen 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-memoryAppserviceRegistryat startup;as_token/hs_tokenare held in a mlockedcore::SecretBufferand only ever compared viacrypto::constant_time_equal_variable_length. A client-server request bearing a registeredas_tokenauthenticates as the appservice's ownsender_localpartuser by default, or as the user named by?user_id=when that user falls within one of the appservice'susersnamespaces (403otherwise);?device_id=must name a device already known to belong to the asserted user (400 M_UNKNOWN_DEVICEotherwise). This masquerade is resolved exactly once, at the top ofhandle_client_server_request_impl, by substitutingreq.access_tokenwith an internal token (appservice::encode_masquerade_token,appservice/masquerade_token.hpp) thatauthenticated_user/authenticated_session/authenticated_admin_userinauth_service.cpprecognise 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 /registerandPOST /loginaccepttype: m.login.application_service(bearer-authenticated withas_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) withm.login.password. An appservice'sexclusivenamespace blocks registration/alias creation by anyone else (M_EXCLUSIVE) — enforced in the registration andPUT /directory/room/{roomAlias}/POST /createRoomhandlers. The same rule binds other appservices:/registerwithm.login.application_serviceinto another service's exclusive users namespace is400 M_EXCLUSIVE(the/registerresponse table's status),/loginwith that type is403 M_EXCLUSIVE, and asserting such a user via?user_id=is403 M_EXCLUSIVE(AUTH-7). Identity assertion also requires the asserted user to be registered and not deactivated — otherwise403 M_FORBIDDEN(AUTH-12); a service creates its virtual users with/registerfirst, and its ownsender_localpartuser is created at startup, so acting as the sender always works. Assertion does not apply to Account Management (AUTH-8): every/account/*endpoint exceptGET /account/whoami(password change and itsrequestTokencalls,deactivate, and the whole 3PID family) answers403 M_FORBIDDENto anas_tokenrequest, with or without?user_id=(appservice::is_account_management_endpoint, checked inhandle_client_server_request_implunder the request lock). Outbound delivery to appservices is wired:appservice::AppserviceClient(src/appservice/appservice_client.cpp) implementssend_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 oneappservice_outboxrow per namespace-interested appservice at event time and makes no network call; one dedicated thread (drain_appservice_queues, run by aPeriodicJobstarted inmain.cpp) sends them oldest first, one event per transaction, tracked by the per-appservice persisted cursor. A transaction that fails is retried under the sametxn_idwith 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_aliasare invoked fromsrc/homeserver/client_server.cppwhen 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. TheGET /_matrix/client/v1/register/m.login.registration_token/validityendpoint 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 /registerwithoutusernamegets 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 emptyusernameis still400 M_INVALID_USERNAME, andm.login.application_serviceregistration still requires one.- Server-side access and refresh token expiry is enforced.
PersistentAccessToken,PersistentRefreshToken, andLocalSessioncarry anexpires_atfield set at issuance from configurablesecurity.access_token_lifetime_ms(default 1h) andsecurity.refresh_token_lifetime_ms(default 30d);0disables 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_sessionand the refresh-token lookup reject expired tokens (failing closed toward re-login/refresh) even when the session is not revoked. For refresh-enabled/loginand/refresh, a positive configured access lifetime is advertised asexpires_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_atempty /nullopt) remain valid. - Token-hash lookups route through constant-time comparison (
crypto::constant_time_equal/auth::constant_time_equal, backed bysodium_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'sdevice_lists.changed/leftfromstore.device_list_changesfiltered bysince_sync_stream_id— throughsync::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 — andbroadcast_device_list_updates()emitsm.device_list_updateEDUs on cross-signing/key changes. Inboundm.device_list_updateandm.signing_key_updateEDUs 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-algorithmdevice_one_time_keys_countis wired into/syncas well. Adevice_lists.changedself-notification is recorded at two trigger points so a user's own devices discover each other without relying on room co-membership: onPOST /_matrix/client/v3/keys/upload(key upload) and onPOST /_matrix/client/v3/loginwhen a genuinely new device is created. The login trigger ensures a freshly-logged-in device's initial/synclists its own user indevice_lists.changed, prompting it to/keys/queryand fetch the user's existing devices' keys before anym.key.verification.requestcan 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.hppmerges the signatures uploaded throughPOST /keys/signatures/uploadinto a published key. Every path that serves keys uses it: client/keys/query, federation/user/keys/queryand/user/devices/{userId}, and thekeysof outboundm.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/queryfor a remote user calls the user's server's/user/keys/queryand returns itsdevice_keys,master_keysandself_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 asremote_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 whoseuser_idbelongs to the sending server puts that user in local users'device_lists.changed, the same asm.device_list_update, so clients re-query them.- Key-backup retrieval and deletion:
handle_key_api_route()implementsget_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, anddelete_room_key_backup_batch, all reading/writing real session rows frompersistent_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 bothid_serverandid_access_tokenare present andid_serveris trusted, the HS resolves the trusted base URL, callsIdentityServerClient::request_email_token/request_msisdn_token, and on a 200 with an IS-issuedsidstores aRegistrationValidationSessionkeyed by thatsid(purposeregister/account-3pid). The IS is the validation authority: it contacts the user by email/SMS and (optionally) redirects vianext_link, so Merovingian has no client-facingsubmitToken. Absentid_server/id_access_tokenthe request is refused400 M_THREEPID_MEDIUM_NOT_SUPPORTED(before AUTH-5 it minted a local session that was already marked validated and sent nothing). Fail closed with502on transport error or a malformed IS response;403whenid_serveris not trusted;400 M_BAD_JSONwhen only one of the pair is supplied. The client'ssend_attemptis 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 anid_access_tokenthat is not printable ASCII without spaces of at most 4096 bytes is400 M_INVALID_PARAMon this, thebind, the deprecated/account/3pidand the 3PID invite routes, before any IS call (identity::id_access_token_is_valid, sp02, ADR-0180): it becomes theAuthorizationheader of the IS call. -
bind(/account/3pid/bind, plus the deprecated/account/3pid) - after the identity server has confirmed the session validated (below) and theid_serveris trusted, the HS callsIdentityServerClient::bindwith the runtime mutex released for the network call, then persistsbound=true,id_server,client_secret, andsid. A bind that names anid_serverthis server does not trust is refused400 M_SERVER_NOT_TRUSTED; before AUTH-5 it was recorded locally as bound although no identity server had been asked./account/3pid/addadds 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/bindand/account/3pid. It callsIdentityServerClient::get_validated_3pid(GET /_matrix/identity/v2/3pid/getValidated3pid?sid=&client_secret=, bearerid_access_token) against the trusted identity server stored with the session, withruntime.mutexreleased, and only marks the session validated (and records the IS-reportedvalidated_at) when the answer is a 200 whosemediumequals the session's and whoseaddressmatches 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'sid_access_tokenin 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 recoversclient_secret+sidfrom the stored binding and callsIdentityServerClient::unbindwith an emptyid_access_token. Per the IS API v2 this is unbind auth mode 2 (client_secret+sid, no bearer) —build_unbind_bodycarries noAuthorizationheader. Transport failure fails closed with502: silently removing the local binding while the IS still holds it would orphan the IS-side binding (the localclient_secret/sidwould be gone, making a retry impossible). A non-2xx IS response other than a recognised "no-support" shape also surfaces502so the user may retry;id_server_unbind_resultreports"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 noclient_secret/sid, the handler falls back to the existing local-onlythreepid_unbind_resultlogic.
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), neveraccess_tokens. Seedocs/database-persistence.mdfor the schema. - Separate mint path.
homeserver::request_openid_token(src/homeserver/auth_service.cpp) is the only function that writes toopenid_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_userinfois the only function that readsopenid_tokens. The ordinary client-server auth gate (authenticated_user, which backs everyAuthorization: Bearercheck) only ever consultsaccess_tokens/database.sessionsand never looks atopenid_tokens. Symmetrically,federation_openid_userinfonever consultsaccess_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/userinforeturns the identical401 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/userinforequires 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 forGET /_matrix/key/v2/server. - Retention.
store_openid_tokensweeps every already-expired row (across all users) on each insert, soopenid_tokenscannot grow without bound — seedocs/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(defaultfalse),authorization_url(the external SSO system's HTTPS entry point),identity_providers(a list of{id, name, icon?, brand?}— the spec'sIdPshape, parsed fromserver.sso.identity_providers. <idpId>.<name|icon|brand>keys,<idpId>itself carrying the id), andredirect_url_allowlist(HTTPS URL prefixes a client'sredirectUrlis validated against). Parse-time validation (config::validate_config) fails closed:enabled = truewith an emptyauthorization_urlor an emptyredirect_url_allowlistis rejected outright, as is a duplicate or empty identity-providerid, an emptyname, or a non-mxc://icon. GET /loginflow advertisement:m.login.sso(with anidentity_providersarray when any are configured) is advertised only whenhomeserver::sso_is_configuredholds — 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): validatesredirectUrlagainstredirect_url_allowlist(seedocs/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), validatesidpIdagainst the configuredidentity_providerswhen present (404 M_NOT_FOUNDfor an unrecognised IdP, per spec), and on success responds302withLocationset toauthorization_urlcarryingidp/action/redirectUrlquery 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-validatesredirectUrlagainst the allowlist (never trust a value handed across an integration boundary a second time without re-checking it), mints a login token via the sameissue_token_hashmachinery access tokens use, persists it to the dedicatedlogin_tokenstable (neveraccess_tokens— see "OpenID tokens" above for why that separation matters, anddocs/database-persistence.mdfor 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 returnsredirectUrlwith any pre-existingloginTokenquery parameters stripped (per spec step 4) and the new one appended. POST /loginwithtype: m.login.token(homeserver::redeem_login_token): consults only thelogin_tokenstable — deliberately independent ofauthenticated_user/find_session, mirroringfederation_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 viahomeserver::login_local_user_by_id, the second half oflogin_local_userfactored 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 completedm.login.passwordauth stage proving current knowledge of the password. A missing, incomplete, or wrong-credentialauthblock returns the401UIA challenge, never403. 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 valueauth::password_matchescan never match, so a credential leak predating deactivation cannot be replayed even if a future change were to soften the login gate.- The
usersrow is retained, not deleted:deactivatedis a column ofusers(part of the v1 schema — seedocs/database-persistence.md), not a row removal, so registration's existing duplicate-user_idcheck 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 inlogin_local_user_by_id(src/homeserver/ auth_service.cpp) each rejectuser.deactivatedwith403"account deactivated" before evaluating the reversiblelocked/suspendedstates — 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 storedid_server+client_secret+sid(stored in the v1 schema), through the sameresolve_trusted_identity_base_urlSSRF gate asPOST /account/3pid/unbind. The local binding is cleared either way. id_server_unbind_result, required in the200response 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 reportsno-supportrather than stranding the account half-closed. The spec models a partial unbind as a200carryingno-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
@alicelocks@aliceout 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_EXCEEDEDcarryingretry_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
@alicecannot 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_EXCEEDEDandRetry-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
revokedflag 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_SUSPENDEDcheck. - 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}onsend,{stateKey}onstate), so a substring test let a suspended user name their transactionredactand 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/uploadand the/room_keys/backup subtree — not the whole/keysprefix, 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 with403 M_USER_SUSPENDED. It composes anm.room.redactionthrough 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 /refreshauthenticates with a refresh token, so it never reaches the request-path moderation gate —refresh_local_sessiontherefore carries the locked-account check itself and answers401 M_USER_LOCKEDwithsoft_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.sessionis 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_memzeroafter 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 fromaccess_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-fileinsrc/main.cpp, backed byhomeserver::bootstrap_admin_userinsrc/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¶
- Preserve the reviewed libsodium boundary for random token generation and password hashing as auth flows expand.
- 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.