Delegated authentication (OIDC)
Zendrite can hand authentication over to an external OpenID Connect (OIDC) provider instead of managing passwords itself. This is the MSC3861 “next-generation auth” model, and it is what Matrix Authentication Service (MAS) implements.
Once enabled, Zendrite stops being an identity store. It validates every access token by asking the provider about it (RFC 7662 token introspection), and creates local accounts and devices on demand from the answer.
This is an opt-in feature and is disabled by default.
Should you enable this?
Section titled “Should you enable this?”Enable it if you want single sign-on across services, or if you need features that only exist in the OAuth 2.0 world, such as centrally revocable sessions and per-device scopes.
Do not enable it on an existing server with password accounts without reading Migrating an existing server first. There is no automatic linking between existing local accounts and OIDC identities, and getting this wrong strands your users.
Before you start
Section titled “Before you start”- A working OIDC provider that supports token introspection. MAS is the reference choice and the only one tested end to end.
- HTTPS on both the homeserver and the provider. Plain HTTP issuers are rejected at startup unless the host is
localhost,127.0.0.1or::1. - The public base URL of your homeserver, for example
https://matrix.example.com.
Step 1: register Zendrite as a client at the provider
Section titled “Step 1: register Zendrite as a client at the provider”Zendrite needs a confidential OAuth 2.0 client so it can introspect tokens.
With MAS, add it to the clients section of config.yaml:
The redirect_uris entry is only needed if you want the legacy SSO compatibility layer, which lets clients without native OIDC support log in.
See Client support.
It must exactly match the callback URL Zendrite advertises, which is {public_base_url}/_matrix/client/v3/login/sso/callback unless you override it with sso_callback_url.
MAS also needs to reach Zendrite’s admin API, which it uses to provision users and devices:
That matrix.secret is the value you put in Zendrite’s admin_token.
Leaking it grants full admin access to the homeserver, so treat it like a root password.
Step 2: configure Zendrite
Section titled “Step 2: configure Zendrite”issuer must be byte-for-byte what the provider advertises as issuer in its discovery document at {issuer}/.well-known/openid-configuration.
With MAS that is http.issuer, which defaults to http.public_base.
If the two differ, Zendrite logs a warning, refuses to trust the document and falls back to config-derived endpoint defaults.
Everything else is discovered automatically from the provider. You only need the endpoint overrides below if your provider does not publish a discovery document, or publishes one you need to deviate from.
Configuration reference
Section titled “Configuration reference”| Field | Required | Description |
|---|---|---|
issuer |
Yes | The OIDC provider URL. Must use https, except for loopback hosts during development. |
client_id |
Yes | OAuth 2.0 client ID registered with the provider. |
client_secret |
Yes | OAuth 2.0 client secret used for introspection. |
client_auth_method |
No | client_secret_basic (default) or client_secret_post. Must match the client’s registration at the provider. |
admin_token |
No | Static bearer token granting admin access without introspection. Required for the MAS admin API and for confirmed cross-signing resets. |
account_management_url |
No | Where users manage their account. Advertised in /.well-known/matrix/client and as account_management_uri in the auth metadata. |
account_management_actions |
No | Actions the account management URL supports, e.g. ["org.matrix.profile", "org.matrix.devices_list"]. |
public_base_url |
No | Public base URL of the homeserver. Used to build client-facing URLs instead of trusting the request’s Host header. Strongly recommended behind a reverse proxy. |
sso_redirect_allowlist |
No | redirectUrl targets accepted by the legacy SSO flow. See Client support. |
sso_callback_url |
No | Overrides the SSO callback URL. Only needed if it is not {public_base_url}/_matrix/client/v3/login/sso/callback. |
introspection_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/introspect. |
userinfo_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/userinfo. |
authorization_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/auth. |
token_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/token. |
registration_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/clients/register. |
revocation_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/revoke. |
device_authorization_endpoint |
No | Overrides discovery. Defaults to the discovered value, then {issuer}/oauth2/device/auth. |
end_session_endpoint |
No | Advertised to clients for RP-initiated logout. Defaults to the discovered value. |
jwks_uri |
No | Upstream JWKS to proxy. Defaults to the discovered value, then {issuer}/oauth2/keys. |
Step 3: reverse proxy
Section titled “Step 3: reverse proxy”Set public_base_url.
Without it, Zendrite builds client-facing URLs from the incoming Host and X-Forwarded-Proto headers, which means a forged Host header can poison what gets advertised in cached auth metadata.
Make sure your proxy sets X-Forwarded-Proto correctly, and that it does not strip cookies on /_matrix/client/v3/login/sso/*.
The legacy SSO flow uses an HttpOnly, SameSite=Lax cookie to bind a login to the browser that started it.
Step 4: verify
Section titled “Step 4: verify”Restart Zendrite and check the discovery surface:
The JWKS endpoint is a homeserver-side proxy of the provider’s keys. It exists so browser clients can fetch them without depending on the provider’s CORS policy. Responses are cached for 15 minutes, and a stale copy is served if the provider becomes unreachable.
/_matrix/client/versions should now advertise org.matrix.msc2965 and org.matrix.msc2967 in unstable_features.
Client support
Section titled “Client support”Clients with native OIDC support, such as Element X, use the metadata above and talk to the provider directly. Nothing else is required for them.
Clients without it, such as current Element Web and Element Desktop, use the legacy SSO compatibility layer that Zendrite provides:
- The client opens
/_matrix/client/v3/login/sso/redirect?redirectUrl=.... - Zendrite starts a PKCE authorization-code flow against the provider and sets a browser-binding cookie.
- The provider redirects back to
/_matrix/client/v3/login/sso/callback. - Zendrite exchanges the code, provisions the user, and redirects to
redirectUrlwith aloginToken. - The client redeems it via
POST /loginwithm.login.tokenand receives the provider-issued access token, refresh token and device ID.
You must configure sso_redirect_allowlist for this to work with a client hosted on a different origin.
By default only redirect targets on the homeserver’s own origin are accepted, and anything else is rejected with M_INVALID_PARAM.
This default is deliberate.
The login token handed to redirectUrl redeems into the provider-issued access and refresh tokens, so an unrestricted redirectUrl would let a single crafted link deliver a user’s long-lived provider credentials to an attacker.
The browser-binding cookie does not help here, because the victim’s own browser runs the whole flow and therefore holds the cookie.
Entries are matched as follows:
httpandhttpsentries match on scheme, host and path prefix at a path boundary.https://app.example.comtherefore does not matchhttps://app.example.com.evil.com/, a different port, orhttps://app.example.com@evil.example.com/.- Entries with any other scheme, such as the
element://desktop deep link, are matched as plain string prefixes.
Keep the list as tight as you can.
How accounts are provisioned
Section titled “How accounts are provisioned”On the first token introspection for an unknown subject, Zendrite picks a localpart in this order:
- The
emailclaim, but only when the provider also reportsemail_verified: true. If the domain matches your server name, it is stripped, soalice@example.combecomes@alice:example.com. - The
usernameclaim. - The
subclaim, for providers that expose no username.
The result is lowercased and sanitised to a valid Matrix localpart.
The mapping from the provider’s (issuer, sub) to that localpart is then stored permanently, so a user’s Matrix ID is stable even if they later change their email or display name at the provider.
If the derived localpart is already taken by an unrelated account, Zendrite falls back to the subject claim rather than attaching the OIDC identity to a pre-existing account. If that is taken too, the login is refused instead of linking anything.
Device IDs come from the token’s urn:matrix:client:device:<id> scope.
Tokens without a device scope share a fallback device called OIDC.
Accounts whose token carries the urn:synapse:admin:* scope are marked as server admins.
Admin access
Section titled “Admin access”Setting admin_token does two things.
It registers the Synapse-compatible admin endpoints that MAS calls to manage users:
GET /_synapse/admin/v1/username_availableGET|PUT /_synapse/admin/v1/users/{userId}PUT|DELETE /_synapse/admin/v1/users/{userId}/devices/{deviceId}DELETE /_synapse/admin/v1/users/{userId}/devicesPOST /_synapse/admin/v1/users/{userId}/_deactivatePOST /_synapse/admin/v1/users/{userId}/_allow_cross_signing_replacement_without_uia
These are authenticated with the admin token as a bearer token, not with a user access token.
If admin_token is unset the routes are not registered at all.
It also lets the token itself be presented as an access token, which resolves to an admin device for @admin:{server_name}.
This is intended for service-to-service calls.
Cross-signing resets
Section titled “Cross-signing resets”Replacing a user’s cross-signing keys normally requires user-interactive auth, which under delegated authentication cannot be a password check.
Zendrite uses the m.oauth UIA type and deep-links the user to the account management UI.
When admin_token is configured, the reset only completes after the provider confirms it by calling _allow_cross_signing_replacement_without_uia.
The confirmation is single-use and valid for 10 minutes.
When admin_token is not configured there is no way for the provider to confirm anything, so Zendrite falls back to accepting a resubmitted challenge and logs a warning.
That fallback is materially weaker: anyone holding a valid access token can reset cross-signing keys.
Configure admin_token.
What changes when this is enabled
Section titled “What changes when this is enabled”| Area | Behaviour |
|---|---|
POST /login |
Only m.login.token is accepted. Everything else returns 403 M_FORBIDDEN. |
GET /login |
Advertises m.login.sso (marked oauth_aware_preferred) and m.login.token. |
POST /register |
403 M_FORBIDDEN, except application-service registrations, which still work. |
/register/available |
403 M_FORBIDDEN. |
/account/password, /account/deactivate |
403 M_FORBIDDEN. Handled at the provider. |
/account/3pid/delete, 3PID requestToken |
403 M_FORBIDDEN. |
/logout, /logout/all, /delete_devices, device modification |
403 M_FORBIDDEN. Sessions are managed at the provider. |
/auth/{authType}/fallback/web |
403 M_FORBIDDEN. |
GET /capabilities |
Reports m.change_password and m.3pid_changes as disabled. |
POST /refresh |
Bridges to the provider’s refresh token grant. |
| Expired tokens | 401 M_UNKNOWN_TOKEN with soft_logout: true, so clients refresh instead of logging out. |
/.well-known/matrix/client |
Gains an m.authentication section with the issuer and account management URL. |
Introspection results are cached for 30 seconds, so a token revoked at the provider may keep working for up to that long. Failed introspections are cached for 5 seconds.
Migrating an existing server
Section titled “Migrating an existing server”Enabling MSC3861 on a server that already has password accounts is a one-way door, and Zendrite will not link the two worlds for you.
- Existing password sessions stop working. Every user has to log in again through the provider.
- An existing local account is not adopted by an OIDC identity that happens to derive the same localpart. Zendrite deliberately refuses to attach an OIDC subject to an unrelated pre-existing account, and falls back to a subject-derived localpart instead. In practice
@alice:example.comwill not become the OIDC useraliceunless you seed the mapping yourself. - Deactivated accounts are refused, rather than being silently reactivated by a matching token.
Plan the identity mapping before you flip this on, and test it on a staging server.
Known limitations
Section titled “Known limitations”The legacy SSO compatibility layer keeps its in-flight state in process memory: SSO sessions, the login tokens issued by the callback, and pending cross-signing reset confirmations.
The /login/sso/redirect, /login/sso/callback and POST /login requests belonging to one login must therefore all reach the same Zendrite process.
Running several instances behind a load balancer breaks the flow unless the balancer pins a client’s requests to one instance.
Clients with native OIDC support are unaffected, since they never touch the compatibility layer.
Troubleshooting
Section titled “Troubleshooting”Every device shows up as OIDC.
The token carried no urn:matrix:client:device: scope, or introspection failed and Zendrite fell back to the UserInfo endpoint, which returns no scope.
Check the logs for an error about rejected client credentials.
Logs show introspection falling back to userinfo.
The provider rejected Zendrite’s client credentials.
Check client_id, client_secret and that client_auth_method matches the client’s registration.
Authentication still works in this state, but device IDs and admin scope are lost, so do not leave it running like this.
SSO login fails with M_INVALID_PARAM mentioning sso_redirect_allowlist.
The client’s redirectUrl is not on the allowlist and is not on the homeserver’s own origin.
See Client support.
Logs warn that the discovered issuer does not match the configured issuer.
issuer differs from what the provider advertises, commonly by a trailing slash or an http versus https mismatch.
Zendrite falls back to config-derived endpoints, which usually still work but skips discovery entirely.
Clients are logged out instead of refreshing.
Check that the provider returns a refresh token, and that the client actually supports POST /refresh.
Admin endpoints return 403.
admin_token is unset, so the MAS admin routes were never registered.