Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

rIDM is a self-hosted, multi-tenant identity server written in Rust. It is an OpenID Connect provider and JWT issuer with user, group and role management, multi-factor authentication, passkeys and identity brokering, plus two web consoles: one for administrators and one for end users managing their own account.

A deployment is the ridm-api server, Postgres and Valkey (or Redis). Every tenant it hosts has its own issuer, signing keys, users, clients, policies and administrators. Applications sign users in through the standard authorization code flow, and APIs accept the resulting access tokens by checking them against the tenant’s published keys (or, for clients that ask for opaque access tokens, at the tenant’s introspection endpoint).

Status: 0.4.1. Until 1.0.0 a minor version may break compatibility, and the changelog says how. The working plan lists what is done and what is still to come.

Who it is for

  • Operators who want an identity provider they run themselves: on bare metal, in docker-compose, on Kubernetes or on any cloud, with the same image and nothing but environment variables to configure it.
  • Application and API developers who need standard OAuth 2.0 and OpenID Connect behaviour to integrate against, without provider-specific SDKs. A Rust API can use the ridm-auth crate to validate tokens; anything else uses an ordinary OIDC or JWT library.
  • Teams serving several customers or business units from one deployment, each as a tenant with its own login page, policies and administrators.
  • Teams leaving another identity provider, who need password hashes, users and configuration to come across without forcing every user to reset.

What it deliberately does not do

rIDM implements the parts of OAuth 2.0 and OpenID Connect that current security guidance recommends and refuses the rest, so that a misconfigured client cannot fall back to a weaker flow.

  • No implicit flow, no hybrid flow, no resource owner password credentials grant. /authorize accepts response_type=code only, and the password never travels to an application. Discovery advertises response_types_supported: ["code"].
  • No plain PKCE. A code challenge must use S256; public clients must always send one.
  • No cloud-specific dependencies. The default build needs a container runtime, Postgres and Valkey and nothing else. Email goes out over SMTP or an HTTP webhook, text messages over an HTTP gateway, and secrets at rest are encrypted with a master key you supply, or held in an HSM or a cloud key management service if you choose one (Key custody).

Where something is not built yet, this book says so where you would look for it.

How this book is organised

SectionRead it forStarts at
ConceptsThe model: tenants, clients, roles, tokens, keys, sessions, and why they work the way they doTenants and issuers
QuickstartsA running server and a first integration, one scenario per pageRun rIDM locally
Admin guideDay-to-day administration through the consoles, the ridm CLI and the admin APIThe admin and account consoles
ReferenceEndpoints, the admin API, token claims, environment variables, the tenant document, errorsHTTP endpoints
DeploymentRunning rIDM in production: containers, TLS, Postgres and Valkey, scaling, observabilityDeployment overview
MigrationMoving users and configuration from another identity providerMigrating to rIDM

The concepts pages explain the reasoning; the admin guide and reference hold the exhaustive lists of settings, fields and endpoints.

Where to start

Running rIDM. Start with Run rIDM locally to have a server, the consoles and a first administrator on screen, then read the Deployment overview before putting it anywhere others can reach. Tenants and issuers and Signing keys and the master key explain the two decisions you cannot easily undo later: your public URL and your master key.

Integrating an application or API. To accept rIDM tokens in an API, read Protect a Rust API with ridm-auth. To sign users in from a browser application, read Sign in from a single-page app, or Sign in from a server-side web app for an application that keeps a secret. Clients, Resource servers, scopes and permissions and Tokens explain what you are configuring.

Moving from another provider. Read Migrating to rIDM, then the page for your current provider.

Tenants and issuers

A tenant is an isolated identity domain: its own users, groups, roles, clients, signing keys, policies, branding and administrators. One rIDM deployment hosts any number of them. A user of one tenant does not exist in another, a token issued by one tenant is not valid in another, and an administrator of one tenant cannot see another unless they are a global administrator.

Tenants suit anything that needs a separate login experience or separate policy: customers of a SaaS product, business units, or simply staging and production side by side.

One issuer per tenant

Every tenant is its own OpenID Connect issuer. With PUBLIC_URL set to https://id.example.com, the tenant with slug acme is the issuer https://id.example.com/t/acme, and every protocol endpoint lives under that prefix:

EndpointPath
Discovery/t/acme/.well-known/openid-configuration
JWKS/t/acme/.well-known/jwks.json
Authorization/t/acme/authorize
Token/t/acme/token
UserInfo/t/acme/userinfo

The full list is in HTTP endpoints.

A separate issuer per tenant is what keeps tenants apart at the protocol level. Each tenant publishes its own keys, so a relying party that trusts https://id.example.com/t/acme cannot be handed a token signed for another tenant: the iss claim differs and the signing key is not in the set it fetched. Tokens also carry the tenant’s id as tid, which a relying party that serves several tenants of one deployment can key on.

Because the issuer is derived from PUBLIC_URL, choose that value before anything depends on it. Changing it changes every tenant’s issuer, and every relying party configured with the old one stops accepting tokens.

A tenant’s slug is 1 to 63 lower-case letters, digits and hyphens, and cannot start or end with a hyphen. It appears in every URL, so treat it as permanent.

A tenant can instead be served on a host of its own, such as https://login.acme.com, which then becomes its issuer; see Custom domains below.

Custom domains

A tenant with settings.custom_domain set is served on that host as if every request had come in under /t/{slug}: https://login.acme.com/authorize is the tenant’s authorization endpoint, and the issuer is https://login.acme.com. A custom domain serves its own tenant and nothing else. Only these paths pass through unchanged:

  • the health probes /healthz and /readyz, which a load balancer asks whatever host it uses;
  • /.well-known/webfinger and /.well-known/security.txt;
  • the tenant’s own /t/{slug}/... and /scim/v2/{slug}/... paths.

Every other path is rewritten under the tenant’s prefix, so the admin API, /metrics, /docs, /openapi.json and other tenants’ /t/{other}/... paths answer 404 on a custom domain. A customer’s host can never be used to reach the operator’s administration surface or another customer’s tenant. Session cookies are named per tenant with Path=/ (see The SSO session), so single sign-on works on the custom domain as it does on the primary host. Setting one up is covered in Custom domains.

The master tenant

Every deployment has a tenant called master, created by the first migration. It is an ordinary tenant with one extra role: it hosts the global administrators. An administrative role held in master applies to every tenant; the same role held in any other tenant applies to that tenant only. Only global administrators can create tenants, and master itself can be neither deleted nor disabled.

The first global administrator is created by the bootstrap step (see Run rIDM locally). Keep master for administrators; put applications and their users in tenants of their own, so that the tenant holding the keys to every other tenant has as few users and clients as possible.

What a new tenant starts with

Creating a tenant seeds it with everything it needs to work at once:

  • the standard scopes openid, profile, email, phone, address, offline_access and features;
  • the built-in resource server urn:ridm:admin, carrying the catalogue of ridm:* admin permissions, and six built-in roles that hold them (ridm:owner, ridm:admin, ridm:user-manager, ridm:client-manager, ridm:org-admin, ridm:viewer);
  • the built-in resource server urn:ridm:account, the audience of the self-service account API;
  • two built-in public clients, ridm-admin-console and ridm-account-console, through which the consoles sign in to this tenant;
  • default settings for every policy.

Signing keys are not created up front: the tenant’s first key is generated the first time something needs to sign with it (see Signing keys and the master key).

Tenant settings

A tenant’s policies live in one settings document, edited in the admin console under Settings, through PATCH /admin/tenants/{slug} as a JSON merge patch, or as part of the tenant configuration document. Every section has defaults, so an empty document is a valid configuration.

SectionGoverns
passwordlength, character classes, history, expiry, breached-password check
sessionSSO idle and absolute timeouts, concurrent sessions, trusted-device lifetime, default token lifetimes
mfa, mfa_methodswho must pass a second step, and which second-step methods are offered
authwhich first-factor methods the login page offers (password, magic link, email or SMS code, passkey)
registrationself-registration, email verification, terms, allowed email domains
lockout, captcha, rate_limitsbrute-force protection and request ceilings
keyssigning algorithm, RSA key size, rotation interval and overlap
locale, brandinglanguages offered, and the login page’s look
dcr, discoverydynamic client registration and WebFinger email domains
notifications, audit, accountsecurity notices to users, audit retention, self-service rights
custom_domain, featuresthe tenant’s own host, and free-form feature flags

Tenants and tenant settings goes through them one by one, and Tenant configuration document gives every field and default.

A tenant has a status of active or disabled. A disabled tenant’s endpoints answer 403 (discovery, sign-in and token requests alike), while its administrators can still read and re-enable it through the admin API.

Isolation in the database

All tenants share one Postgres database. Every tenant-scoped table carries a tenant_id column, every index on those tables leads with it, and foreign keys between tenant-scoped tables include it, so a row can never point at a row of another tenant.

On top of that, every tenant-scoped table has a forced row level security policy keyed on a per-transaction setting, app.tenant_id. The server opens each unit of work in a transaction bound to one tenant, and within it Postgres itself hides every other tenant’s rows. A query that forgets its WHERE tenant_id = ... returns nothing from other tenants rather than leaking them. The setting is bound with set_config(..., true), so it ends with the transaction and can never carry over to the next request on a pooled connection. The few operations that must see every tenant (bootstrap, the background jobs, the global audit chain) open an explicitly marked cross-tenant transaction instead.

Row level security is defence in depth against mistakes in the server’s own queries, not a boundary against someone who holds database credentials. It does, however, depend on how the server connects: Postgres superusers bypass row level security and table owners can disable it, so DATABASE_URL must name a non-superuser role with data-manipulation rights only, and migrations are applied by the schema owner (ridm-api migrate). See Postgres and Valkey.

Outbound requests

Some URLs rIDM calls are chosen by tenant administrators or by clients registering themselves, not by the operator: webhook targets, back-channel logout URIs, client jwks_uris, upstream identity provider endpoints, a tenant’s HTTP email or SMS gateway, its CAPTCHA verify_url and its SMTP host. Left unchecked, any of them could point rIDM at the operator’s own network (server-side request forgery). rIDM therefore resolves these hosts itself and connects only to public addresses:

  • private, loopback, link-local, carrier-grade NAT, unique-local, documentation, benchmarking and reserved ranges are refused, including IPv6 forms that embed such an IPv4 address;
  • the check happens when the connection is made, not only when the URL is saved, so a public name that later resolves to a private address (DNS rebinding) is still refused;
  • redirects are not followed, since a redirect would be a second, unchecked target, and HTTP_PROXY/HTTPS_PROXY are ignored, since a proxy would resolve the name out of the check’s sight;
  • for development, loopback stays allowed when it is named as such: the host localhost or a loopback IP literal;
  • the operator can open private networks where internal applications live with OUTBOUND_ALLOW_NETWORKS; addresses inside them count as public.

A tenant SMTP host that is a private IP literal is refused when it is saved. When sending, rIDM connects to the address it vetted (the first allowed one) and verifies TLS against the configured hostname.

URLs the operator sets in the environment, such as the audit sink and the breached-password check, are trusted and not filtered.

Tenants and the cache

Tenants are looked up on every request, so they are read through the two-level cache (in-process, then Valkey) and evicted on every write, on every node. A settings change takes effect on the next request everywhere; there is nothing to restart.

Clients

A client is an application registered with a tenant: something that sends users to the tenant’s login page, or that asks the token endpoint for tokens on its own behalf. Every client has a public client_id, a type, a way of proving who it is at the token endpoint, and a set of grants, redirect URIs, scopes and audiences it is allowed to use. Anything a client is not explicitly allowed is refused.

Register clients in the admin console, with ridm client create, through POST /admin/tenants/{slug}/clients, in the tenant configuration document, or (when the tenant allows it) by dynamic registration. Registering clients covers the fields one by one.

Client types

The type describes what kind of application the client is and sets sensible defaults for everything else. Every default can be overridden.

TypeWhat it isDefault authenticationDefault grants
spaBrowser application; cannot keep a secretnone (public, PKCE)authorization_code, refresh_token
webServer-side web applicationclient_secret_basicauthorization_code, refresh_token
nativeMobile or desktop applicationnone (public, PKCE)authorization_code, refresh_token
machineService calling other services, no userclient_secret_basicclient_credentials
deviceTV, CLI or kiosk that cannot show a browsernone (public)urn:ietf:params:oauth:grant-type:device_code, refresh_token

A sixth type, saml, is a SAML 2.0 service provider: it has no redirect URIs, grants or secret, and is registered and edited through the SAML routes, not these (see SAML identity provider).

A client created without a type is a web client. Clients other than machine are allowed the standard scopes by default; a machine client starts with none.

The type also decides which redirect URIs are acceptable. Every client may use https redirect URIs, and plain http only for loopback addresses (localhost, 127.0.0.1, [::1]). Only native clients may register custom URI schemes such as com.example.app:/callback (RFC 8252), and only native clients may use a registered loopback redirect with any port at request time, because a desktop application picks a free port when it starts. Every other comparison is an exact string match: no prefix matching, no wildcards.

Public and confidential

A confidential client can keep a credential secret (it runs on a server); a public client cannot (its code is in the user’s browser or on their device). The difference is the token endpoint authentication method:

MethodHow the client proves itself
client_secret_basicits secret in an HTTP Basic Authorization header
client_secret_postits secret as client_secret in the form body
private_key_jwta JWT assertion (RFC 7523) signed with a private key whose public half is registered as jwks or at jwks_uri
nonenothing: a public client, identified only by client_id

A client must authenticate with the method it registered; presenting a different one is invalid_client, even if the credential would have been valid. This closes the door on a leaked secret being used against a client that was meant to authenticate with a key.

Secrets are 256-bit random values, shown once when they are created and stored only as a SHA-256 hash. A client can hold two secrets at once so that rotation needs no downtime: rotating issues a new secret and keeps the previous one working for a grace period (24 hours by default, at most 30 days). private_key_jwt assertions are accepted once each (their jti is remembered for the assertion’s lifetime), and a jwks_uri is re-fetched when an assertion names a key rIDM has not seen, so the client can rotate its own keys. A jwks_uri must be reachable at a public address: rIDM does not fetch from private networks (see Outbound requests).

Public clients can never use client_credentials: a grant that issues tokens to the client itself makes no sense for a client anyone can impersonate.

Grants

GrantUsed for
authorization_codeSigning a user in through the browser. Always with PKCE S256 for public clients, and by default for confidential ones too (require_pkce).
refresh_tokenRenewing tokens without sending the user back to the login page. Refresh tokens rotate on every use.
client_credentialsA confidential client acting as itself. With a service account, it acts as a user that holds roles.
urn:ietf:params:oauth:grant-type:device_codeSigning a user in on a device without a usable browser (RFC 8628).
urn:ietf:params:oauth:grant-type:token-exchangeTrading an access token for one aimed at another API, optionally on behalf of another party (RFC 8693).

Refused by design: the implicit grant, the hybrid flow and the resource owner password credentials grant. /authorize accepts only response_type=code, and none of these can be switched on. The implicit and hybrid flows put tokens in the browser’s address bar and history; the password grant hands the user’s password to the application. Authorization code with PKCE does everything they did, without either problem.

The authorization response can be returned as a query string, a fragment, a form_post, or a signed JWT (JARM: jwt, query.jwt, fragment.jwt, form_post.jwt). Requests can be pushed first (PAR, /par) or sent as signed request objects (JAR).

Scopes and audiences

A client lists the scopes it may request (allowed_scopes) and the resource servers it may obtain tokens for (allowed_audiences). An authorization request naming a scope outside the list is refused with invalid_scope; one naming a resource outside it is refused with invalid_target. A request with no scope at all receives the tenant’s default scopes (those marked is_default) that are in the client’s allowed_scopes, and a scope bound to a resource server adds that resource server to the token’s audiences; see Scopes.

An empty allowed_audiences means “any of the tenant’s resource servers”, with two exceptions: the built-in urn:ridm:admin audience must always be listed explicitly, so no third-party client can obtain admin tokens by accident, and token exchange only ever issues tokens for listed audiences. See Resource servers, scopes and permissions.

With require_consent on (the default), the user is asked to approve the scopes a client requests the first time, and again whenever it asks for a scope they have not granted. Grants are remembered per user and client, listed in the account console, and can be withdrawn there, which also cancels the client’s refresh tokens. Turning require_consent off makes a client first-party: its users are never shown the consent screen. The built-in console clients are first-party.

Other per-client settings

Clients also carry: token lifetimes that override the tenant’s defaults; a subject_type of public (the user’s id as sub) or pairwise (a different, stable sub for each client or sector, so clients cannot correlate users); optional ID token encryption to the client’s own key (RSA-OAEP-256 or RSA-OAEP with A256GCM or A128GCM); dpop_bound_access_tokens, which requires every token to be sender-constrained; require_pushed_authorization_requests and a security_profile (fapi2 holds the client to the FAPI 2.0 Security Profile); the CIBA delivery mode for backchannel sign-in; cors_origins for browser clients calling the token endpoint; and back-channel and front-channel logout URIs (a back-channel URI must resolve to a public address, like every URL rIDM calls on a client’s behalf). See Tokens and Registering clients.

access_token_format chooses between jwt (the default) and opaque access tokens. A client with opaque receives at_... tokens that carry nothing readable and that its APIs must check at /introspect, because they cannot be validated locally (ridm-auth accepts JWTs only); see Opaque access tokens. The built-in console clients always use JWTs.

Service accounts

A client_credentials token normally has the client itself as its subject (sub is the client_id) and carries no roles. When the client needs permissions on an API, give it a service account: a user named svc-<client_id> created for the client, which can then hold roles and join groups like anyone else. Tokens from client_credentials are then issued for that user, with its roles and permissions. See Machine-to-machine access.

Dynamic client registration

A tenant can let applications register themselves (RFC 7591) at /t/{slug}/register and manage their registration afterwards (RFC 7592). The tenant’s settings.dcr.mode decides whether that is disabled (the default), open, or initial_access_token: allowed only to a caller presenting, as a bearer token, an initial access token issued by an administrator. settings.dcr.allowed_grants limits the grants a registered client may ask for, and settings.dcr.require_pkce (on by default) decides whether a dynamically registered confidential client must use PKCE; public clients always must.

Initial access tokens (iat_...) are issued with an optional description, an optional expiry and an optional limit on the number of registrations each allows; without those, a token neither expires nor runs out. Each is shown once and stored as a SHA-256 hash, and administrators with the client permissions can list and revoke them:

ridm --tenant acme client iat create --description "partner onboarding" \
    --expires-in 86400 --max-uses 5
ridm --tenant acme client iat list
ridm --tenant acme client iat revoke <id>

The admin API equivalents are GET and POST /admin/tenants/{slug}/dcr/initial-access-tokens and DELETE /admin/tenants/{slug}/dcr/initial-access-tokens/{id} (ridm:clients:read to list, ridm:clients:write to issue and revoke); the console offers the same on the Keys, discovery and audit settings page while the mode is initial_access_token. Issuing and revoking raise dcr_token.created and dcr_token.revoked. A registration with a missing, expired, exhausted or revoked token is refused with 401 invalid_token.

Built-in clients

Every tenant has two clients that rIDM manages itself: ridm-admin-console and ridm-account-console, the public clients the two consoles sign in with. Their redirect URIs follow UI_URL and are brought back in line whenever it changes. They cannot be deleted, and they are left out of tenant exports, so configuration as code never has to mention them. Their token lifetimes and CORS origins can still be tuned.

Users, groups and roles

Users are the people (and service accounts) who sign in to a tenant. Groups organise them, roles say what they may do, and permissions attach meaning to roles for a particular API. All four belong to one tenant and are invisible to every other. A tenant can also group its users into organizations, which carry membership, their own role grants and email domains.

Users

A user has a username and an optional email and phone, each unique within the tenant, each with a verified flag. Everything else about a person lives in profile attributes, which the tenant declares in its profile schema: name, type (string, number, boolean, email, url, phone, date, enum, json), validation, whether it is required, and who may edit it (editable_by: user, admin, or none for values only bulk import and SCIM provisioning set; the interactive admin console and API cannot). The registration page, the account console and the admin console all build their forms from that schema, and every write is validated against it. A schema can also allow undeclared attributes, which are then stored as they are and editable by administrators only.

Attributes reach tokens in three ways. An attribute’s schema entry can list where it appears in visible_in (id_token, userinfo, access_token); it is then emitted under the attribute’s name there, whenever the user has a value. A scope can name the attribute in its claims list, releasing it when that scope is granted (see Scopes). And a claim mapper can emit it under any name, shape or type. visible_in never overwrites a claim a scope released or one the token service owns (sub, roles, permissions and the like); mappers run last and may override what visible_in added.

A user’s status is one of:

StatusMeaning
activemay sign in
pendingcreated but not yet complete (for example, awaiting email verification)
lockedtemporarily locked after too many failed sign-ins (settings.lockout)
disabledswitched off by an administrator; disabling ends the user’s sessions
deletedsoft-deleted; purged after the tenant’s retention period

Deleting a user is a soft delete: every session, token and trusted device ends at once and the username and email are freed immediately, and a daily job removes the row for good after settings.account.deletion_retention_days (30 by default). Tokens are only issued to active users, and a refresh fails once the user is no longer active.

Passwords are hashed with argon2id. Users imported from another system keep their existing hashes (bcrypt, PBKDF2, salted SHA-2, MD5) and are upgraded to argon2id the first time they sign in; see Migrating to rIDM. A user may also have no password at all and sign in with a passkey, a magic link, a one-time code or an upstream identity provider.

Users arrive by self-registration (when settings.registration.enabled is on), by invitation, through the admin API or console, by bulk import, through SCIM provisioning, or on their first sign-in through an upstream identity provider. See Users, invitations and bulk import.

Groups

A group is a named set of users. Groups nest: each has an optional parent, and a member of a group is treated as a member of every group above it. A user in engineering/platform is also in engineering.

Groups exist to hold roles. Assign a role to a group and every member, including the members of its subgroups, holds that role. This is usually the better way to grant access: add a person to the right group rather than granting them five roles one by one, and remove them from it when they move on.

Groups also carry free-form JSON attributes, and can be emitted in tokens by name or as full paths.

Roles

A role is a named capability, such as orders-admin or support. rIDM does not interpret custom roles itself; it hands them to your applications in tokens and maps them to permissions on your APIs.

A role is either realm-wide (usable by every client in the tenant) or client-scoped (it belongs to one client, which is deleted with it). Roles are assigned to users directly or to groups.

Composite roles contain other roles. Holding a composite means holding every role inside it, recursively: grant manager, which contains viewer and approver, and the user holds all three. Cycles are refused when a composite is added.

Effective roles

A user’s effective roles are everything they hold by any route:

  1. roles assigned to the user directly;
  2. roles assigned to any group the user belongs to, or to any ancestor of those groups;
  3. every role contained in any of the above, through composites, to any depth.

Effective roles are what tokens carry, what permissions are computed from, and what the admin API checks. They are resolved once and cached under a per-tenant version token. Any change that could alter the result (a role, composite, assignment, group, membership or permission grant) replaces the token, which makes every cached resolution in the tenant unreachable at once on every node. The next request resolves afresh, so a revoked role stops counting immediately, not after a cache expiry.

Permissions

A permission is a string such as orders:read, defined on a resource server (an API) and granted to roles. When a token is issued for that API, rIDM collects the permissions that the user’s effective roles hold on it and puts them in the token’s permissions claim. The API then checks permissions, not role names, so the mapping from roles to what they allow is managed in one place.

Administrators are users with roles

rIDM’s own administration uses the same model. Every tenant carries a built-in resource server, urn:ridm:admin, whose permissions are the admin API’s (ridm:users:read, ridm:clients:write, and so on), and six built-in roles that hold them:

RoleGrants
ridm:ownereverything, including creating, deleting and importing tenants
ridm:admineverything except creating, deleting and importing tenants
ridm:user-managerusers, invitations and groups; read roles, tenant settings and audit
ridm:client-managerclients, scopes, claim mappers and resource servers; read roles, tenant settings and audit
ridm:org-adminorganizations, their members, domains and invitations; read roles — granted inside one organization it administers that one
ridm:viewerevery read permission

Built-in roles cannot be renamed, deleted or given different permissions, but they can be assigned, put in groups and used inside composites. Custom roles may be granted any admin permission, or a wildcard such as ridm:users:*. Roles held in master apply to every tenant; roles held anywhere else apply to that tenant only.

Two rules keep this safe:

  • No escalation. An administrator cannot grant a role, add a user to a group, add a composite or send an invitation that would give someone admin permissions the administrator does not hold themselves. The rule covers the bulk paths too: a bulk-import row, or an item of a tenant configuration import, that would grant such a role or group fails on its own with “cannot grant permissions you do not hold” (in a dry run as well), and a SCIM client adding members to a group that grants ridm:* permissions is refused with 403.
  • Checked on every request. The admin API reads the caller’s permissions from their effective roles on each call rather than trusting a list inside the token, so removing a role takes effect on the caller’s very next request.

See Administrator access.

Claims from users, groups and roles

Every user access token carries roles (effective role names) and groups (effective group names). Anything more, and anything in ID tokens and userinfo beyond the scope claims and visible_in attributes, comes from claim mappers, configured per tenant or per client:

Mapper typeAdds
user_attributea user value, as a string, number, boolean or JSON: attributes.department is that profile attribute; any other name is a top-level user field (username, email, …) or, failing that, the profile attribute of that name
groupseffective group names, or parent/child paths
roleseffective role names: realm-wide ones, or only those belonging to one named client
hardcodeda fixed value
templatea Handlebars template over user, tenant, client, roles and groups
audiencean extra audience on access tokens

Each mapper names where its claim goes (include_in: access, id, userinfo). Some claims are off limits, and a mapper targeting one is refused when it is saved:

  • the claims the token service owns: iss, sub, aud, exp, iat, nbf, jti, azp, at_hash, c_hash, nonce, auth_time, amr, acr, sid, tid, client_id, scope, typ, and cnf and act, which would otherwise let a mapper bind a token to a key or name an actor that never took part;
  • permissions, which is always computed from the audience’s grants;
  • roles and groups, except by a mapper of that type: a roles mapper targeting roles (or a groups mapper targeting groups) replaces the built-in claim, for example to emit only one client’s roles or group paths.

The complete claim list is in Token claims.

Organizations

An organization is a grouping inside one tenant: a customer, a business unit, a team. A tenant that sells to businesses usually has one organization per customer, with that customer’s people as its members.

Organizations are optional. A tenant with none behaves exactly as it did before they existed, and nothing about them appears in its tokens.

Organizations are not tenants

The two are easy to confuse, and the difference decides most designs:

TenantOrganization
IsolationComplete: separate issuer, users, clients, keys, settingsNone: one pool of users, clients and roles, shared settings
A user belongs toexactly oneas many as you put them in
Sign-inits own issuer and login pagesthe tenant’s, with the organization chosen during the flow
Use it forseparate products or deployments, or customers who must never share anythingcustomers or teams inside one product

If two groups of people must not see the same users, clients or keys, they are tenants. If they share an application and differ only in who belongs where, they are organizations. See Tenants and issuers.

Membership

A user may belong to several organizations of their tenant. One of them is their primary organization (org_id on the user), which is the first one they join; later memberships do not move it. Membership comes from three places:

  • an administrator adds the user (console or admin API);
  • an invitation that names an organization: accepting it makes the new user a member, and that organization their primary one;
  • a verified domain with auto-join (below).

Deleting an organization leaves its members’ accounts alone: they lose the membership, the roles granted inside it, and, if it was their primary one, they are simply left without one.

Choosing one while signing in

A session acts in one organization, and the tokens issued through it carry it as the org_id claim. The choice happens during the sign-in flow, after identity is settled (password, second factor, profile, terms) and before consent:

  • the user belongs to no organization: nothing is asked, and no claim is minted;
  • one: it is chosen silently;
  • several: the login page asks, unless the authorization request named one (below);
  • a session that already acts in an organization is never asked again; a flow resuming it inherits what it chose.

A client can name the organization it wants with an organization request parameter on /authorize, holding the organization’s slug or its id:

GET /t/acme/authorize?...&organization=northwind

A member is put there without being asked. If the user does not belong to what the request named, they are asked instead of being put somewhere else silently.

To act in another organization, the user signs in again — a session’s organization never changes under a token that already names it. The account console lists what they belong to.

Roles inside an organization

A role can be granted within an organization, to a user or to a group (role_assignments.org_id). Such a grant applies only to sessions acting in that organization: the same user signing in to another one does not hold it, and it disappears from roles in their tokens there. Grants with no organization are unscoped and apply everywhere, as they always did.

This is what makes “administrator of Northwind, ordinary member of Contoso” expressible without one role per customer — and rIDM’s own administration works the same way. A grant of ridm:orgs:read and ridm:orgs:write inside an organization makes its holder that organization’s administrator: they manage its members, domains, invitations and internal role grants, and nothing else. The tenant keeps what is the tenant’s — creating and deleting organizations, renaming one, disabling one, adding an existing user to one — and an org-scoped grant never satisfies a tenant-wide permission check. See Administrator access.

A user must be a member of the organization before a role can be granted to them inside it, and granting a role there runs the same no-escalation check as anywhere else: the administrator doing it must already hold every permission the role carries.

Email domains and auto-join

An organization can claim email domains. A domain is verified by publishing a TXT record, and a verified domain marked auto-join makes every user with a verified address at that domain a member as they sign in:

_ridm-challenge.northwind.example.  IN TXT "ridm-domain-verification=0192f…"

Two rules keep this honest: the address must be verified (an unproven email joins nobody), and the domain must be verified (an unproven domain joins nobody). A domain belongs to one organization per tenant, so auto-join always has one answer. Because the check runs at every sign-in, turning auto-join on for a domain picks up the users a tenant already has, not only new ones.

Auto-join never removes anyone: a member whose address changes keeps the membership until an administrator removes it.

What organizations are not, yet

  • Org-scoped administrators (an org admin who manages their own members and nothing else) are the next phase; today, managing organizations needs the tenant-level ridm:orgs:write permission.
  • Organizations carry no settings, branding or policy of their own. Anything of that kind is the tenant’s.

Where to look next

Resource servers, scopes and permissions

A resource server is an API that accepts rIDM access tokens: your orders service, your billing API, rIDM’s own admin API. Registering it with the tenant gives it an identifier, which becomes the aud (audience) of tokens meant for it, and a set of permissions that roles can be granted on it.

Three separate ideas meet here, and they answer different questions:

ConceptQuestion it answersWho decidesWhere it shows up
AudienceWhich API is this token for?the client asks, the client’s configuration allowsaud
ScopeWhat has the user let this application do on their behalf?the client asks, the user consentsscope
PermissionWhat is this user allowed to do in this API?administrators, through rolespermissions

Audiences: one token, one API

A resource server’s identifier is an opaque string, conventionally a URL such as https://orders.example, up to 512 characters. It cannot be changed after creation, because tokens and APIs depend on it.

An access token names the API it is for in aud, and an API must refuse tokens whose aud does not name it. That is what stops a token minted for a low-value API being replayed against a high-value one in the same tenant: both trust the same issuer and the same keys, so the signature alone cannot tell them apart. ridm-auth checks the audience for you; any other JWT library must be configured to.

A client obtains a token for an API in one of two ways:

  • Resource indicators (RFC 8707). The client sends one or more resource parameters naming resource server identifiers, at /authorize and again at /token:

    GET /t/acme/authorize?response_type=code&client_id=acme-spa
        &scope=openid%20profile&resource=https%3A%2F%2Forders.example
        &redirect_uri=...&code_challenge=...&code_challenge_method=S256
    

    A resource that is not a registered resource server, or that the client is not allowed, is refused with invalid_target. On a refresh, resource narrows the new access token to some of the audiences the original grant carried, so one refresh token obtained for several APIs can mint a narrow token for each in turn; naming an audience the grant did not carry is invalid_target, and without resource the refresh repeats the original audiences.

  • The client’s defaults. With no resource parameter, the token is issued for every audience in the client’s allowed_audiences.

Requesting a scope that is bound to a resource server (see below) also targets that resource server, on top of the audiences chosen either way.

A client with an empty allowed_audiences may request any of the tenant’s resource servers, except the built-in ones, which must always be listed explicitly. A token requested for no resource server at all has the client’s own client_id as its audience.

Scopes: what the user agreed to

Scopes are the OAuth vocabulary for delegation: openid, profile, email, phone, address, offline_access, features (feature flags), plus any the tenant defines. A client may only request scopes in its allowed_scopes, and with consent required the user approves them on the consent screen, where each scope’s description is shown. The granted scopes appear space-separated in the token’s scope claim.

Default scopes. A request that names no scope at all is given the tenant’s scopes marked is_default that the client is allowed. That applies to /authorize, the device flow and client_credentials (which never receives openid or offline_access this way). An authorization or device request that names no scope and has no default to fall back on is refused with invalid_scope; a client_credentials token may carry no scope at all.

Released claims. Every scope, standard or custom, has a claims list, and granting the scope releases those claims from the user’s record at /userinfo (and in the ID token for clients with id_token_scope_claims). A claim name is looked up as an OIDC standard claim (preferred_username is the username, phone_number the phone, email_verified, locale, updated_at, address), then as a top-level user field, then as a profile attribute of that name; attributes.<name> names a profile attribute explicitly. The standard scopes are seeded with the OIDC Core §5.4 claim sets: profile the name fields, preferred_username, locale and the rest, email the address and whether it is verified, phone the number, address the postal address. Editing a standard scope’s claims changes what it releases. Claims the token service owns (sub, aud, roles, permissions and the like) are never released this way. Claim mappers add or reshape claims on top.

Bound scopes. A scope linked to a resource server (resource_server_id) belongs to that API. Requesting it adds the resource server to the token’s audiences, and is refused with invalid_scope if the client may not target that resource server. A token that does not carry that audience (a refresh narrowed to another resource, say) has the scope dropped from its scope claim.

Standard scopes can have their descriptions and claims changed but cannot be deleted.

Permissions: what the user is allowed to do

A permission is a name defined on one resource server, such as orders:read or orders:refund, and granted to roles. When a token is issued for a resource server, rIDM takes the subject’s effective roles, collects every permission those roles hold on that resource server, and puts the result in the access token’s permissions claim:

{
  "iss": "https://id.example.com/t/acme",
  "sub": "0199a0c4-7e1b-7c52-9d0e-5f3b2c1a4d77",
  "aud": "https://orders.example",
  "scope": "openid profile",
  "roles": ["support"],
  "permissions": ["orders:read", "orders:refund"]
}

With several audiences in one token, the claim holds the union of the permissions across them.

Why permissions and scopes are separate

A scope says what the application may do for the user; a permission says what the user may do at all. An intern’s browser and a finance manager’s browser run the same single-page app, request the same scopes and get the same consent screen, but only one of them should be able to issue refunds. Encoding that in scopes would mean giving the client every scope any user might need and trusting it to ask for less. Permissions keep the decision on the server: the administrator grants orders:refund to the finance role, and the API checks for orders:refund in permissions.

An API should therefore check the audience, then the permission; it may also check the scope when it wants to know that the user delegated that kind of access to this particular application.

Token lifetime and signing per API

A resource server may set its own access token lifetime (token_ttl_secs), which overrides the client’s and the tenant’s for tokens aimed at it. A token for several resource servers gets the shortest of their lifetimes, so a sensitive API can insist on very short-lived tokens without every client having to be reconfigured.

Signing algorithm. A resource server that can only verify one algorithm sets signing_alg (RS256, RS384, RS512, ES256 or EdDSA). Access tokens for it are signed with the tenant’s active key of that algorithm; saving the resource server generates such a key if the tenant has none, and the scheduled rotation keeps it fresh like the default key (see Signing keys and the master key). Without signing_alg the tenant’s default_alg is used. A token has one signature, so a request for several resource servers that name different algorithms is refused with invalid_target: request them separately.

Offline access. allow_offline_access (on by default) decides whether a token for this API may carry offline_access, the scope that lets a refresh token outlive the user’s sign-in session (see Tokens). When any audience of a grant has it off, offline_access is dropped from the grant, and the refresh token ends with the session.

Built-in resource servers

Every tenant carries two resource servers that rIDM owns:

IdentifierUsed by
urn:ridm:adminthe admin API (/admin/...); its permissions are the ridm:* catalogue
urn:ridm:accountthe self-service account API (/t/{slug}/account/...); no permissions, a token may only act on its own subject

They cannot be changed or deleted. A client only obtains a token for either of them when it lists the identifier in allowed_audiences; an otherwise unrestricted client never gets one implied. Anyone holding an admin token can change users and clients, so this is not left to defaults.

Resource servers are cached by identifier on every node and evicted when written, and the permissions a set of roles holds are cached under the tenant’s roles version, so issuing a token does not query the permission tables on the hot path.

Tokens

rIDM issues three kinds of token at a tenant’s /token endpoint, each with one job:

TokenFormatForDefault lifetime
Access tokensigned JWT, typ: at+jwt (RFC 9068); or opaque at_... for clients that ask for itcalling an API5 minutes
ID tokensigned JWT, typ: JWT, optionally encryptedtelling the client who signed in5 minutes
Refresh tokenopaque string starting rt_getting new tokens without the user30 days

Every claim is listed in Token claims; this page explains how the tokens behave.

Access tokens

An access token is a JWT signed with the tenant’s active key (or, when its resource server asks for one, the tenant’s active key of that algorithm). An API validates it locally against the tenant’s JWKS, without calling rIDM, which is what lets token validation scale with the APIs rather than with the identity server. It carries:

  • who: sub (the user, or the client_id for a client acting as itself), client_id and azp (the client it was issued to), tid (the tenant);
  • what for: aud (the resource servers it is meant for), scope, and permissions for those resource servers;
  • about the user: roles, groups, profile attributes whose schema lists access_token in visible_in, plus anything claim mappers add;
  • about the sign-in: sid, auth_time, amr, acr;
  • the usual iss, iat, nbf, exp and a unique jti.

The typ: at+jwt header matters. ID tokens are JWTs signed with the same keys, and an API that only checked the signature and issuer would accept an ID token as if it were an access token. Requiring at+jwt (as ridm-auth does) closes that gap.

Access tokens are short-lived on purpose. A JWT cannot be recalled from every API that has already accepted it, so the lifetime is the bound on how long a leaked or revoked token remains usable. rIDM does keep a revocation list: revoking an access token at /revoke adds its jti to a denylist in Valkey until it would have expired, and rIDM’s own endpoints (/userinfo, /introspect, the account and admin APIs) refuse it at once. An API that validates tokens locally does not see that list; one that must react to revocation within seconds should call /introspect instead.

Opaque access tokens

A client registered with access_token_format: opaque receives access tokens of the form at_ followed by 256 random bits instead of JWTs. The claims a JWT would have carried are kept in Valkey under the token’s SHA-256 hash until the token expires, so nothing needs cleaning up; the price is that losing Valkey ends every opaque token early, the same way it ends SSO sessions.

An opaque token reveals nothing to whoever holds it and cannot be validated locally: a resource server must call /introspect for every token (or cache the answer briefly). ridm-auth validates JWTs only, so an API built on it needs its callers’ clients to use the default jwt format. rIDM’s own endpoints accept opaque tokens wherever they accept JWT access tokens: /userinfo, /introspect, /revoke (which deletes the entry at once), token exchange (as urn:ietf:params:oauth:token-type:access_token, never the jwt type), and the account and admin APIs. The built-in console clients cannot be switched to opaque.

ID tokens

An ID token is addressed to the client (aud is its client_id) and says who signed in and how: sub, auth_time, amr, acr, sid, tid, nonce when the request carried one, and at_hash binding it to the access token issued with it. It is issued whenever the granted scopes include openid and there is a user.

The claims the granted scopes release are served by /userinfo, not put in the ID token, because an access token is always issued alongside (OIDC Core §5.4). Each scope releases the claims named in its claims list; the standard scopes (profile, email, phone, address) are seeded with the OIDC claim sets, and an administrator can edit those lists like any other (see Scopes). A client that wants the scope claims in the ID token as well sets id_token_scope_claims. Profile attributes whose schema lists id_token or userinfo in visible_in are added to that token or response under the attribute’s name, without overwriting a claim a scope released; claim mappers run last and may override either.

Subject identifiers. With subject_type: public (the default), sub is the user’s id, the same for every client. With pairwise, each client (or each group of clients sharing a sector_identifier_uri) sees a different, stable sub for the same user, derived from the sector, the user and a per-tenant secret salt, so unrelated clients cannot correlate their users.

Encryption. A client that registers its own public key can have its ID tokens encrypted to it (JWE, RSA-OAEP-256 or RSA-OAEP with A256GCM or A128GCM), for deployments where the front channel must not see identity claims.

Lifetimes

Each lifetime has a tenant default under settings.session (access_token_ttl_secs, id_token_ttl_secs, refresh_token_ttl_secs), which a client can override with its own value. For access tokens, a resource server can override both: a token for a resource server with token_ttl_secs set lives that long, and a token for several resource servers gets the shortest of their lifetimes. A token obtained by token exchange never outlives the token it was exchanged for.

Refresh tokens

A refresh token is an opaque random string (rt_ followed by 256 random bits). rIDM stores only its SHA-256 hash, so a database leak does not yield usable refresh tokens. It is issued by the authorization code and device grants when the client is allowed the refresh_token grant; it is never issued by client_credentials (the client can simply ask again) or by token exchange.

Rotation. Every use returns a new refresh token and spends the old one. The new token belongs to the same family (everything descending from one sign-in) and keeps the family’s original expiry: rotation never extends a session beyond refresh_token_ttl_secs from the original sign-in. A refresh may narrow the scope but never widen it, and may name a resource to narrow the new access token’s audience to one or more of the original grant’s audiences, never a new one. A scope or resource outside the original grant is refused (invalid_scope or invalid_target) before the refresh token is spent, so the client can retry with the same token. ID tokens minted from a refresh repeat the original auth_time, amr and acr, which the family stores.

Reuse detection. Presenting a refresh token that has already been spent means two parties hold it, and rIDM cannot tell which is legitimate. It assumes theft: the whole family is revoked, the request fails with invalid_grant, and a token.refresh_reuse_detected event is raised. The legitimate client’s next refresh fails too, and the user signs in again. This is the OAuth 2.0 Security BCP’s answer to refresh tokens held by public clients, which cannot authenticate themselves.

Session binding and offline_access. A refresh token issued without the offline_access scope lives only as long as the SSO session it was issued in (OIDC Core §11): once that session ends in any way, by sign-out, idle timeout or absolute timeout, the next refresh revokes the family and fails with invalid_grant. Each refresh counts as activity and extends the session’s idle window, so an application in active use keeps its session alive. A refresh token granted offline_access outlives the session’s timeouts, up to its own refresh_token_ttl_secs, but not an explicit sign-out. offline_access is only granted when the client is allowed it and every resource server the token is for has allow_offline_access on; otherwise it is dropped from the grant rather than refused. A device-flow client that has to keep working for days (a TV, a CLI) should request offline_access, because the browser session that approved it will time out long before.

A refresh token is also bound to the client it was issued to, and stops working when the user is no longer active, when its session is signed out (by the user, “sign out everywhere”, an administrator revoking it, a password change or reset that ends other sessions, or the user being disabled or deleted), whether or not it carries offline_access, when the user withdraws the client’s consent, or when it is revoked at /revoke, which revokes the whole family. A code exchanged after its session was signed out is refused.

Authorization codes are single-use as well. Replaying a code revokes everything its first exchange produced: the refresh family, and the access token through the denylist (RFC 6749 §4.1.2).

Introspection and revocation

/introspect (RFC 7662) answers for access tokens (JWT or opaque), refresh tokens and personal access tokens, to an authenticated confidential client. ID tokens are not access tokens and are always {"active": false}. A client learns the details of a token only if the token was issued to it or names one of its audiences; anything else, including unknown, expired and revoked tokens, is {"active": false}. /revoke (RFC 7009) accepts access and refresh tokens and answers success for unknown ones, as the RFC requires.

DPoP: sender-constrained tokens

A bearer token works for whoever holds it. DPoP (RFC 9449) binds a token to a key the client holds, so a stolen token is useless without the private key.

The client sends a DPoP header on the token request: a short JWT signed with its key, naming the method and URL. rIDM binds every token of the response to that key’s thumbprint: the access token carries cnf.jkt, token_type is DPoP, and a public client’s refresh token is bound too, so a later refresh needs a proof from the same key (a proof from another key is refused and leaves the refresh token unspent). Introspection reports cnf.jkt and token_type DPoP. At a resource, the client sends a fresh proof with every request, including ath, the hash of the token.

Proofs are single-use within a five-minute window, may be at most five minutes old and thirty seconds in the future, and must match the request’s method and URL (the query is ignored; the tenant’s custom domain and the /t/{slug} form both count). /userinfo, the account API and the admin API refuse a bound token presented as a plain bearer token, without a proof or with another key’s proof, with a DPoP challenge (WWW-Authenticate: DPoP algs=..., see Errors). A client registered with dpop_bound_access_tokens must always use DPoP. Server-provided DPoP nonces and the dpop_jkt authorization parameter are not implemented.

ridm-auth refuses tokens carrying cnf.jkt rather than accepting them as bearer tokens, because it verifies no proofs.

Token exchange

Token exchange (RFC 8693) lets a client that is allowed the urn:ietf:params:oauth:grant-type:token-exchange grant trade an access token of the tenant for a new one aimed at another API: typically a service that received a user’s token and needs to call a downstream service as that user.

  • The new token keeps the subject, the session and the sign-in context (amr, acr), never outlives the original, and can only narrow the scope.
  • Unlike every other grant, an empty allowed_audiences does not mean “any”: the exchanging client may only obtain tokens for audiences it lists, because the token it presents may have been minted for someone else.
  • The subject token is an access token of the tenant, a JWT or an opaque at_... token (see HTTP endpoints for the token types). A revoked, expired or foreign subject token is invalid_grant; an unsupported token type is invalid_request.
  • With an actor_token, the result is a delegation: its act claim names the acting party (sub, client_id), nesting any earlier act on re-exchange.
  • A DPoP-bound subject token can only be exchanged by a request proving the same key, so exchange cannot strip a binding.

Other tokens

Two further credentials are not issued by /token but are accepted by rIDM’s own APIs: personal access tokens (rpat_...), long-lived tokens users mint in the account console for scripts and the ridm CLI, whose permissions are narrowed at every use to what the user still holds; and SCIM tokens (rscim_...) for provisioning systems. Both are shown once and stored hashed. See Administrator access and SCIM provisioning.

Signing keys and the master key

rIDM holds two kinds of key material, with very different jobs:

  • Signing keys belong to one tenant each. They sign the tenant’s tokens, and their public halves are published so that anyone can verify those tokens. They rotate routinely and without anyone noticing.
  • The master key belongs to the deployment. It encrypts every secret rIDM stores (signing keys’ private halves among them). It is never stored in the database, and losing it means losing those secrets.

Signing keys

Every tenant has its own set of signing keys, so a key compromised in one tenant says nothing about another, and each tenant can choose its own algorithm and rotation schedule. The tenant’s settings.keys policy decides:

SettingDefaultMeaning
default_algRS256algorithm for new keys: RS256, RS384, RS512, ES256 or EdDSA (Ed25519)
rsa_bits2048modulus size for new RSA keys: 2048, 3072 or 4096
rotation_interval_days90rotate the active key once it is this old (0 = never automatically)
retire_overlap_hours24how long a replaced key stays published

RS256 is the default because every OIDC library supports it; ES256 and EdDSA give much smaller keys and signatures for relying parties that support them. Tokens are signed with the tenant’s active key for default_alg, with one exception: an access token for a resource server that names its own signing_alg is signed with the tenant’s active key of that algorithm, which is generated when the resource server is saved if the tenant has none yet. A key’s kid is its RFC 7638 thumbprint.

A tenant’s first key is generated the first time it is needed. On a multi-node deployment exactly one node generates it while the others wait for it, and the database allows only one active key per tenant and algorithm, so there is never a token signed with a key missing from the published set.

Lifecycle

A key moves through four states:

StatusSigns tokensPublished in JWKS
pendingnoyes
activeyesyes
retiringnoyes, until its overlap ends
revokednono

The published set, /t/{slug}/.well-known/jwks.json, holds every pending, active and retiring key. It is served with an ETag and Cache-Control: max-age=300, and the cached document is replaced the moment any key changes.

Rotation creates a key with the tenant’s policy, makes it active, and moves the previous active key of the same algorithm to retiring, where it stays published for retire_overlap_hours. The overlap exists for the tokens the old key already signed: they keep verifying until they expire. Since access and ID tokens live minutes, the default day is generous. After the overlap, the hourly key_rotation job revokes the old key, and the same job rotates every active key older than rotation_interval_days, one per algorithm in use, so a key kept for a resource server’s signing_alg rotates on the same schedule as the default one.

A rotated key signs immediately. Relying parties that cache the JWKS must refetch it when a token names a kid they have not seen (ridm-auth does, and most JWT libraries can be configured to). A relying party that cannot do that can be given time instead: create the new key as pending (published, not signing), wait until every relying party has picked it up, then activate it.

Revoking a key unpublishes it at once, and every token it signed stops verifying at rIDM and at every relying party that refetches. That is the response to a leaked key, not a routine operation.

Changing default_alg takes effect at the next signing: a key for the new algorithm is generated and used, while the old algorithm’s key stays active and published until you retire it.

See Rotating keys for the console, CLI and API steps.

The master key

Secrets rIDM must be able to read back are encrypted at rest under the master key:

WhatWhere
Signing keys’ private halvessigning_keys
MFA secrets: authenticator app seeds, passkeys, recovery codes (hashed, then encrypted), email and SMS factor recordscredentials
SMTP, SMS gateway and CAPTCHA credentialstenant_provider_settings
Upstream identity provider client secretsidentity_providers
Webhook signing secretswebhooks

Secrets rIDM only ever needs to compare are hashed instead and cannot be decrypted at all: passwords (argon2id), client secrets, refresh tokens, personal access tokens, SCIM tokens and dynamic registration initial access tokens (SHA-256). Opaque access tokens are not stored in the database at all: Valkey holds their claims under the token’s SHA-256 hash.

Encryption is XChaCha20-Poly1305 with a fresh random nonce per value. rIDM is moving to AES-256-GCM under a key derived for each value (HKDF-SHA-256 from the master key and a random salt): every server reads both, the FIPS build already writes AES-256-GCM, and the standard build will from the next release. Each ciphertext is bound to its table, tenant and row as associated data, so a ciphertext copied into another row, or another tenant’s row, fails to decrypt rather than being read in the wrong place. Private signing keys are decrypted only on the node that signs with them and kept in memory there, never in Valkey.

The master key is 32 bytes, supplied as hex or base64 in MASTER_KEY or in a file named by MASTER_KEY_FILE (for container secrets). It must be the same on every node. rIDM never writes it anywhere, so back it up separately from the database: a database backup without its master key can be restored, but its signing keys, MFA enrolments, IdP secrets and webhook secrets cannot be read. A node checks at start-up that its keys decrypt the database’s signing keys and refuses to start if they do not. See Backup and restore, which also covers recovering from a lost key.

Rotating the master key

Every ciphertext records the master-key generation (MASTER_KEY_VERSION) that produced it, and a node can hold previous generations for decryption, so the master key rotates without downtime:

  1. Roll out a new MASTER_KEY with MASTER_KEY_VERSION incremented, keeping the old key in MASTER_KEY_PREVIOUS as <old version>=<old key>. New writes use the new generation; existing rows still decrypt.
  2. Re-encrypt every row under the current generation: ridm-api rotate-master-key on any node, or ridm master-key rotate over the admin API. ridm-api rotate-master-key --status (or ridm master-key status) shows how many rows remain on each generation.
  3. Once nothing remains on the old generation, remove it from MASTER_KEY_PREVIOUS.

See Rotating keys.

Key custody

Instead of the environment, an HSM (PKCS#11) or a key-management service (AWS KMS, Vault / OpenBao Transit, Google Cloud KMS, Azure Key Vault) can hold the master key (KEY_WRAPPER). Each generation is then a random data key the backend wrapped, stored wrapped in master_key_generations; nodes unwrap it at start-up, so the backend is never on a request’s path, and neither the configuration nor a backup can decrypt anything without it. Generations from the environment and from a backend coexist, which is how a deployment moves onto one online. See Key custody: HSM and KMS.

Sign-in flows and sessions

Signing a user in involves three parties: the application (the client), the rIDM API, and the login pages the user actually sees. rIDM keeps the pages and the API apart. The pages are a static site that holds no secrets and makes no decisions; the API holds all the state and all the rules; and the two are connected by a short-lived login flow.

The flow-handoff pattern

 client              rIDM API                          login pages (UI_URL)
   |  /authorize ------> validate the request
   |                     create flow {id} (Valkey, 10 min)
   |  <------------------ 302 to /login/?tenant=acme&flow={id}
   |                                                   GET  /t/acme/flows/{id}
   |                                                   POST /t/acme/flows/{id}/password
   |                                                   POST /t/acme/flows/{id}/mfa/verify
   |                                                   ... until stage = done
   |                     GET /t/acme/flows/{id}/finish  <-- browser follows finish_url
   |  <------------------ code (or error) to redirect_uri
  1. The client sends the browser to /t/{slug}/authorize. rIDM validates everything it can up front: the client, the exact redirect URI, the response type, scopes, PKCE, resource indicators. A bad client or redirect URI is shown as an error page and never redirected, so rIDM never sends users to an address it has not verified.
  2. If the browser already has a live session that satisfies the request, and that session owes no step (see The SSO session), rIDM issues the authorization code at once. Otherwise it stores the validated request as a login flow in Valkey (ten minutes to live) and redirects the browser to the login pages with nothing but the tenant and the flow id: {UI_URL}/login/?tenant=acme&flow=<id> (or /register/, /consent/, /mfa/ when that is where the flow starts).
  3. The page reads the flow’s public state from GET /t/{slug}/flows/{id}, which says what stage the flow is at and what to show: the client’s name and logo, the sign-in methods offered, a CAPTCHA if one is due, the scopes awaiting consent, the attributes still missing, the negotiated language.
  4. The page posts each step to the flow API (/flows/{id}/password, /flows/{id}/mfa/verify, /flows/{id}/consent, …). Every step carries the flow’s CSRF token, and the API answers with the new state.
  5. When the stage is done, the state includes a finish_url. The browser follows it; rIDM issues the code and redirects to the client’s redirect_uri in the requested response mode.

A flow passes through the stages that apply to it: authenticate (or register), verify_email, password_change, mfa, profile, terms, organization (only when the user belongs to more than one and the request named none — see Organizations), consent, and finally done. The API decides the next stage; the page only renders it.

A tenant with adaptive authentication on scores each sign-in as its first factor passes. A score past the step-up threshold adds the mfa stage even where the MFA policy would not; a score past the block threshold ends the flow there and then — no session is opened, the flow is discarded, and the browser goes back to the client with access_denied.

Why it works this way

  • The UI can be any static host. The pages are a static export with no dynamic routes and no server code, so they can sit on a CDN, on any web server, or behind the same host as the API. Only UI_URL has to point at them.
  • The pages hold nothing worth stealing. The authorization code is minted by the API at finish and goes straight to the client; tokens never pass through the login pages. A compromised page can show the wrong thing but cannot mint a token.
  • Every rule is enforced in one place. Password policy, lockout, CAPTCHA, MFA policy, required attributes, terms and consent are decided by the API. A custom login page cannot skip a step, because the flow will not reach done without it.
  • One mechanism for every entry point. The device flow, invitations, identity brokering and step-up authentication all create or resume a login flow; the same pages and the same rules serve all of them.

Flows are rate-limited per address, count failed attempts (a CAPTCHA is demanded after settings.captcha.after_failures failures, three by default, and five wrong second-factor codes discard the flow), and expire after ten minutes, after which the user starts again from the application.

Endpoint details are in HTTP endpoints.

The SSO session

Once a user has authenticated, rIDM opens a single sign-on session: the record that this browser is signed in to this tenant, as this user, since this time, by these methods (auth_time, amr, acr). The next application that sends the browser to /authorize finds the session and gets its code without the user typing anything.

  • The session lives in Valkey, where every node can read it on the hot path, and is mirrored to Postgres so that it can be listed, ended from the consoles, and audited. Activity slides the idle window in Valkey at most once a minute and in Postgres at most once per quarter of the idle timeout (15 minutes at most), so a listing’s “last seen” can trail by that much.
  • The browser holds only a reference, in an HttpOnly, SameSite=Lax cookie with Path=/, named per tenant: __Host-ridm_session_{slug} (for example __Host-ridm_session_acme), or ridm_session_{slug} when COOKIE_SECURE=false, for plain-http development. The __Host- prefix makes browsers insist on Secure, Path=/ and no Domain, so no other host can set or overwrite the cookie; the slug in the name keeps tenants on one host apart, and Path=/ lets the same cookie work on a tenant’s custom domain, where the tenant’s pages are not under /t/{slug}. One browser has at most one session per tenant, and sessions of different tenants never share a cookie.
  • The session opens when the first factor succeeds and is upgraded in place when a second factor passes, so its amr and acr always describe what actually happened.
  • A session that has not finished its sign-in is not good for anything. Every time /authorize (or a device-flow approval) is about to use a live session, rIDM checks it against the tenant’s MFA policy as it stands now and against a pending forced password change. A browser that abandoned the flow after the password, a policy tightened after the user signed in, or a role granted since that falls under required_for_roles, all send the user back to the owed step (the second factor or the password change) before any code is issued; under prompt=none the answer is login_required. A trusted device waives a policy-driven second factor here exactly as it does in the flow. A session whose user has since been disabled or deleted gets no code at all: the user is asked to sign in afresh.
  • An administrator may open a session as a user (impersonation). It owes none of the user’s steps, carries no amr, names the administrator in every token’s act claim, and ends after the tenant’s impersonation.max_minutes.

The request parameters that interact with the session behave as OIDC specifies: prompt=login and max_age force a fresh sign-in, prompt=none never shows a page (it answers login_required or consent_required instead, which is how single-page apps renew silently), prompt=consent asks for consent again, and prompt=create starts at registration.

Session policy

settings.session governs how long sessions last:

SettingDefaultMeaning
idle_timeout_secs1800 (30 minutes)the session ends after this long without use; each use restarts the clock
absolute_timeout_secs43200 (12 hours)the session ends this long after it opened, however active
max_concurrent0 (unlimited)sessions one user may hold; opening one more revokes the oldest
remember_device_days30lifetime of a trusted device

The same section holds the default token lifetimes; see Tokens.

A session ends when either timeout passes, when the user signs out, when the user or an administrator ends it from a console (one session or all of them), when the user changes their password and chooses to sign out elsewhere, when an administrator sets a password with revoke_sessions, when the user resets a forgotten password (which signs them out everywhere), when the account is disabled or deleted (including through SCIM), or when opening a new session pushes the user over max_concurrent and the oldest is evicted.

Signing a session out revokes every refresh token issued in it, including those granted offline_access, and sends back-channel logout to the clients that took part (see Signing out); an authorization code issued in the session can no longer be exchanged. A session that merely times out leaves offline_access refresh tokens working, while refresh tokens without it stop at the next refresh (see Tokens).

Trusted devices. On the sign-in page a user can ask rIDM to remember the browser. The device is registered, and its own cookie (__Host-ridm_device_{slug}, or ridm_device_{slug} over plain http, also Path=/) set, only once the whole flow has completed, including any second step, so an attacker who knows only the password cannot make their browser trusted. A trusted device skips the tenant’s MFA policy on later sign-ins (a client’s explicit step-up request is still honoured) and suppresses the new-device notice. Users see and revoke their trusted devices in the account console.

Signing out

rIDM supports all three OpenID Connect logout mechanisms:

  • RP-initiated logout. The client sends the browser to /t/{slug}/end_session. With an id_token_hint that matches the browser’s session, the session ends at once and the browser goes to the client’s registered post_logout_redirect_uri. Without one, the login pages ask the user to confirm first, so a third-party page cannot sign users out behind their backs.
  • Back-channel logout. Every client registered with a backchannel_logout_uri that took part in the session receives a signed logout token, server to server, so it can end its own session for the user. This happens on every path that signs a session out, not only end_session: the user signing out one or all sessions from the account console, an administrator revoking one or all of a user’s sessions, a password change that signs out other sessions, an administrator password reset with revoke_sessions, a recovery password reset, the user being disabled or deleted (by an administrator or through SCIM), and eviction under max_concurrent. The logout URI must resolve to a public address (see Outbound requests).
  • Front-channel logout. Clients registered with a frontchannel_logout_uri are loaded in the logout page, for applications that can only clear their state in the browser. It only works where the browser still sends the application’s cookies to a frame embedded in another site: Safari (Intelligent Tracking Prevention), Firefox (Total Cookie Protection), private windows in every browser and Android WebView do not, so the frame loads without the application’s session and the user stays signed in there. Nothing on the identity provider’s side can change that; register a back-channel logout URI instead wherever the application can receive one.

Signing out ends the SSO session and revokes its refresh tokens. Access tokens already issued remain valid until they expire, as with any JWT; rIDM’s account and admin APIs additionally refuse tokens whose session has ended.

MFA and passkeys

rIDM separates two questions: how may a user prove who they are first, and when must they prove it a second way. The first is the tenant’s choice of sign-in methods; the second is its MFA policy, plus whatever an individual application demands for a sensitive action.

First factors

settings.auth decides what the login page offers:

SettingDefaultMethod
passwordonusername or email, and password
magic_linkoffa one-time link sent by email
email_otpoffa six-digit code sent by email
sms_otpoffa six-digit code sent by text message
passkeyoffa passkey (WebAuthn discoverable credential), no username needed

A user can also sign in through an upstream identity provider, which counts as a first factor of its own.

Second factors

settings.mfa_methods decides which second-step methods users may enrol:

SettingDefaultMethod
totponan authenticator app (RFC 6238: SHA-1, six digits, 30-second steps, one step of drift either way; each code accepted once)
email_otpoffa six-digit code to the account’s verified email address
sms_otpoffa six-digit code to the account’s verified phone number

Passkeys are offered as a second step whenever settings.auth.passkey is on.

Enrolling proves the channel: an email or SMS factor is only saved once a code sent to it has been entered, and the address or number is then marked verified. Emailed and texted codes are stored hashed, bound to the flow, and good for ten minutes and five attempts; sends are limited to three per ten minutes per user. A second send within twenty seconds of the first reuses the pending code instead of sending another. The flow state lists the methods the mfa stage offers this user under mfa.methods, beside the factors already enrolled (mfa.factors).

The first second factor a user enrols also issues ten single-use recovery codes, shown once. Any one of them replaces the second factor for one sign-in, and the user is told how many remain. Codes are hashed, then encrypted; authenticator seeds and passkeys are encrypted per row under the master key.

MFA policy

settings.mfa decides who is asked for a second step at sign-in. It is a tagged object, for example {"mode": "required"} or {"mode": "required_for_roles", "roles": ["finance"]}:

modeWho is asked
off (default)nobody, unless the application asks for it
optionalusers who have enrolled a second factor
requiredeveryone; a user without a factor enrols one during this sign-in
required_for_rolesholders of any listed role (directly, through a group, or through a composite); everyone else as optional
required_for_adminsanyone holding any admin-console (ridm:*) permission; everyone else as optional

required_for_admins is the least disruptive way to protect a tenant: the accounts that can change other accounts must use a second factor, and nobody else is forced to.

The policy is enforced on live sessions too, not only at sign-in. Whenever /authorize or a device-flow approval is about to use an existing SSO session, rIDM checks it against the policy as it stands at that moment. Tightening the policy, or granting a user a role that required_for_roles names, therefore takes effect at their next authorization request: a session without a second factor is sent to the second step before any code is issued (or answered with login_required under prompt=none). The same check stops a browser that closed the sign-in page after the password from using its half-finished session. See The SSO session.

A trusted device (a browser the user asked rIDM to remember, after a complete sign-in including any second step) skips the policy-driven second step for settings.session.remember_device_days. See Sign-in flows and sessions.

The tenant’s risk policy can demand the second step for one sign-in whatever the mode above says, when that sign-in scores as unusual — from a new country, after impossible travel, on a new device, or from an address behind a run of failures. A trusted device does not waive a step-up the risk policy asked for: the cookie says which browser this is, not who is holding it. See Adaptive authentication.

Step-up: acr and amr

Applications learn how a user signed in from two claims, carried in ID tokens and access tokens:

  • amr (authentication methods references, RFC 8176) lists the methods used:

    ValueMeaning
    pwdpassword
    otpa one-time code or link (authenticator app, email, SMS, magic link)
    smsthe code came by text message (alongside otp)
    hwka passkey (hardware- or platform-bound key)
    userthe passkey verified the user (PIN or biometric)
    fedan upstream identity provider
    mfamore than one factor was used
  • acr (authentication context class) is urn:ridm:acr:single for a one-factor session and urn:ridm:acr:mfa once a second factor has passed. Both are advertised in discovery as acr_values_supported.

An application that needs a second factor for a particular action (approving a payment, opening an admin page) does not have to rely on the tenant policy. It sends the user back through /authorize with acr_values=urn:ridm:acr:mfa. Any requested class ending in :mfa is treated as a demand for a second factor, so an application may use its own class name (urn:example:mfa), and the token then reports that class. This step-up:

  • is always honoured, even on a trusted device and even with the tenant policy off;
  • skips the password when the browser’s session is otherwise fresh, sending the user straight to the second step;
  • answers login_required under prompt=none, because a second step needs the user.

acr_values is a preference list: rIDM honours the first class it recognises, so a request that lists urn:ridm:acr:single before urn:ridm:acr:mfa does not demand a second factor. Classes rIDM does not recognise are ignored, and the token reports the class the session actually holds. An application must check the acr it receives rather than assume its request was met.

Passkeys

A passkey is a WebAuthn credential kept by the user’s device or password manager. rIDM uses passkeys in two ways:

  • As a sign-in on their own. With settings.auth.passkey on, the login page offers “Sign in with a passkey”. The browser offers the user’s discoverable credentials for the site; rIDM finds the account from the credential the authenticator presents, checks that the user handle names the same account, and opens the session. User verification is required, so a passkey sign-in is already two factors (amr: hwk, user, mfa) and no second step follows.
  • As a second step after a password or code, for users who enrolled one.

The relying party id that passkeys are bound to is the host of the login pages (UI_URL), or the tenant’s custom domain when it has one. A passkey created for one host will not work on another, so settle on the login host before users start enrolling passkeys. Each passkey’s signature counter and backup flags are stored and the counter is checked on every use. Challenges live in Valkey for five minutes, bound to the flow, and are spent by the first answer.

Managing factors

Users enrol and remove factors and regenerate recovery codes in the account console; the recovery codes go with the last factor removed. Every change of factors (and every other security change in the account console) requires a sign-in from the last fifteen minutes, including the second step when the account has one; otherwise the console sends the user through sign-in again first. Users are notified (by email, or by text message when they have only a verified phone) when a factor is added or a recovery code is used, unless the tenant has switched that notice off (settings.notifications.mfa_changed).

Administrators can list a user’s factors (never the secrets) and remove one, for a user who has lost their device. As in the account console, removing a user’s last second factor removes their recovery codes with it, so no orphaned codes are left to stand in for a factor the user no longer has. See MFA policy.

Identity brokering

Identity brokering lets users sign in to a tenant with an account they already have elsewhere: Google, Microsoft, GitHub, Apple, GitLab, a partner’s OpenID Connect provider, or any OAuth 2.0 service with a userinfo endpoint. rIDM stands between that upstream provider and your applications.

Your applications never talk to the upstream provider. They send users to rIDM as always and receive rIDM tokens, with rIDM’s subject identifiers, roles and permissions. Adding or removing a provider changes the login page, not the applications, and the tenant’s own policies (MFA, terms, required profile attributes, consent) still apply to brokered sign-ins.

Providers

Identity providers are configured per tenant (admin console: Identity providers; admin API: /admin/tenants/{slug}/identity-providers). Each has an alias, used in its URLs, and one of three kinds:

KindHow the identity is proved
oidcan ID token, verified against the provider’s published keys
oauth2the provider’s userinfo endpoint, read with the access token
samla signed SAML assertion posted to rIDM’s assertion consumer service; see SAML identity providers (upstream)

A preset (google, microsoft, github, apple, gitlab) fills in the kind, endpoints, scopes and claim mappings the provider needs; for any other OpenID Connect provider, giving its issuer is enough, and the endpoints are discovered from it. You register rIDM with the provider as a client, using the callback URL rIDM reports for the provider ({issuer}/broker/{alias}/callback), and give rIDM the client id and secret. The secret is stored encrypted under the master key and never returned.

Upstream endpoints must use https; plain http is accepted only for loopback hosts, for development and tests. Because a tenant administrator chooses these URLs, rIDM only connects to them at public addresses, follows no redirects and ignores proxy settings (see Outbound requests): a provider on a private network cannot be used.

Providers that are enabled and not hidden appear on the login page as “Continue with …” buttons, in sort_order. A hidden provider still works through a direct link, which suits a partner’s employees who arrive from a link on the partner’s intranet.

What happens at sign-in

  1. The user presses “Continue with Google”. The browser goes to /t/{slug}/broker/{alias}/start?flow={id}, carrying the current login flow.
  2. rIDM redirects to the provider’s authorization endpoint with a fresh state (whose record in Valkey remembers the flow), a nonce and a PKCE challenge, and sets a short-lived SameSite=Lax cookie binding the sign-in to this browser.
  3. The provider returns the browser to /t/{slug}/broker/{alias}/callback (GET, or POST for providers using form_post, such as Apple; a posted answer is kept for a moment and continued by a same-site GET to …/callback?continue=, since a cross-site POST carries no Lax cookie). The callback signs in only the browser holding the binding cookie: a callback URL someone else obtained, opened in another browser, is refused with broker_error=invalid_state (login CSRF).
  4. rIDM redeems the code, authenticating as the provider’s token_endpoint_auth_method says (client_secret_basic, client_secret_post, or none for PKCE alone). For oidc providers it verifies the ID token’s signature against the provider’s JWKS (cached for an hour, refetched once for an unknown kid), its issuer (a Microsoft provider configured with the common, organizations or consumers issuer accepts any directory’s), audience, expiry and nonce. For oauth2 providers it reads the userinfo endpoint (for GitHub, the primary verified address comes from /user/emails).
  5. rIDM resolves the upstream identity to a local user (below), then the login flow carries on as after any first factor: second step if the policy asks for one, required profile attributes, terms, consent.

A brokered sign-in records amr: ["fed"]. rIDM does not import the upstream provider’s own authentication methods: if a second factor is required, the user passes rIDM’s.

Linking upstream identities to users

An upstream identity is the pair (provider, subject). Once linked to a local user, that user signs in with it from then on, whatever the email address says.

The first time an identity arrives, the provider’s link_policy decides what to do when the tenant already has an account with the same email address:

link_policyExisting account with that emailNo such account
verified_emaillinked, if both the upstream provider and the local account have verified the address; otherwise refuseda new account is created
explicitrefused; the user signs in the usual way and links the provider from the account consolea new account is created
always_newa new account is created, without the email addressa new account is created

A refused sign-in returns to the login page with broker_error=email_in_use.

The default choice, verified_email, is the one that is safe to automate: linking on an unverified address would let anyone who can register that address at the upstream provider take over the local account. trust_email tells rIDM to treat the provider’s addresses as verified even when it does not say so; set it only for providers that really do verify every address, such as your own corporate directory.

A new account takes its username from the mapped username claim, else the email address, else {alias}-{subject}, with a random suffix if the name is taken. Mapped profile attributes are written on every sign-in, so changes upstream flow through.

Users see their linked identities in the account console, can link further providers there (which sends them through the provider once) and unlink them. Administrators can list and unlink a user’s identities from the user’s detail page.

Claim mapping

Each provider’s mappers name the upstream claims to read, looked up in the ID token first and then in userinfo, with a dot descending into objects:

MapperDefault
subjectsub (id for GitHub)
usernamepreferred_username
emailemail
email_verifiedemail_verified
attributesprofile attribute name → upstream claim

Mapped attributes are validated against the tenant’s profile schema like any other write. Values the schema refuses are dropped and logged rather than failing the sign-in.

Configuration as code

Identity providers are part of the tenant configuration document, without their client secrets, so a tenant can be reproduced in another environment and the secrets set there afterwards.

Events: identity_provider.created, .updated and .deleted, identity.linked, identity.unlinked and login.brokered (and logout.upstream when a SAML IdP’s logout request ends sessions). An LDAP or Active Directory directory is a provider of kind ldap whose users sign in with the password form; see LDAP and Active Directory. A Kerberos realm is a provider of kind kerberos: the login page asks the browser for a ticket (HTTP Negotiate), with no redirect; see Kerberos desktop sign-in.

Configuration as code

Everything an administrator configures in a tenant can be written down as one JSON document, kept in version control, reviewed like code, and applied to any rIDM deployment to produce the same tenant. Applying it is idempotent: applying the same document twice changes nothing the second time.

This is how you keep staging and production in step, rebuild a tenant from scratch, review a change to a client’s redirect URIs before it happens, and avoid configuration that exists only because someone once clicked it into a console.

The document

A tenant document has the format tag ridm.tenant/1 and one section per kind of configuration:

{
  "format": "ridm.tenant/1",
  "tenant": {
    "slug": "acme",
    "display_name": "Acme Corporation",
    "settings": { "mfa": { "mode": "required_for_admins" } }
  },
  "resource_servers": [
    {
      "identifier": "https://orders.example",
      "name": "Orders API",
      "permissions": [{ "name": "orders:read", "description": "See orders" }]
    }
  ],
  "roles": [
    { "name": "orders-reader", "permissions": ["https://orders.example#orders:read"] }
  ],
  "groups": [
    { "path": ["staff", "support"], "roles": ["orders-reader"] }
  ],
  "clients": [
    {
      "client_id": "acme-spa",
      "name": "Acme SPA",
      "client_type": "spa",
      "redirect_uris": ["https://app.acme.example/callback"],
      "allowed_audiences": ["https://orders.example"]
    }
  ]
}
SectionContains
tenantdisplay name and the complete settings document
profile_schemathe user profile attributes
resource_serversAPIs and their permissions
scopescustom scopes, and changes to the standard ones
clientsOAuth clients, including whether each has a service account
rolesroles, their composites and their permission grants
groupsthe group tree and each group’s roles
claim_mapperstenant-wide and per-client claim mappers
message_templatesemail and SMS template overrides
webhookswebhook endpoints and the events they receive
ip_rulestenant-wide and per-client IP allow and deny rules
identity_providersupstream providers for brokering, directories and Kerberos realms (without their secrets)

The complete schema is in Tenant configuration document, and examples/demo-tenant.json is a working example.

Natural keys, not ids

Every object is identified by what it is called, never by a database id: a client by its client_id, a resource server by its identifier, a role by its name (client_id/name for a client-scoped role), a group by its path from the root (["staff", "support"]), a permission by <resource server identifier>#<permission name>. That is what makes a document portable: the same file means the same thing in a tenant where every row has a different id. The tenant.slug in the document is informational; an import always applies to the tenant named in the request.

Deterministic export

An export orders everything the same way every time, so exporting an unchanged tenant twice produces identical files and a git diff between two exports shows exactly what changed.

What is not in it

The document holds configuration, not data and not secrets:

  • No secrets. Client secrets, webhook signing secrets and identity provider client secrets are never exported. A client or webhook created by an import gets a fresh secret, returned once in the import report; an identity provider’s secret is set after the import, in the console or through the admin API.
  • No users, sessions, tokens, consents or audit history. Users move with bulk import and export, or SCIM; see Users, invitations and bulk import.
  • No provider credentials: SMTP, SMS gateway and CAPTCHA settings contain secrets and differ between environments, so they are configured per deployment.
  • No signing keys. Each environment has its own; keys are never copied between deployments.
  • No built-ins. The ridm:* roles, the urn:ridm:admin and urn:ridm:account resource servers and the two console clients are managed by rIDM itself. Exports leave them out, pruning never deletes them, and an import that tries to define a console client is refused.

Import: plan, then apply

An import first computes a plan: which objects would be created, which updated (with a field-by-field before and after), and, with prune, which existing objects not mentioned in the document would be deleted. Without prune, an import only adds and changes; it never removes anything. The plan also lists, under errors, every change the importer would be refused: a role or group whose composites, permissions or roles would hand out admin (ridm:*) permissions the importing administrator does not hold themselves (“cannot grant permissions you do not hold”). A role the same document defines counts with the permissions it will have once imported.

ridm --tenant acme tenant export -o acme.json    # current configuration
ridm --tenant acme tenant diff   -f acme.json    # the plan, without applying it
ridm --tenant acme tenant import -f acme.json    # show the plan, confirm, apply

ridm tenant diff --exit-code exits with status 3 when the plan is not empty, which lets a pipeline fail when a tenant has drifted from the document in the repository. ridm tenant diff prints the plan’s refusals and exits with status 1 when there are any, whatever --exit-code says, since applying that document would fail in part (with --output json it prints the plan, errors included, and leaves the judgement to the caller); ridm tenant import shows them with the plan before asking for confirmation. Over the admin API, the same operations are GET /admin/tenants/{slug}/export (permission ridm:tenants:export) and POST /admin/tenants/{slug}/import?dry_run=true&prune=false (ridm:tenants:import, held only by ridm:owner among the built-in roles, because an import can rewrite every client and role in a tenant).

Changes are applied in dependency order (resource servers before the roles that grant their permissions, roles before the groups that hold them) and deletions in reverse. Each change is applied on its own: one that fails (including one refused under the no-escalation rule) is reported with its error and the rest still apply, so a partly failed import can be fixed and run again. ridm tenant import exits with status 1 when any change failed. The tenant itself must already exist; create it first with ridm tenant create or POST /admin/tenants.

A typical GitOps setup keeps one document per tenant in a repository, runs tenant diff --exit-code on every pull request to show reviewers the plan, and runs tenant import --yes from the main branch with a personal access token or a machine client’s credentials. See The ridm command line.

Events, audit and webhooks

Every change rIDM makes, and every sign-in it handles, is described by a domain event: a typed record of what happened, in which tenant, caused by whom. Events are the single source for the audit log and for webhooks, so whatever an administrator can read in the audit log, an external system can subscribe to, under the same name.

Domain events

A state-changing action emits exactly one event, from one place in the code, after the change has been committed. An event carries:

FieldMeaning
idunique id (a time-ordered UUID)
tenant_idthe tenant, or null for deployment-wide events such as master-key rotation
occurred_atwhen it happened
actorwho caused it: {"type": "user" | "admin" | "client", "id": ...} or {"type": "system"}
impersonatorthe administrator behind it, when it happened in a session they opened as the user (impersonation)
ip, user_agentthe request’s client address and browser, when there was a request
kindwhat happened, with its details, tagged by type

Every event also has a stable dotted name, such as user.created, role.assigned or login.failed. Names are part of the public contract: once shipped, they do not change. The main families:

FamilyExamples
tenant.*tenant.created, tenant.updated, tenant.moved, tenant.profile_schema_updated
user.*user.created, user.registered, user.password_changed, user.email_verified, user.locked
login.*login.succeeded, login.failed, login.new_device, login.brokered, login.passwordless_sent
logout.*logout.upstream (SAML identity providers)
directory.*directory.synced (LDAP and Active Directory)
session.*, device.*session.created, session.revoked, device.trusted, device.revoked
token.*token.revoked, token.refresh_reuse_detected
mfa.*mfa.changed
risk.*risk.step_up, risk.blocked (adaptive authentication)
audit.*audit.chain_broken (the scheduled check)
impersonation.*impersonation.requested, impersonation.started, impersonation.ended (impersonation)
client.*, consent.*, authorization.*client.created, client.secret_rotated, consent.granted, authorization.granted
backchannel.*backchannel.requested, backchannel.denied (backchannel sign-in)
group.*, role.*, permission.*group.member_added, role.assigned, role.composite_added, permission.granted
resource_server.*, scope.*, claim_mapper.*configuration changes
signing_key.*, master_key.*signing_key.created, signing_key.status_changed, master_key.rotated
saml_key.*saml_key.created, saml_key.status_changed (SAML identity provider)
identity_provider.*, identity.*identity.linked, identity.unlinked
invitation.*, personal_token.*, scim_token.*, dcr_token.*, ip_rule.*, webhook.*lifecycle of each

The full list, with each event’s fields, is in Webhooks and the audit log.

The event bus

Events are published on an in-process bus on the node that handled the request. Publishing never blocks or fails the action: a sign-in does not wait for the audit log, and a slow webhook endpoint cannot slow down the token endpoint. Two subscribers run on every node:

  • the audit writer, which appends every event to the audit log;
  • the webhook dispatcher, which turns every event that a tenant’s webhooks subscribe to into queued deliveries.

Both subscribers have their own queue: a burst or a slow database delays them rather than dropping events. The audit writer takes what has queued up and appends each chain’s share in one transaction with one insert, several chains at a time; the dispatcher queues each tenant’s deliveries with one insert, and sends them at once in the background. A chain that cannot be written for now (a tenant being moved between regions, a region’s database down) is set aside, in order, and retried every five seconds.

Each queue is bounded (EVENT_QUEUE_CAPACITY, 100,000 events by default), so a consumer that stops keeping up cannot take the node’s memory with it. Once a queue is 80% full, /readyz reports the node not ready (events: saturated), so the load balancer sends it no new work while it catches up; only an event published into a full queue is dropped, and that is logged and counted (ridm_event_queue_dropped_total). The depths are the ridm_event_queue_depth gauges (and ridm_audit_queue_depth, ridm_webhook_dispatch_queue_depth).

Events still live in memory until written. A node that is stopped gives its queues, and the deliveries it started, up to 15 seconds to drain after its last request; what is left after that is lost, and an event that fails to append is logged. Treat the audit log as a faithful record rather than a transactional ledger, and watch those gauges.

Two other things that the event names might suggest are not driven by the bus:

  • Security notices to users (a sign-in from a new browser, a password or email change, a second factor added or a recovery code used) are sent by the services that make those changes, through the tenant’s email or SMS settings, in the user’s language. Each can be switched off under settings.notifications.
  • Cache invalidation has its own channel. Every write evicts the cache entries it affects locally and in Valkey, and publishes the evicted keys on a Valkey pub/sub channel so that every other node evicts its in-process copy too. That is why a change made through one node is visible on all of them on the next request.

The audit log

The audit writer stores every event as a row of audit_events: its name, actor, subject, address, browser, the event’s details, and the time it was recorded. The table is partitioned by month and protected by tenant row level security like everything else.

Rows form a hash chain per tenant: each row stores the hash of the one before it, and its own hash is SHA-256(prev_hash || row). Changing or deleting a row inside the retained window breaks every hash after it, which GET /admin/tenants/{slug}/audit/verify detects and pinpoints. Exports include the hashes, so the chain can also be checked offline. Events without a tenant form a separate global chain, readable by global administrators.

Retention is a tenant setting, settings.audit.retention_days (365 days by default, 0 to keep everything). A daily job creates upcoming partitions and removes each tenant’s expired rows from the start of its chain, so what remains is still one contiguous, verifiable chain.

The audit log can also be streamed out as it is written: set AUDIT_SINK_URL to an HTTPS endpoint (JSON batches) or a syslog receiver, and every row is shipped with its chain sequence and hash. See Observability.

Webhooks

A webhook sends a tenant’s events to an HTTPS endpoint of yours as they happen. It names the events it wants: exact names (user.created), prefixes (user.*), or * for everything. Deployment-wide events are never delivered to tenant webhooks.

Each delivery is a POST with the JSON body {delivery_id, attempt, event} and these headers:

HeaderMeaning
X-RIDM-Eventthe event name
X-RIDM-Deliverythe delivery id, the same across retries
X-RIDM-Webhookthe webhook’s id
X-RIDM-Timestampwhen this attempt was signed
X-RIDM-Signaturet=<unix time>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>

A receiver should verify the signature with the webhook’s secret (shown once, when the webhook is created or its secret rotated), refuse stale timestamps, and ignore delivery ids it has already processed, because a delivery can arrive more than once.

Deliveries are queued in Postgres when the event occurs and sent at once. A 2xx answer counts as delivered. A 5xx, 408, 425, 429 or network error is retried with backoff (30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours) up to the webhook’s max_attempts; any other 4xx fails the delivery immediately, since retrying would get the same answer. A delivery that runs out of attempts is marked dead, raises webhook.delivery_dead (audited, but never itself delivered, so a broken endpoint cannot breed deliveries), and stays in the delivery log with its last status and response snippet until an administrator redelivers it.

Webhook targets must be https (plain http only to loopback, for development) and must be reachable at a public address. The check is made when each delivery connects, not only when the URL is saved: the host name is resolved by rIDM and private, loopback, link-local, carrier-grade NAT, unique-local and reserved addresses are dropped, so a name that resolves (or later re-resolves) to an internal address is refused. Redirects are not followed and proxy environment variables are ignored. For development, the host localhost and loopback IP literals remain allowed. See Outbound requests. Signing secrets are encrypted under the master key.

Setting up and operating webhooks is covered in Webhooks and the audit log.

Run rIDM locally

From an empty checkout to a running server, the admin console, a global administrator and a demo tenant with users you can sign in as. Everything here is for a laptop: the passwords are written down on purpose, and none of them belongs on a machine anyone else can reach.

This page builds from source, since it is about working on rIDM. To run a release instead, see Releases and verification.

What you need

ToolVersionWhy
Rust1.98 or later (rust-toolchain.toml pins 1.98.1)the server and the ridm CLI
Node.js24the consoles and sign-in pages, served by next dev
Docker with Compose v2any recentPostgres, Valkey and Mailpit
curl, jq, python3, opensslanythe setup script and the examples below

The short way

The repository’s Makefile runs the steps below. With .env written as in step 1:

make setup     # step 2 and 3, the first administrator, and an admin token
make api       # step 4, in one terminal (make watch restarts it on every change)
make ui        # step 5, in another; next dev reloads pages as you edit them
make seed      # step 7, plus a second tenant, acme, with 120 users to page through

make setup gets its token from ridm bootstrap --issue-token (see The ridm command line) and writes it to target/dev/token, which is what make seed uses; step 6 is only needed for a token of your own choosing. make alone lists every target. The rest of this page is what those targets do, one step at a time.

Ports

The server’s own default listen address is 0.0.0.0:8080, and the compose file publishes a containerised API on 8080 too. This page runs the API on 8090 instead, because that is what examples/setup.sh and the example applications default to; with 8090 none of them needs an override.

PortWhat
5432Postgres (RIDM_PG_PORT moves it)
6379Valkey (RIDM_VALKEY_PORT)
8025Mailpit web UI (RIDM_MAILPIT_UI_PORT); SMTP on 1025 (RIDM_MAILPIT_SMTP_PORT)
8090rIDM API (PUBLIC_URL, BIND_ADDR)
3110rIDM UI under next dev (UI_URL)

If a port is taken, set the matching RIDM_*_PORT variable in .env and adjust the URLs below to match.

1. Configure

git clone https://github.com/ZerosAndOnesLLC/rIDM.git
cd rIDM
cp .env.example .env
openssl rand -hex 32        # the master key; paste it into .env

Then edit .env so these lines read as follows (the rest of .env.example can stay as it is):

MASTER_KEY=<the 64 hex characters from openssl>
PUBLIC_URL=http://localhost:8090
BIND_ADDR=127.0.0.1:8090
UI_URL=http://localhost:3110
COOKIE_SECURE=false
DOCS_ENABLED=true

# First global administrator, created on the first start.
BOOTSTRAP_ADMIN_EMAIL=admin@ridm.local
BOOTSTRAP_ADMIN_PASSWORD=Local-Admin-Passw0rd

# Outbound mail goes to Mailpit.
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM="rIDM <no-reply@ridm.local>"
SMTP_SECURITY=none

Why each one matters:

  • MASTER_KEY encrypts signing keys and other secrets at rest. The server refuses to start without it; losing it makes those secrets unreadable. See Signing keys and the master key.
  • PUBLIC_URL is the externally visible base URL. Every tenant’s issuer is derived from it as {PUBLIC_URL}/t/{slug}, so the demo tenant’s issuer will be http://localhost:8090/t/demo.
  • UI_URL is where the sign-in pages and consoles are served. In development that is next dev on 3110, not the API. The built-in console clients register their redirect URIs from it, so changing it later re-registers them on the next start.
  • COOKIE_SECURE=false is needed on plain http://; browsers drop Secure cookies there. The session cookie is then ridm_session_{slug} rather than the __Host-ridm_session_{slug} a secure deployment uses.
  • BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD must be set together. The password has to satisfy the master tenant’s policy (at least 12 characters by default).
  • SMTP_FROM is required whenever SMTP_HOST is set.

Every variable is described in Server configuration.

2. Start Postgres, Valkey and Mailpit

docker compose --env-file .env -f deploy/docker-compose.yml --profile dev \
  up -d postgres valkey mailpit

--env-file .env matters: Compose otherwise looks for .env next to the compose file in deploy/, and the file refuses to load without MASTER_KEY. Naming the three services starts only them; the containerised API services (api, api-dev) and the one-shot migrate service stay down.

On its first start the Postgres container creates two roles (deploy/postgres/init-app-role.sh): ridm_migrator, which owns the schema, and ridm_app, which has DML privileges only and is what the API connects as. The split exists because Postgres superusers bypass row level security, which is what keeps tenants apart, and a table owner can switch it off.

3. Apply the migrations

Run them as the schema owner. DATABASE_URL on the command line wins over the one in .env:

DATABASE_URL=postgres://ridm_migrator:ridm_migrator@localhost:5432/ridm \
  cargo run -p ridm-api -- migrate

With sqlx-cli installed, sqlx migrate run --source api/migrations against the same URL does the same.

The server itself leaves the schema alone: with MIGRATE_ON_START=false (the default in .env.example), it only logs a warning at startup when migrations are pending. After pulling new code, run this step again before restarting.

4. Start the server

cargo run -p ridm-api

It reads .env, connects as ridm_app, and on the first start creates the global administrator: user admin (set BOOTSTRAP_ADMIN_USERNAME to choose another name) in the master tenant, holding the ridm:owner role, with the email and password from .env. It must change that password at first sign-in. Bootstrap does nothing once any master user holds ridm:owner, so the variables can stay in .env. Adding BOOTSTRAP_SAMPLE_CLIENT=true also makes sure master has a public single-page-app client, sample-spa (PKCE, redirect http://localhost:3000/callback, post-logout redirect and CORS origin http://localhost:3000); an existing one is left as it is.

Check it is up:

curl -s http://localhost:8090/readyz
curl -s http://localhost:8090/t/master/.well-known/openid-configuration | jq .issuer

The server generates an RSA signing key the first time a tenant needs one. Debug builds compile the RSA and argon2 crates optimised, so that and password hashing take milliseconds rather than seconds.

5. Start the UI

In a second terminal:

cd ui
npm install
API_PROXY=http://localhost:8090 npx next dev -p 3110

API_PROXY makes the dev server forward /t, /admin and /.well-known to the API, so the pages call it same-origin, as they are when the server embeds the UI, and session cookies work without CORS. next dev reloads pages as you edit them. To run the pages the way the container image serves them instead, build the export once (NEXT_PUBLIC_API_URL= npm run build in ui/), start the API with cargo run -p ridm-api --features embedded-ui and without UI_URL, and open http://localhost:8090/console/.

PageURL
Admin consolehttp://localhost:3110/console/
Account consolehttp://localhost:3110/account/?tenant=master
Mailpithttp://localhost:8025
Swagger UI over the admin APIhttp://localhost:8090/docs/ (because DOCS_ENABLED=true)

Open the admin console, sign in through tenant master as admin with the bootstrap password, and choose a new one when asked. The consoles are described in The admin and account consoles.

6. Give the CLI a token

Every ridm command except bootstrap talks to the admin API with a bearer token. On a development machine the quickest is to have ridm bootstrap mint one for the administrator, straight through the database:

cargo build -p ridm-cli
export RIDM_URL=http://localhost:8090
export RIDM_TOKEN=$(target/debug/ridm bootstrap --no-migrate --issue-token local-dev)

That token carries every permission the administrator holds and expires in 30 days (--token-days). The way that works against any server, including one whose database you cannot reach, is a personal access token from the account console:

  1. Open the account console at http://localhost:3110/account/?tenant=master, signed in as admin.
  2. Under Security → Personal access tokens, create a token. Tick the permissions the CLI will need; for the demo tenant that is ridm:tenants:read, ridm:tenants:write, ridm:tenants:create, ridm:tenants:import, ridm:users:read, ridm:users:write, ridm:roles:read, ridm:roles:write, ridm:clients:read and ridm:clients:write. A token can never carry more than its owner holds.
  3. Copy the rpat_… value; it is shown once.

Then build the CLI and store the token in a profile:

cargo build -p ridm-cli                  # target/debug/ridm
export PATH="$PWD/target/debug:$PATH"
export RIDM_TOKEN=rpat_...               # the token you just copied
ridm login --url http://localhost:8090
ridm whoami

ridm login checks the token against /admin/me and writes the URL and token to ~/.config/ridm/config.json (mode 0600). With RIDM_TOKEN exported it uses that token instead of prompting; --token-stdin reads it from a pipe. See The ridm command line for profiles and the other ways to authenticate.

7. Create the demo tenant

export RIDM_URL=http://localhost:8090
examples/setup.sh

setup.sh exits unless RIDM_TOKEN is exported, even after ridm login, because looking users up and assigning roles call the admin API with curl, which cannot read the token ridm login stored. It:

  1. creates the tenant demo (“Example Orders Co.”) if it does not exist;
  2. imports examples/demo-tenant.json with ridm tenant import: the resource server https://orders.example with the permissions orders:read and orders:write, matching scopes, the roles orders-reader and orders-manager, the public client orders-spa and the confidential client orders-web;
  3. creates two users and gives each one role;
  4. prints the orders-web client secret, the first time only.

Keep the secret it prints. It is shown once; the web app quickstart needs it. The script is safe to re-run: the import is a reconciliation, and each run resets the two users’ passwords.

UserPasswordRoleMay
danaDemo-Passw0rd!2026orders-managerread and place orders
samDemo-Passw0rd!2026orders-readerread orders only

DEMO_PASSWORD=… examples/setup.sh chooses another password, and SPA_URL=http://localhost:3101 or WEB_URL=… registers the clients for another origin when port 3100 or 3200 is taken (the script substitutes it into the document as it imports it).

Confirm the tenant matches its document:

ridm --tenant demo tenant diff -f examples/demo-tenant.json

An empty plan means the tenant is exactly what the file describes. See Configuration as code.

Where next

Running the API in a container instead

docker compose --env-file .env -f deploy/docker-compose.yml --profile dev up -d also builds and runs the API (api-dev, on 8080), runs the migrations with the one-shot migrate service, and seeds master with an administrator from BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD (compose defaults admin@ridm.local and ChangeMe-Now-1234) and the sample client sample-spa described above. The image embeds the UI, so the consoles are at http://localhost:8080/console/ and http://localhost:8080/account/?tenant=master with nothing else running. For work on the pages, run the API with cargo and the UI under next dev as above. See docker-compose.

Resetting

docker compose --env-file .env -f deploy/docker-compose.yml --profile dev down -v   # drops the data volumes

To reset only the administrator’s password, with a token for a user that may:

printf 'a-new-password-of-12+' |
  ridm --tenant master user reset admin --password-stdin --no-must-change

Protect a Rust API with ridm-auth

An API that accepts rIDM access tokens, built with axum and ridm-auth. By the end, an invoices API answers 401 without a token, 403 for a token without the right permission, and 200 for one with it.

A resource server holds no sessions, cookies or users. It verifies a signed JWT against the issuer’s published keys, checks the token was minted for this API, and decides whether the subject’s permissions allow the request. ridm-auth does those three steps; getting a token is the client’s job (the other quickstarts).

This page assumes a local rIDM on http://localhost:8090 and a ridm CLI logged in, as set up in Run rIDM locally. In production the issuer would be https://id.example.com/t/acme.

export RIDM_URL=http://localhost:8090
export RIDM_TOKEN=rpat_...      # a token with ridm:tenants:*, ridm:users:*, ridm:roles:*, ridm:clients:*

1. Register the API in rIDM

rIDM calls an API a resource server. Its identifier is the value access tokens for it carry in aud; it has to be an absolute URI and it never changes. Its permissions are what roles grant, and what the API checks. See Resource servers, scopes and permissions.

Create a tenant and describe the API in a configuration document, acme.json:

{
  "format": "ridm.tenant/1",
  "tenant": { "slug": "acme", "display_name": "Acme" },
  "resource_servers": [
    {
      "identifier": "https://invoices.acme.example",
      "name": "Invoices API",
      "permissions": [
        { "name": "invoices:read", "description": "See invoices" },
        { "name": "invoices:write", "description": "Issue invoices" }
      ]
    }
  ],
  "scopes": [
    { "name": "invoices:read", "description": "See your invoices",
      "resource_server": "https://invoices.acme.example" },
    { "name": "invoices:write", "description": "Issue invoices on your behalf",
      "resource_server": "https://invoices.acme.example" }
  ],
  "roles": [
    { "name": "invoices-reader", "description": "May see invoices",
      "permissions": ["https://invoices.acme.example#invoices:read"] },
    { "name": "invoices-clerk", "description": "May see and issue invoices",
      "permissions": [
        "https://invoices.acme.example#invoices:read",
        "https://invoices.acme.example#invoices:write"
      ] }
  ],
  "clients": [
    {
      "client_id": "invoices-smoke",
      "name": "Invoices smoke test",
      "client_type": "machine",
      "allowed_audiences": ["https://invoices.acme.example"],
      "allowed_scopes": ["invoices:read", "invoices:write"],
      "service_account": true
    }
  ]
}
ridm tenant create acme --name "Acme"
ridm --tenant acme tenant import -f acme.json --yes

The import prints client invoices-smoke secret: cs_… once. Keep it: that machine client is how step 4 gets a token to test with.

A permission in a role is written {resource server identifier}#{permission}. The token carries only the permission name (invoices:read), and only when the token was minted for that resource server. The two scopes are bound to the resource server, so a client that requests invoices:read also gets the invoices API added to its token’s audience.

A resource server can also carry signing_alg, when the API can only verify one algorithm (ridm-auth accepts all five rIDM signs with: RS256, RS384, RS512, ES256, EdDSA), and allow_offline_access (on by default), which decides whether refresh tokens behind tokens for this API may outlive the user’s sign-in session. See Resource servers, scopes and permissions.

A configuration document reconciles the whole tenant section, settings included. On a new tenant that is what you want; on an existing one, start from ridm tenant export and edit that, and check with ridm tenant diff first. See Configuration as code.

The same objects can be created one at a time through the admin API:

StepRequest
resource serverPOST /admin/tenants/acme/resource-servers with identifier, name
permissionPOST /admin/tenants/acme/resource-servers/{rs}/permissions with name, description
rolePOST /admin/tenants/acme/roles with name, description
grant a permission to a rolePUT /admin/tenants/acme/roles/{role}/permissions/{permission_id}

The Admin API reference has the bodies.

2. Add ridm-auth

ridm-auth is on crates.io; its version follows rIDM’s. It needs Rust 1.98 or later.

[package]
name = "invoices-api"
version = "0.1.0"
edition = "2024"

[dependencies]
ridm-auth = "0.4"
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
serde_json = "1"

The default features are axum (the extractor, the guard and the error responses) and rustls (TLS for the crate’s own calls to the issuer).

3. Write the API

src/main.rs:

use std::sync::Arc;

use axum::middleware::from_fn_with_state;
use axum::routing::{get, post};
use axum::{Json, Router};
use ridm_auth::Validator;
use ridm_auth::axum::{Guard, RidmClaims, guard};
use serde_json::{Value, json};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let issuer = std::env::var("RIDM_ISSUER")?; // http://localhost:8090/t/acme
    let audience = std::env::var("RIDM_AUDIENCE")?; // https://invoices.acme.example
    let allow_http = std::env::var("RIDM_ALLOW_HTTP").is_ok_and(|v| v == "true");

    // One validator for the whole process: it holds the key-set cache.
    let validator: Arc<Validator> = Validator::builder(&issuer)
        .audience(&audience)
        .allow_http(allow_http)
        .discover()
        .await?
        .shared();
    // Optional: fetch the keys now, so a wrong issuer fails at startup.
    validator.warm().await?;

    let read = Router::new()
        .route("/invoices", get(list_invoices))
        .route_layer(from_fn_with_state(
            Guard::new(validator.clone())
                .permission("invoices:read")
                .realm("invoices"),
            guard,
        ));
    let write = Router::new()
        .route("/invoices", post(issue_invoice))
        .route_layer(from_fn_with_state(
            Guard::new(validator.clone())
                .permission("invoices:write")
                .realm("invoices"),
            guard,
        ));

    let app = Router::new()
        .route("/whoami", get(whoami)) // any valid token, no permission needed
        .merge(read)
        .merge(write)
        .with_state(validator);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8082").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn whoami(RidmClaims(claims): RidmClaims) -> Json<Value> {
    Json(json!({
        "subject": claims.sub,
        "client": claims.client_id,
        "scopes": claims.scopes().collect::<Vec<_>>(),
        "roles": claims.roles,
        "permissions": claims.permissions,
    }))
}

async fn list_invoices(RidmClaims(claims): RidmClaims) -> Json<Value> {
    Json(json!({ "invoices": [], "for": claims.sub }))
}

async fn issue_invoice(RidmClaims(claims): RidmClaims) -> Json<Value> {
    Json(json!({ "issued_by": claims.sub }))
}

What each piece does:

  • Validator::builder(issuer) names the tenant whose tokens are accepted. .audience(...) is required: build and discover refuse a validator without one, because it would accept a token minted for any API of the tenant.
  • .discover() reads jwks_uri from {issuer}/.well-known/openid-configuration and checks that the document names the same issuer. .build() skips discovery and assumes {issuer}/.well-known/jwks.json.
  • .allow_http(true) is needed for a local http:// issuer and for nothing else; an http:// issuer or key set is refused otherwise.
  • Guard validates once for a route subtree and refuses anything short of what it asks for: .permission, .scope and .role each add a requirement (all must hold). .realm names the realm in the WWW-Authenticate challenge (default api).
  • RidmClaims hands a handler the verified claims. Behind a guard it reuses what the guard verified; on an unguarded route (/whoami) it validates the Authorization header itself and asks for nothing more. OptionalRidmClaims does the same for a route that also serves anonymous callers; a token that is presented must still be valid.

Every check ridm-auth makes:

CheckRefused as
signature, against the issuer’s JWKS (cached; refetched when a token names an unknown kid)invalid_token
JOSE typ is at+jwt, so an ID token cannot be spent as an access tokeninvalid_token
alg is asymmetric, allowed, and matches the published keyinvalid_token
iss is the configured issuer, aud contains the configured audienceinvalid_token
exp and nbf, with 60 seconds of leewayinvalid_token
no cnf (a DPoP-bound token is refused, not downgraded; a certificate-bound one passes validate_with_certificate with its certificate)invalid_token
the scopes, permissions and roles the guard or builder asked forinsufficient_scope

It does not check revocation: a verified token is accepted until it expires. Access tokens live five minutes by default; an API that must notice a revocation sooner should call the tenant’s /introspect endpoint instead. See Tokens.

ridm-auth validates JWT access tokens only. A client registered with access_token_format: opaque receives at_... tokens that carry no claims and no signature; an API serving such clients has to send each token to /introspect (as a confidential client of the tenant) instead of using ridm-auth. See Opaque access tokens.

Without axum, the same checks are two calls:

#![allow(unused)]
fn main() {
let claims = validator.validate(token).await?;          // or validate_authorization(header)
claims.require_permission("invoices:read")?;            // Err(AuthError::MissingPermission)
}

AuthError::status() and AuthError::www_authenticate(realm) give the response to send.

Run it:

RIDM_ISSUER=http://localhost:8090/t/acme \
RIDM_AUDIENCE=https://invoices.acme.example \
RIDM_ALLOW_HTTP=true \
cargo run

4. Get a token to test with

The invoices-smoke client from step 1 has a service account: a user named svc-invoices-smoke that its client_credentials tokens are issued for, so it can hold roles like anyone else. Give it the reader role:

SVC=$(curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/acme/clients/invoices-smoke/service-account" | jq -r .service_account.id)
ROLE=$(curl -s -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/acme/roles" | jq -r '.[] | select(.name=="invoices-reader") | .id')
curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/acme/users/$SVC/roles/$ROLE" -o /dev/null

The PUT on service-account is idempotent: it returns the existing account. Then ask for a token for the invoices API:

SECRET=cs_...   # what the import printed
TOKEN=$(curl -s -u "invoices-smoke:$SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://invoices.acme.example \
  "$RIDM_URL/t/acme/token" | jq -r .access_token)

Machine-to-machine access explains each part.

5. Call it

curl -s -H "Authorization: Bearer $TOKEN" localhost:8082/whoami | jq
curl -i -H "Authorization: Bearer $TOKEN" localhost:8082/invoices

/whoami shows "permissions": ["invoices:read"] and "roles": ["invoices-reader"], and GET /invoices answers 200.

What a refusal looks like

Every refusal is an RFC 6750 response: a status, a WWW-Authenticate challenge, Cache-Control: no-store, and a JSON body. Your handlers write none of it.

No token:

curl -i localhost:8082/invoices
HTTP/1.1 401 Unauthorized
content-type: application/json
cache-control: no-store
www-authenticate: Bearer realm="invoices"

{"error_description":"no bearer token was presented"}

A token that is not acceptable (expired, wrong audience, bad signature, an ID token):

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="invoices", error="invalid_token", error_description="the token has expired"

{"error":"invalid_token","error_description":"the token has expired"}

A valid token without the permission. The reader cannot issue invoices:

curl -i -X POST -H "Authorization: Bearer $TOKEN" localhost:8082/invoices
HTTP/1.1 403 Forbidden
www-authenticate: Bearer realm="invoices", error="insufficient_scope", error_description="the token is missing permission `invoices:write`"

{"error":"insufficient_scope","error_description":"the token is missing permission `invoices:write`"}

Assign invoices-clerk to the service account as in step 4, request a new token (the old one keeps the permissions it was minted with), and the same POST answers 200.

When the issuer’s key set cannot be reached and nothing usable is cached, the answer is 503 with Retry-After: 5 and no challenge: the token was never judged, so the caller should retry rather than re-authenticate. While the issuer is down, the last good key set keeps answering.

Notes for production

  • Build one Validator per issuer at startup and share it. A validator per request would fetch the key set on every request.
  • Key rotation needs no restart: a token signed with a new key names a kid the cache has not seen, which triggers a refetch. jwks_max_age (default 10 minutes) bounds how long a cached set is trusted, so a revoked key stops verifying within that window; min_refresh_interval (default 30 seconds) is the floor between fetches, so tokens naming made-up keys cannot turn your API into a load generator against rIDM. See Rotating keys.
  • A browser app calling this API from another origin needs a CORS layer on the API (tower-http’s CorsLayer, allowing the Authorization header), as the orders example does.
  • Permission is decided per route. Which rows a subject may see is still your query’s job: claims.sub is who is calling.

The finished, runnable version of this page is examples/axum-api. Token contents are listed in Token claims.

Sign in from a single-page app

A browser app that signs users in with rIDM and calls an API with their access token: authorization code with PKCE, no client secret, tokens in memory, and a silent sign-in after a reload. This page walks through the protocol steps; the complete app is examples/nextjs-spa, a few hundred lines of TypeScript with no OIDC library.

A single-page app is a public client: everything it ships is readable by whoever loads it, so it cannot keep a secret. PKCE replaces the secret. The app proves that whoever spends the authorization code is whoever asked for it, by presenting a random verifier whose hash went out with the request.

Try the example first

With the local stack and the demo tenant running, plus the orders API example:

RIDM_ISSUER=http://localhost:8090/t/demo RIDM_AUDIENCE=https://orders.example \
RIDM_ALLOW_HTTP=true cargo run -p ridm-example-axum-api      # :8081

cd examples/nextjs-spa && npm install && npm run dev          # :3100

Open http://localhost:3100/, sign in as dana or sam (password Demo-Passw0rd!2026), and reload the page: you stay signed in without seeing rIDM. sam can list orders but is refused 403 insufficient_scope when placing one.

The app reads four build-time variables, none of them secret:

VariableDefault
NEXT_PUBLIC_RIDM_ISSUERhttp://localhost:8090/t/demo
NEXT_PUBLIC_CLIENT_IDorders-spa
NEXT_PUBLIC_API_URLhttp://localhost:8081
NEXT_PUBLIC_API_AUDIENCEhttps://orders.example

If port 3100 is taken, the app still works on any port: it derives its redirect URI from the origin it is served on. Register that origin’s URIs on the client, as below.

1. Register the client

For your own app, on tenant acme with the API from Protect a Rust API:

ridm --tenant acme client create \
  --name "Acme invoices" \
  --client-id invoices-spa \
  --type spa \
  --redirect-uri https://app.acme.example/callback/ \
  --post-logout-redirect-uri https://app.acme.example/ \
  --cors-origin https://app.acme.example \
  --audience https://invoices.acme.example \
  --scope openid --scope profile --scope email --scope offline_access \
  --scope invoices:read --scope invoices:write \
  --no-consent
SettingWhy
--type spapublic: token endpoint auth method none, grants authorization_code and refresh_token, PKCE required
--redirect-uricompared as an exact string. https://app.acme.example/callback and …/callback/ are different URIs; a static export with trailing slashes needs the slash. Plain http:// is accepted only for localhost, 127.0.0.1 and [::1].
--post-logout-redirect-uriwhere rIDM may send the browser after sign-out
--cors-originan origin (scheme://host[:port], no path), because the app calls the token and revocation endpoints from the browser. The token endpoint admits only the origins of the client that is authenticating.
--audiencethe resource servers this client may get tokens for
--scopethe scopes it may ask for; given none, a spa gets openid profile email phone address offline_access
--no-consentskip the consent screen, for a first-party app. Leave it off for a third-party one.

The demo tenant registers orders-spa the same way in examples/demo-tenant.json, with http://localhost:3100/callback/. See Registering clients for every client setting.

2. Discover the endpoints

const issuer = "https://id.example.com/t/acme";
const discovery = await (await fetch(`${issuer}/.well-known/openid-configuration`)).json();
if (discovery.issuer.replace(/\/$/, "") !== issuer) throw new Error("issuer mismatch");

The endpoints to use are authorization_endpoint, token_endpoint, revocation_endpoint and end_session_endpoint. Read them rather than assembling URLs: a tenant on a custom domain has a different issuer. Checking that the document names the issuer it was fetched from is OIDC Discovery §4.3.

3. Send the browser to sign in

Mint three random values, keep them in sessionStorage until the callback, and redirect:

const state = randomToken();     // ties the callback to this request
const nonce = randomToken();     // ties the ID token to this request
const verifier = randomToken();  // PKCE: 32 random bytes, base64url
sessionStorage.setItem("signin", JSON.stringify({ state, nonce, verifier }));

const challenge = base64url(
  new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
);
const query = new URLSearchParams({
  response_type: "code",
  client_id: "invoices-spa",
  redirect_uri: `${location.origin}/callback/`,
  scope: "openid profile email offline_access invoices:read invoices:write",
  state,
  nonce,
  code_challenge: challenge,
  code_challenge_method: "S256",
  resource: "https://invoices.acme.example",
});
location.assign(`${discovery.authorization_endpoint}?${query}`);
  • code_challenge_method must be S256. rIDM refuses plain, and refuses a challenge with no method because RFC 7636 would then default to plain.
  • resource (RFC 8707) says which API the access token is for, and must name a resource server in the client’s allowed_audiences. Without it, rIDM mints the token for every audience in allowed_audiences; with that list empty too, the token’s aud is the client itself and no API will accept it. Always sending resource keeps the audience explicit. (A scope that the tenant binds to a resource server, such as invoices:read bound to the invoices API, also adds that API to the audience when requested.)
  • offline_access asks for a refresh token that survives the end of the user’s SSO session; see step 6.

rIDM shows its own sign-in pages (password, MFA, consent as the tenant requires) and then redirects to the redirect_uri with code and state, or with error and error_description.

4. Spend the code

On the callback page, check state, then post the code with the verifier:

const params = new URLSearchParams(location.search);
const pending = JSON.parse(sessionStorage.getItem("signin")!);
sessionStorage.removeItem("signin");
if (params.get("state") !== pending.state) throw new Error("unknown sign-in");

const response = await fetch(discovery.token_endpoint, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: params.get("code")!,
    redirect_uri: `${location.origin}/callback/`,
    code_verifier: pending.verifier,
    client_id: "invoices-spa",
  }),
});
const tokens = await response.json(); // access_token, id_token, refresh_token, expires_in

A public client sends client_id in the body and no Authorization header. Then check the ID token: iss is the issuer, aud contains the client id, exp is in the future, and nonce equals the one you stored. The example does not verify the ID token’s signature: it came straight from the token endpoint over TLS, which OIDC Core §3.1.3.7 accepts in place of a signature check for this case. The API verifies its own token fully.

Replace the callback URL (with the router, not a full page load) so the code does not sit in history.

5. Keep tokens in memory

Hold the tokens in a module variable, not localStorage. Anything in localStorage is readable by every script on the origin, so one compromised dependency can take a token and spend it until it expires. Memory is lost on reload, which step 7 recovers from.

Call the API with the access token:

await fetch("https://api.acme.example/invoices", {
  headers: { authorization: `Bearer ${tokens.access_token}` },
});

The API is on another origin, so it must answer CORS preflights that carry the Authorization header. A 401 invalid_token means the token is no longer acceptable; a 403 insufficient_scope means the user lacks the permission, which is an answer, not a failure.

6. Refresh, and keep what comes back

Access tokens live five minutes by default. Before one runs out, trade the refresh token for a new pair:

const refreshed = await (await fetch(discovery.token_endpoint, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: tokens.refresh_token,
    resource: "https://invoices.acme.example",
    client_id: "invoices-spa",
  }),
})).json();

rIDM rotates refresh tokens on every use: the response carries a new refresh_token, and the old one is spent. Presenting a spent refresh token again is treated as theft and revokes the whole family, so replace the stored token every time. resource on a refresh may only name audiences the original sign-in was granted.

rIDM issues refresh tokens to any client allowed the refresh_token grant, but how long one keeps working depends on offline_access:

  • Without it, the refresh token lives only as long as the user’s SSO session at rIDM: it stops at sign-out or when the session times out (30 minutes idle, 12 hours absolute by default). Each refresh counts as activity and keeps the session’s idle clock running, so an open tab that refreshes every few minutes stays signed in.
  • With it, the refresh token outlives the session’s timeouts, up to the refresh token lifetime (30 days by default, or the client’s own), though an explicit sign-out still ends it. rIDM grants offline_access only when the client is allowed it and every API the token is for has allow_offline_access on; otherwise it is left out of the grant.

If the refresh fails, drop the session and sign in again.

7. Sign in silently after a reload

With nothing in memory, send the browser to the same authorization request with prompt=none added:

query.set("prompt", "none");
location.assign(`${discovery.authorization_endpoint}?${query}`);

If the browser still has an SSO session with rIDM, the callback arrives with a code and the user never sees a page. If it does not, the callback carries error=login_required (or consent_required when consent is still owed), which means “nobody is signed in”, not a failure: show a sign-in button. Remember in sessionStorage that a silent attempt was made, or a signed-out user bounces to rIDM on every load.

This is a top-level navigation to rIDM’s own origin, so the SSO cookie is first-party. There is no hidden iframe and no dependence on third-party cookies.

8. Sign out

Revoke the refresh token, forget the tokens, then end the SSO session:

await fetch(discovery.revocation_endpoint, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    token: tokens.refresh_token,
    token_type_hint: "refresh_token",
    client_id: "invoices-spa",
  }),
});
const logout = new URLSearchParams({
  id_token_hint: tokens.id_token,
  post_logout_redirect_uri: `${location.origin}/`,
  client_id: "invoices-spa",
});
location.assign(`${discovery.end_session_endpoint}?${logout}`);

Without the last step the next sign-in is silent and instant, and the user concludes the sign-out did not work. With a valid id_token_hint for the current session rIDM signs out at once and redirects to the registered post_logout_redirect_uri; without one it asks the user to confirm first, so a third party cannot sign users out by linking to the endpoint. Other applications in the same session are told through back-channel or front-channel logout; see Sign-in flows and sessions.

What can go wrong

SymptomCause
error page at rIDM, “redirect_uri”the URI is not registered exactly, trailing slash included
the token request fails in the browser with a CORS errorthe app’s origin is not in the client’s cors_origins
invalid_targetresource names a resource server the tenant does not have, or one outside the client’s allowed_audiences
invalid_scopea scope the client is not allowed, one the tenant does not define, or one bound to a resource server the client may not target
invalid_grant on refreshthe refresh token was already used, revoked, or has expired, or the SSO session it belongs to was signed out (or, without offline_access, timed out)
the API answers 401 invalid_token with an audience messagethe token was minted without resource for a client whose allowed_audiences does not include the API

The token endpoint’s errors are listed in Errors.

Sign in from a server-side web app

A web application with a server of its own: the authorization code comes back to the server, the server spends it with its client secret, and the browser only ever holds a session cookie. The same app refreshes access tokens as they run out, signs the user out of rIDM as well as itself, and ends its session when rIDM tells it the user signed out elsewhere.

The complete app is examples/confidential-client, an axum server of about 1,200 lines including its HTML pages and tests. This page follows it step by step.

Try the example first

With the local stack and the demo tenant running, the orders API from Protect a Rust API, and the orders-web secret that examples/setup.sh printed:

RIDM_ISSUER=http://localhost:8090/t/demo RIDM_AUDIENCE=https://orders.example \
RIDM_ALLOW_HTTP=true cargo run -p ridm-example-axum-api      # :8081

RIDM_ISSUER=http://localhost:8090/t/demo \
RIDM_CLIENT_ID=orders-web \
RIDM_CLIENT_SECRET=cs_... \
RIDM_ALLOW_HTTP=true \
cargo run -p ridm-example-confidential-client                 # :3200

Open http://localhost:3200/ and sign in as dana (password Demo-Passw0rd!2026). If you lost the secret, mint a new one with POST /admin/tenants/demo/clients/orders-web/secrets (see Registering clients).

VariableDefaultMeaning
RIDM_ISSUERnonehttps://{host}/t/{tenant}
RIDM_CLIENT_ID, RIDM_CLIENT_SECRETnonethe client’s credentials
BASE_URLhttp://localhost:3200where the app is reachable; the redirect URIs derive from it
API_URL, API_AUDIENCEhttp://localhost:8081, https://orders.examplethe API it calls
SCOPESopenid profile email offline_access orders:read orders:write
RIDM_ALLOW_HTTPfalsetrust an http:// issuer; development only
BIND_ADDR127.0.0.1:3200

1. Register the client

ridm --tenant acme client create \
  --name "Acme billing" \
  --client-id billing-web \
  --type web \
  --redirect-uri https://billing.acme.example/callback \
  --post-logout-redirect-uri https://billing.acme.example/ \
  --audience https://invoices.acme.example \
  --scope openid --scope profile --scope email --scope offline_access \
  --scope invoices:read --scope invoices:write \
  --no-consent

--type web defaults to client_secret_basic, the authorization_code and refresh_token grants, and PKCE required. The secret is printed once (client secret: cs_…); store it where the app’s other secrets live.

ridm client create has no flag for the back-channel logout URI, so set it with a merge patch on the client:

curl -s -X PATCH "$RIDM_URL/admin/tenants/acme/clients/billing-web" \
  -H "Authorization: Bearer $RIDM_TOKEN" -H 'content-type: application/json' \
  -d '{"backchannel_logout_uri":"https://billing.acme.example/backchannel-logout"}'

Or put the whole client in the tenant’s configuration document, as examples/demo-tenant.json does for orders-web:

{
  "client_id": "billing-web",
  "name": "Acme billing",
  "client_type": "web",
  "token_endpoint_auth_method": "client_secret_basic",
  "redirect_uris": ["https://billing.acme.example/callback"],
  "post_logout_redirect_uris": ["https://billing.acme.example/"],
  "backchannel_logout_uri": "https://billing.acme.example/backchannel-logout",
  "allowed_grants": ["authorization_code", "refresh_token"],
  "allowed_scopes": ["openid", "profile", "email", "offline_access",
                     "invoices:read", "invoices:write"],
  "allowed_audiences": ["https://invoices.acme.example"],
  "require_pkce": true,
  "require_consent": false
}

No CORS origin is needed: the browser never calls rIDM’s token endpoint, the server does.

2. Start a sign-in (/login)

Discover the endpoints once at startup from {issuer}/.well-known/openid-configuration, checking that the document names the issuer you asked. Then, per sign-in, mint state, nonce and a PKCE verifier, store them server-side keyed by state, and redirect:

GET /t/acme/authorize?response_type=code
    &client_id=billing-web
    &redirect_uri=https%3A%2F%2Fbilling.acme.example%2Fcallback
    &scope=openid%20profile%20email%20offline_access%20invoices%3Aread%20invoices%3Awrite
    &state=…&nonce=…
    &code_challenge=…&code_challenge_method=S256
    &resource=https%3A%2F%2Finvoices.acme.example
Host: id.example.com

A confidential client has a secret, so PKCE is not what authenticates it, but rIDM requires it by default for web clients and it closes authorization code injection at no cost. resource (RFC 8707) names the API the access token is for. Without it the token is minted for every audience in the client’s allowed_audiences (plus the resource server of any resource-bound scope requested), and for the client itself only when there are none. offline_access asks for a refresh token that outlives the user’s rIDM session; see step 4.

Expire pending sign-ins after a few minutes (the example keeps them ten).

3. Finish it (/callback)

Treat every callback as a refusal until each check passes:

  1. an error parameter means rIDM refused; show error_description;

  2. state must be one you issued and have not spent; take it out of the store as you read it;

  3. spend the code, authenticating with the secret in the Authorization header (client_secret_basic):

    curl -s -u "billing-web:$CLIENT_SECRET" \
      -d grant_type=authorization_code \
      -d code="$CODE" \
      -d redirect_uri=https://billing.acme.example/callback \
      -d code_verifier="$VERIFIER" \
      https://id.example.com/t/acme/token
    

    The answer carries access_token, id_token, refresh_token and expires_in. rIDM accepts only the authentication method registered for the client; the same secret sent as client_secret_post is invalid_client.

  4. verify the ID token’s signature and claims. ridm-auth does this with a validator configured for ID tokens: the audience is the client, and the JOSE typ is JWT rather than at+jwt:

    #![allow(unused)]
    fn main() {
    let id_tokens = Validator::builder(&issuer)
        .audience(&client_id)
        .token_type(Some("JWT"))
        .discover()
        .await?
        .shared();
    
    let claims = id_tokens.validate(&id_token).await?;
    if claims.claim("nonce").and_then(|n| n.as_str()) != Some(pending.nonce.as_str()) {
        // not the answer to this sign-in
    }
    }
  5. open a session: store the tokens server-side, and give the browser only an opaque session id:

    Set-Cookie: session=…; Path=/; HttpOnly; SameSite=Lax; Secure
    

    HttpOnly keeps it away from scripts. SameSite=Lax still sends it on the top-level redirect back from rIDM. Secure once the app is on https. Keep the session’s sid claim from the ID token: back-channel logout uses it.

4. Call the API, refreshing as needed

Access tokens live five minutes by default. Before using one, check its expiry (the example allows ten seconds of margin) and refresh if needed:

curl -s -u "billing-web:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d resource=https://invoices.acme.example \
  https://id.example.com/t/acme/token

rIDM rotates refresh tokens: the answer’s refresh_token replaces the one you sent, and must be stored before anything else happens. Presenting a spent refresh token again is treated as theft: the whole family is revoked and the answer is invalid_grant (“refresh token reuse detected”). With two replicas of your app, make sure two concurrent requests for the same session cannot both refresh with the same token (serialise per session, or share the session store).

A refresh fails with invalid_grant once the refresh token has been revoked, which happens whenever the rIDM session it was issued in is signed out: the user signing out from any application in the session or from the account console, an administrator revoking the session or signing the user out everywhere, a password change or reset that ends other sessions, or the user being disabled. A refresh token issued without offline_access also stops once the session times out (30 minutes idle, 12 hours absolute by default; each refresh counts as activity). One with offline_access, which rIDM grants only when every API the token is for allows it, survives the timeouts but not a sign-out. Either way, invalid_grant is the signal to drop the local session and show the signed-out page.

5. Sign out (/logout)

In this order:

  1. revoke the refresh token (RFC 7009), authenticating as the client:

    curl -s -u "billing-web:$CLIENT_SECRET" \
      -d token="$REFRESH_TOKEN" -d token_type_hint=refresh_token \
      https://id.example.com/t/acme/revoke
    

    Revocation answers 200 whatever it finds; a failure must not stop the sign-out.

  2. delete the local session and clear the cookie (Max-Age=0);

  3. redirect the browser to the end-session endpoint (RP-initiated logout):

    GET /t/acme/end_session?id_token_hint=…
        &post_logout_redirect_uri=https%3A%2F%2Fbilling.acme.example%2F
        &client_id=billing-web
    Host: id.example.com
    

Skip step 3 and the SSO session survives: the next /login is silent and instant and the user concludes sign-out did not work. With a valid id_token_hint for the browser’s session, rIDM ends it at once and redirects to the registered post_logout_redirect_uri; without one it asks the user to confirm, so a link from elsewhere cannot sign users out.

6. Back-channel logout (/backchannel-logout)

Whenever an rIDM session is signed out, rIDM revokes the session’s refresh tokens and POSTs a logout token to the backchannel_logout_uri of every client that took part in the session. That covers the end-session endpoint, whichever application started it, and every other sign-out: the user in the account console, an administrator revoking one or all of the user’s sessions, a password change or reset that signs out other sessions, the user being disabled or deleted (also through SCIM), and the oldest session being evicted under the tenant’s max_concurrent limit. No browser is involved:

POST /backchannel-logout
Content-Type: application/x-www-form-urlencoded

logout_token=eyJ…

The logout token is a JWT signed with the tenant’s keys, JOSE typ logout+jwt, with iss, aud (your client_id), iat, exp (two minutes), jti, sub, sid, and events containing http://schemas.openid.net/event/backchannel-logout. Verify it with a third validator:

#![allow(unused)]
fn main() {
let logout_tokens = Validator::builder(&issuer)
    .audience(&client_id)
    .token_type(Some("logout+jwt"))
    .discover()
    .await?
    .shared();

let claims = logout_tokens.validate(&form.logout_token).await?;
let is_logout = claims
    .claim("events")
    .and_then(|e| e.as_object())
    .is_some_and(|e| e.contains_key("http://schemas.openid.net/event/backchannel-logout"));
if !is_logout || claims.claim("nonce").is_some() {
    // refuse: 400
}
// end every local session whose `sid` matches claims.sid
}

The different typ values are what stop an ID token being accepted as a logout token or the other way round. Answer 200 with Cache-Control: no-store when the session is ended (or was already gone) and 400 for a token you refuse. rIDM makes one attempt per client with a five-second timeout and does not retry, so the endpoint should be quick and must be reachable from rIDM. It must also resolve to a public address: rIDM refuses to deliver to private, loopback (other than localhost itself, for development) and other internal addresses unless the operator opened that network with OUTBOUND_ALLOW_NETWORKS (the usual case for an application on an internal network), and follows no redirects (see Outbound requests).

A missed logout token is not fatal: the session’s refresh tokens are revoked either way, so the app also finds out at its next refresh, within one access-token lifetime. See Sign-in flows and sessions.

Production notes

  • The example keeps sessions and pending sign-ins in a HashMap, so a restart signs everyone out and replicas share nothing. Use Redis, Valkey or a database.
  • Treat the client secret like a database password. Rotate it with POST /admin/tenants/{slug}/clients/{client}/secrets: the old secret keeps working for a grace period (24 hours unless the body sets grace_secs, at most 30 days), so a rolling deploy does not lock the app out. See Registering clients.
  • private_key_jwt replaces the shared secret with a key pair; see Machine-to-machine access.
  • Token contents are listed in Token claims.

Machine-to-machine access

A program with no user behind it (a nightly job, a backend calling another backend, a CI pipeline) gets an access token with the OAuth 2.0 client_credentials grant: it authenticates as itself and receives a token for one API. By the end of this page a job called orders-job places an order through the example orders API.

This page uses the demo tenant and the orders API from Run rIDM locally and Protect a Rust API:

export RIDM_URL=http://localhost:8090
export RIDM_TOKEN=rpat_...        # ridm:clients:*, ridm:users:*, ridm:roles:read

RIDM_ISSUER=http://localhost:8090/t/demo RIDM_AUDIENCE=https://orders.example \
RIDM_ALLOW_HTTP=true cargo run -p ridm-example-axum-api      # :8081, in another terminal

1. Register a machine client

ridm --tenant demo client create \
  --name "Orders nightly job" \
  --client-id orders-job \
  --type machine \
  --audience https://orders.example \
  --scope orders:read --scope orders:write
Client `orders-job` created (Orders nightly job).
client secret: cs_...
  (shown once — store it now)

--type machine means: token endpoint auth method client_secret_basic, the client_credentials grant only, no redirect URIs, no PKCE, and no scopes unless you list them. --audience limits which resource servers the client may get tokens for; leave it out and any non-built-in resource server of the tenant is allowed, which is rarely what you want.

The secret is shown once. Store it like a database password.

2. Get a token

CLIENT_SECRET=cs_...
curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://orders.example \
  -d scope="orders:read orders:write" \
  http://localhost:8090/t/demo/token | jq
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "orders:read orders:write"
}
ParameterMeaning
-u client_id:secretclient_secret_basic: the credentials in the Authorization header. rIDM accepts only the method registered for the client.
grant_type=client_credentialsrefused for public clients (unauthorized_client)
resourceRFC 8707: the resource server the token is for. It becomes aud. Repeat it for several. It must be an absolute URI naming a resource server of the tenant that is in the client’s allowed_audiences; otherwise invalid_target.
scopeoptional; every scope must exist in the tenant and be allowed for the client (invalid_scope otherwise). openid and offline_access are refused: there is no user to identify and nothing to refresh. Without scope, the token gets the tenant’s default scopes (is_default) that the client is allowed, other than those two, which is often none. A scope bound to a resource server (as orders:read is to https://orders.example in the demo tenant) also adds that resource server to aud.

Without resource, the token is minted for every audience in the client’s allowed_audiences; with that list empty too, aud is the client’s own client_id, which no API accepts. Send resource every time.

There is no refresh token: when the access token expires (five minutes by default), ask for a new one. Cache the token and reuse it until shortly before expires_in runs out rather than requesting one per call. The token endpoint is rate-limited per tenant and per client address; see Rate limits, IP rules and CAPTCHA.

3. Call the API, and be refused

TOKEN=$(curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d resource=https://orders.example \
  http://localhost:8090/t/demo/token | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/whoami | jq
curl -i -H "Authorization: Bearer $TOKEN" localhost:8081/orders

/whoami answers, because the token is valid for https://orders.example:

{
  "subject": "orders-job",
  "tenant": "…",
  "client": "orders-job",
  "scopes": [],
  "roles": [],
  "permissions": [],
  "client_only": true
}

GET /orders answers 403 insufficient_scope: the token is missing permission orders:read. The subject is the client itself (sub equals client_id), and a client on its own holds no roles, so the token carries no permissions.

Scopes are a different matter. An API can gate on the scopes a client was allowed (Guard::scope("orders:read") in ridm-auth), and a token requested with scope=orders:read carries it. The orders API checks permissions, which come from roles, and roles belong to users. That is what a service account is for.

4. Give the client a service account

A service account is a user of the tenant, named svc-{client_id}, that the client’s client_credentials tokens are issued for. It holds roles and groups like any other user, so the token carries their permissions.

SVC=$(curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/clients/orders-job/service-account" | jq -r .service_account.id)

ROLE=$(curl -s -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/roles" | jq -r '.[] | select(.name=="orders-manager") | .id')

curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "$RIDM_URL/admin/tenants/demo/users/$SVC/roles/$ROLE" | jq .effective
RequestEffect
PUT /admin/tenants/{slug}/clients/{client}/service-accountcreates the svc-orders-job user and links it to the client; returns the client and service_account. Idempotent. Needs ridm:clients:write, and the client must allow client_credentials.
DELETE …/service-accountunlinks and deletes that user
PUT /admin/tenants/{slug}/users/{user}/roles/{role_id}assigns a role; assigning one twice is not an error. Needs ridm:users:write, and an administrator can grant only permissions it holds.

In a configuration document the same client reads "service_account": true; role assignments are not part of the document, so they stay an admin API (or console) step. See Configuration as code.

Request a new token (the old one keeps what it was minted with) and try again:

TOKEN=$(curl -s -u "orders-job:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d resource=https://orders.example \
  http://localhost:8090/t/demo/token | jq -r .access_token)

curl -s -H "Authorization: Bearer $TOKEN" localhost:8081/whoami | jq '{subject, roles, permissions, client_only}'
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"item":"Anvil","quantity":2}' localhost:8081/orders

Now subject is the service account’s user id, roles is ["orders-manager"], permissions is ["orders:read", "orders:write"], client_only is false, and the order is placed (201). In ridm-auth, Claims::is_client_only() tells a client acting on its own from one with a service account.

Administering rIDM from a machine client

The admin API is itself a resource server, urn:ridm:admin. It is built in, so a client must list it in allowed_audiences explicitly; an empty list does not imply it. With that audience, a service account holding administrator roles in master or the tenant, and a secret, the CLI runs non-interactively:

printf '%s' "$CI_CLIENT_SECRET" |
  ridm login --url https://id.example.com --tenant acme --client-id ci-bot --client-secret-stdin

See The ridm command line and Administrator access.

Stronger client authentication: private_key_jwt

A client secret is a shared secret: rIDM stores only its hash, but whoever holds the secret can use it, and it has to travel to every place the job runs. With private_key_jwt (RFC 7523) the client instead holds a private key that never leaves it, and rIDM holds the public key. Each token request carries a short signed assertion in place of a secret.

ridm client create has no flag for the client’s keys, so register this kind of client through the admin API, with either an inline jwks or a jwks_uri the client publishes (rIDM refetches a jwks_uri when an assertion names a key it has not seen, so the client can rotate its own keys):

curl -s -X POST "$RIDM_URL/admin/tenants/demo/clients" \
  -H "Authorization: Bearer $RIDM_TOKEN" -H 'content-type: application/json' \
  -d '{
        "client_id": "orders-sync",
        "name": "Orders sync service",
        "client_type": "machine",
        "token_endpoint_auth_method": "private_key_jwt",
        "jwks_uri": "https://sync.acme.example/.well-known/jwks.json",
        "allowed_audiences": ["https://orders.example"]
      }'

No secret is minted. The assertion is a JWT the client signs with its private key:

Claim or headerValue
algan asymmetric algorithm; HMAC (HS256 …) is refused
kidthe key’s id in the client’s key set (may be omitted when the set has exactly one key)
iss, subboth the client_id
audthe tenant’s token endpoint URL, or its issuer
expat most 600 seconds after iat
jtiunique; a reused jti is refused for the assertion’s lifetime
curl -s \
  -d grant_type=client_credentials \
  -d resource=https://orders.example \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$ASSERTION" \
  https://id.example.com/t/demo/token

Everything after authentication (audience, scopes, service account, permissions) works exactly as with a secret. The confidential web app in Sign in from a server-side web app can use private_key_jwt the same way.

Troubleshooting

Answer from /tokenCause
401 invalid_clientwrong secret, a secret sent with a method other than the registered one, a retired secret, or a bad assertion
400 unauthorized_clientthe client is public, or not allowed client_credentials
400 invalid_scopea scope the tenant does not define, one the client is not allowed, one bound to a resource server the client may not target, or openid / offline_access
400 invalid_targetresource is not an absolute URI, names no resource server, or names one outside allowed_audiences

Every error code is listed in Errors; the claims in these tokens are in Token claims.

The admin and account consoles

rIDM ships two browser consoles in its Next.js UI (ui/):

  • the admin console at /console/, where administrators manage tenants, users, clients and everything else the admin API offers;
  • the account console at /account/, where end users manage their own profile, security and connected applications.

Both are static pages served wherever the UI is, the same host as the login pages: by the server itself in the container image (the embedded UI), or by next dev or any static host serving the ui/out export. UI_URL tells the server where that is (it defaults to PUBLIC_URL, the embedded UI’s origin), because the consoles’ built-in clients redirect there. See Deployment overview.

Neither console has privileges of its own. Each is an ordinary OIDC public client of the tenant you sign in through, and every action it takes is an API call made with your token, checked like any other.

The admin console

Open https://id.example.com/console/ (or wherever UI_URL points).

Signing in

Signed out, every console page shows a card asking which tenant to sign in through. Global administrators use master; the administrator of acme alone uses acme. The last tenant used is remembered. The browser then goes through that tenant’s normal login page, including a forced password change or a second factor if one is due, and comes back to /console/callback/ with an authorization code that the page exchanges with PKCE.

The client it signs in with is ridm-admin-console, built into every tenant: public, PKCE only, no consent step, and urn:ridm:admin as its only audience. It is created at start-up and with every new tenant, and its redirect URIs are kept in line with UI_URL. It cannot be deleted. See Administrator access for what makes a user an administrator.

Tokens live in the tab’s sessionStorage, not in cookies or localStorage, so each tab signs in on its own and closing it forgets them. The access token is refreshed shortly before it expires, with refresh-token rotation. Because admin tokens are bound to the browser session, Sign out (RP-initiated logout) ends both the console and the tenant session at once. If the API starts refusing the token (a revoked session, a removed role), the console drops back to the sign-in card with a notice.

A user who signs in but holds no admin permission gets 403 from the API, and the console says “This account has no administrator permissions”.

Finding your way

  • Command bar: across the top, with the tenant you are looking at, the search field, the theme switch and a bell that lights up when something needs attention (locked accounts, open invitations, a signing key about to stop publishing, webhook deliveries given up on); it opens the overview’s “Needs attention” list.
  • Sidebar: grouped as Identity, Applications, Security, Integrations and Tenant, and filtered by your permissions from GET /admin/me, so it only lists pages you can open. Entries with a list show how many there are; Users shows how many invitations are waiting. Below the tablet breakpoint it becomes a drawer.
  • Tenant switcher (global administrators): pick the tenant every page acts on. The choice travels in the URL as ?tenant=acme, so links deep-link into a tenant. Tenant administrators always act on their own tenant.
  • Search (Ctrl K / ⌘ K, or /): console pages you may open, plus users and clients of the current tenant whose username, email, client ID or name starts with what you type (two characters or more).
  • Breadcrumbs at the top of the page say where you are; a user’s or a client’s detail opens as a panel over its list, and the address (?user=…, ?client=…) still deep-links to it.
  • Theme: system, light or dark, kept per browser. The tenant being administered decides whether the switch is offered and what applies otherwise (see Appearance below).

Lists: filters, sorting and bulk actions

Every list is one table: filters sit above it as chips, the rows load as you scroll or with “Load more”, and the footer says how many are loaded. Filters, sort order and search live in the address (?q=ada&status=locked&sort=username&dir=desc), so a reload, a bookmark and the back button land on the same view. Users and clients sort on the server (click a column header); the other lists sort what they have loaded.

Where a list has a checkbox column, selecting rows raises a bar at the bottom with the actions that apply to all of them at once: enable, disable or delete users; revoke invitations; delete IP rules. Every row is attempted even if one refuses, and one message sums the run up (“2 of 3 users deleted; 1 failed: …”). Destructive actions, single or bulk, ask first.

Appearance

Settings → Appearance decides how the sign-in pages, the account console and this console (while it administers the tenant) look:

  • Skin: signal-v2 (the default: dark command bar, tinted ground, the page as a sheet), signal, vault or registry. A skin is a set of tokens and a few layout switches; every page works in all four.
  • Primary and contrast colours: a hex pair (six presets, or your own). The primary drives actions, navigation, links and focus; the contrast marks what must read as different (selection, the tenant mark, attention). The page shows both tonal ramps and the readability checks for light and dark.
  • Light/dark policy: follow each person’s device, or always light or dark; and whether people may switch on their own device.
  • Headline and tagline: with the Signal v2 skin, a headline turns the sign-in page into a split layout with a brand panel; the other skins keep the single card.
  • Logo, favicon, background colour, support link, footer links and custom CSS, as before.

The login-page preview on the right follows every change. The settings are part of the tenant document (settings.branding), see Tenants and tenant settings.

Keyboard shortcuts

Single keys work when no text field has focus; ? lists them.

KeysDoes
Ctrl K / ⌘ K, /Search
tTenant switcher (global administrators)
?The shortcut list
g then oOverview
g then u / g / z / rUsers / Groups / Organizations / Roles
g then c / b / a / p / mClients / SAML / Resource servers / Scopes / Claim mappers
g then k / l / i / nSigning keys / Audit log / IP rules / Client certificates
g then w / e / d / vWebhooks / Messaging / Identity providers / Provisioning
g then t / s / f / xTenants / Settings / Feature flags / Export & import

The g sequence waits one second for its second key; Esc cancels it, and a held modifier (Shift ahead of ?) does not count as the second key.

Pages

PagePathNeedsCovered in
Overview/console/any admin permissionSign-ins, failures, live sessions and second-factor adoption over 7, 30 or 90 days with the change against the window before, what needs attention, the newest audit events and the most authorized clients (GET /admin/tenants/{slug}/stats, ridm:tenants:read)
Users/console/users/ridm:users:readUsers, invitations and bulk import
Groups, Roles/console/groups/, /console/roles/ridm:groups:read, ridm:roles:readUsers, groups and roles
Organizations/console/organizations/ridm:orgs:readOrganizations
Clients/console/clients/ridm:clients:readRegistering clients
SAML/console/saml/ridm:clients:read (keys: ridm:keys:write)SAML identity provider
Playground/console/playground/?client=<id>reached from a client’s detail pageRuns the client’s flow for real (authorization code with PKCE, or client credentials with a pasted secret) and shows the token response, the decoded tokens and userinfo
Resource servers, Scopes, Claim mappers/console/resource-servers/, /console/scopes/, /console/claim-mappers/the matching read permissionResource servers, scopes and permissions
Signing keys/console/keys/ridm:keys:readRotating keys
Audit log/console/audit/ridm:audit:readWebhooks and the audit log
IP rules/console/ip-rules/ridm:tenants:readRate limits, IP rules and CAPTCHA
Client certificates/console/client-certificates/ridm:tenants:readMutual TLS
Webhooks/console/webhooks/ridm:webhooks:readWebhooks and the audit log
Messaging/console/messaging/ridm:messaging:readEmail, SMS and templates
Identity providers/console/identity-providers/ridm:idps:readIdentity brokering
Provisioning/console/provisioning/ridm:scim:readSCIM provisioning
Tenants/console/tenants/ridm:tenants:readTenants and tenant settings
Settings/console/settings/ridm:tenants:readTenants and tenant settings
Feature flags/console/features/ridm:tenants:readFeature flags
Export & import/console/config/ridm:tenants:exportConfiguration as code

With only a read permission, a page shows its data but disables or hides the controls that would change it. An organization administrator sees that one organization’s members, domains, roles and invitations and nothing of the tenant’s own pages.

Saving as you go

Detail pages and Settings have no Save button. Each change is applied on the page at once and sent as a merge patch when typing pauses (600 ms, and at most 2.5 s into continuous editing; also when the tab is hidden or closed). The header shows “Unsaved changes”, “Saving…”, “Saved”, or the API’s reason for refusing, after which the stored values are reloaded. Actions such as assigning a role or adding a group member apply at once.

Secrets the API returns once (a client secret, a temporary password, a webhook signing secret, a personal access token, a dynamic registration initial access token) are shown in a dialog that says so. Copy them before closing it.

The account console

Open https://id.example.com/account/. Signed out, each page asks which organisation (tenant) to sign in through; ?tenant=acme fills it in and the last one is remembered. After signing in, the user comes back to the page they asked for.

The account console signs in with the built-in ridm-account-console client, whose tokens carry the urn:ridm:account audience and reach only the self-service API under /t/{slug}/account/. A token acts only on its own user, only in the tenant that issued it, and only while its browser session is alive.

PageWhat the user can do
ProfileEdit the attributes the tenant’s profile schema lets users edit (others are shown read-only), choose a language, change their email address or phone number (proven by a code sent to the new one), remove the phone number
SecuritySee how well the account is protected (a score from the password, the second steps, recovery codes and a verified email, with the next step to take); change the password (the current one is required while one is set, with an option to sign out everywhere else); enrol and remove second factors and regenerate recovery codes; link and unlink upstream accounts; list and revoke trusted devices; list sessions (named by browser and system) and end one or all but this one; mint and revoke personal access tokens
ApplicationsSee the applications they consented to, with scopes and the applications’ privacy and terms links, and remove access, which also revokes the application’s refresh tokens
RequestsApprove or deny the backchannel sign-in requests applications sent them; the page refreshes itself while open
Your dataDownload everything held about them as JSON (no secrets), and delete the account after typing the username

Every security change (a factor, a device, a session, the password, a contact detail, the export, deletion) needs a sign-in from the last fifteen minutes, including the second step once the user has one. If it is older, the console sends the user through sign-in again and back to where they were.

Administrators govern what the account console allows with settings.account:

SettingDefaultEffect
self_deletiontrueWhether users may delete their own account
deletion_retention_days30How long a deleted account is kept before it is purged
personal_tokenstrueWhether users may mint personal access tokens
personal_token_max_days365Longest token lifetime (0 no limit)

settings.mfa_methods and settings.auth.passkey decide which second factors the Security page offers (see MFA policy), and the identity providers configured for the tenant decide which upstream accounts can be linked.

Administrators cannot delete themselves from the account console; another administrator has to.

The account console and the sign-in pages wear the tenant’s appearance (skin, colours, light/dark policy) and offer the light/dark switch when the tenant allows it; see Appearance.

The ridm command line

ridm is the command-line administration tool, built from crates/ridm-cli. Every command except bootstrap is a client of the admin API: it resolves a server URL, a tenant and a bearer token, sends one or two requests, and prints either a readable summary or the server’s JSON. It needs nothing but network access to the server and a token.

Each release ships a static ridm for linux amd64 and arm64 (Releases and verification). To build it from the repository:

cargo build --release -p ridm-cli        # target/release/ridm
cargo run -p ridm-cli -- --help          # while developing

bootstrap links the server library and talks to the database directly. Build with --no-default-features for a slim, HTTP-only ridm without it; bootstrapping is then left to ridm-api bootstrap in the server container.

cargo build --release -p ridm-cli --no-default-features

A first session

ridm login --url https://id.example.com             # paste a personal access token
ridm whoami

ridm --tenant acme tenant export -o acme.json        # configuration as code
ridm --tenant acme tenant diff   -f acme.json        # what an import would change
ridm --tenant acme tenant import -f acme.json        # plan, confirm, apply

ridm --tenant acme key rotate
ridm --tenant acme user create alice --email alice@example.com --temporary-password
ridm --tenant acme user reset alice --revoke-sessions
ridm --tenant acme client create --name "Acme SPA" --type spa \
     --redirect-uri https://app.acme.example/callback
ridm master-key status

Global flags

These work with every command, before or after the subcommand.

FlagEnvironmentDefaultMeaning
--profile NAMERIDM_PROFILEthe selected profile, else defaultStored profile to use
--url URLRIDM_URLthe profile’sServer origin, such as https://id.example.com (a trailing slash is dropped)
--tenant SLUGRIDM_TENANTthe profile’s, else masterTenant the command acts on
--token TOKENRIDM_TOKENthe profile’s credentialBearer token to present; never written to disk
--output text|json—textjson prints the API response verbatim, for jq

ridm --version prints the version; ridm help <command> or --help on any command prints its flags.

The CLI also reads a .env file in the working directory at start-up, so RIDM_URL and RIDM_TOKEN (and, for bootstrap, the server’s own variables) may live there.

Profiles and the config file

ridm login writes the server URL, the tenant and the credential to a profile. All profiles live in one JSON file:

  1. $RIDM_CONFIG, when set;
  2. else $XDG_CONFIG_HOME/ridm/config.json;
  3. else ~/.config/ridm/config.json.

The file is written with mode 0600, because a profile may hold a personal access token, a refresh token or a client secret. Nothing is written until ridm login runs: --url and --token (or RIDM_URL and RIDM_TOKEN) are enough on their own for a one-off command or a pipeline, and never touch the file.

{
  "current": "prod",
  "profiles": {
    "prod": {
      "url": "https://id.example.com",
      "tenant": "master",
      "credential": { "kind": "token", "token": "rpat_…" }
    }
  }
}

The first profile stored becomes the selected one. A profile’s tenant is the tenant its credential belongs to and the default target of commands; --tenant acts on another tenant with the same credential, which works only for a global administrator (see Administrator access).

An OAuth credential ("kind": "oauth") is renewed in place about a minute before the access token expires: with the refresh token while one lasts, else, for a confidential client, with the stored secret. The renewed credential is written back so the next command reuses it.

Authenticating

An admin token must carry urn:ridm:admin in aud and belong to a user (or a machine client’s service-account user) holding at least one ridm:* permission, so the CLI always asks for that resource. ridm login has three routes:

RouteCommandUse it for
Personal access token (rpat_…)ridm login --url … and paste, or --token-stdinInteractive use and CI; nothing to register
Client credentialsridm login --url … --client-id ID --client-secret-stdinAutomation acting as a machine client’s service account
Device authorization grantridm login --url … --client-id ID --deviceAn operator approving in a browser; a refresh token keeps the session

ridm login checks the credential against GET /admin/me before storing it, and refuses to store one that does not get through.

  • Personal access token. Mint one in the account console under Security → personal access tokens, choosing the admin permissions it carries (see Personal access tokens). In CI, put it in RIDM_TOKEN and skip login.
  • Client credentials. The client needs the client_credentials grant, urn:ridm:admin in its allowed_audiences, and a service account holding admin roles. With --client-id and neither --client-secret-stdin nor --device, the secret is prompted for.
  • Device grant. The tenant must offer the device endpoint, and the client must allow urn:ietf:params:oauth:grant-type:device_code and list urn:ridm:admin in its audiences. The CLI prints a URL and a code and polls until the code is approved, gives up after the code expires or fifteen minutes, whichever is sooner. The built-in console clients deliberately do not allow this grant; register a device client for it.
# CI: a token from the secret store, no profile file at all
export RIDM_URL=https://id.example.com RIDM_TOKEN="$RIDM_ADMIN_TOKEN"
ridm --tenant acme tenant diff -f acme.json --exit-code

# a stored profile authenticated as a machine client
printf '%s' "$CLIENT_SECRET" | ridm login --url https://id.example.com \
  --name ops --client-id ops-automation --client-secret-stdin

Commands

login, logout, whoami, profile

CommandFlagsDoes
ridm login--name NAME profile to write (default: the selected one, else default); --token-stdin; --client-id ID; --client-secret-stdin (needs --client-id); --device (needs --client-id); --scope SCOPE (default openid, for the device grant)Obtain a credential, verify it with GET /admin/me, store it. --token-stdin and --client-id are mutually exclusive; a global --token is stored as given
ridm logout--allForget the profile’s credential; --all removes the profile
ridm whoami—GET /admin/me: user, tenant, scope (global or tenant), roles and permissions, and the organization the sign-in acts in when there is one
ridm profile list—Stored profiles, the selected one marked, and the file path
ridm profile use NAME—Select the profile commands use by default
ridm profile show [NAME]—One profile (default: the selected one), credential redacted

bootstrap

Creates the first global administrator. See Bootstrap below.

FlagMeaning
--email EMAILAdministrator’s email (else BOOTSTRAP_ADMIN_EMAIL, else prompted)
--username NAMEUsername (else BOOTSTRAP_ADMIN_USERNAME, else admin)
--password-stdinRead the password from stdin (else BOOTSTRAP_ADMIN_PASSWORD, else prompted twice)
--no-must-changeLet the administrator keep this password past the first sign-in
--no-migrateDo not apply database migrations first

tenant

[SLUG] defaults to the tenant from --tenant or the profile.

CommandFlagsAPI call
ridm tenant list--limit N page sizeGET /admin/tenants
ridm tenant show [SLUG]—GET /admin/tenants/{slug} (always printed as JSON)
ridm tenant create SLUG--name NAME display name (default: the slug)POST /admin/tenants; needs ridm:tenants:create, which only a global owner holds
ridm tenant export [SLUG]-o, --out FILE (default: stdout)GET /admin/tenants/{slug}/export
ridm tenant import [SLUG]-f, --file FILE (default -, stdin); --prune; -y, --yesPOST /admin/tenants/{slug}/import?dry_run=true for the plan, then without dry_run to apply
ridm tenant diff [SLUG]-f, --file FILE (default -); --prune; --exit-codePOST /admin/tenants/{slug}/import?dry_run=true

import shows the plan, with any items the server would refuse (for example a role granting admin permissions you do not hold yourself), and asks before applying; --yes skips the question and is required when stdin is not a terminal. --prune also deletes configuration the document does not mention. An import that reports per-item errors exits 1 after printing them; secrets of clients and webhooks it created are printed once. diff prints the plan and fails with exit 1 when the dry run reports items that would be refused (not with --output json, which prints the plan as it arrived); otherwise diff --exit-code exits 3 when the plan is not empty. The document format is described in Tenant configuration document, and the workflow in Configuration as code.

key and master-key

CommandFlagsAPI call
ridm key list--status pending|active|retiring|revokedGET /admin/tenants/{slug}/keys
ridm key rotate—POST /admin/tenants/{slug}/keys/rotate: a new key with the tenant’s default algorithm, active at once
ridm master-key status—GET /admin/master-key: current generation and rows still under older ones
ridm master-key rotate-y, --yesPOST /admin/master-key/rotate: re-encrypt every secret at rest under the current generation
ridm master-key new-generation—POST /admin/master-key/generations: have the key custody backend wrap a new generation and make it current; refused when the master key comes from the environment

The master-key commands need a global administrator. See Rotating keys.

audit

CommandFlagsAPI call
ridm audit verify--head HEX the hash the chain must end on; --global the global chainGET /admin/tenants/{slug}/audit/verify (or /admin/audit/verify)
ridm audit verify -f FILE--head HEX; --after HEX the hash the first row must follow; - reads stdinnone: the file is checked here, trusting nothing else
ridm audit export-o, --out FILE (default: stdout); --format json|csv; --from, --to RFC 3339 bounds; --globalGET /admin/tenants/{slug}/audit/export, streamed to disk

verify prints Intact: N rows (seq A–B), ending on <hash> and exits 0, or names the first row that doesn’t hash or link and exits 1. Keep the hash: passing it as --head later proves the chain was not cut off or rewritten since. See Checking an export yourself.

user

CommandFlagsAPI call
ridm user create USERNAME--email, --email-verified, --phone (E.164), --locale TAG, --status active|disabled|pending, --external-id ID, --attributes JSON (an object), --password-stdin or --temporary-passwordPOST /admin/tenants/{slug}/users
ridm user reset USER--password-stdin, --no-must-change, --skip-policy, --notify, --revoke-sessionsPUT /admin/tenants/{slug}/users/{id}/password

USER is a user id, a username or an email address. A name that is not a UUID is looked up through the user search and must match exactly one account, so a typo never resets somebody else’s password. Without --password-stdin, reset generates a temporary password and prints it once; either way the user must change it at the next sign-in unless --no-must-change is given. create without a password flag makes an account with no password (the user can recover one through the reset flow, or sign in another way the tenant offers).

client

CommandFlagsAPI call
ridm client create--name NAME (required), --client-id ID, --type spa|web|native|machine|device, --redirect-uri URI, --post-logout-redirect-uri URI, --grant GRANT, --scope SCOPE, --audience AUDIENCE, --cors-origin ORIGIN (each repeatable), --auth-method none|client_secret_basic|client_secret_post|private_key_jwt, --description TEXT, --no-pkce, --no-consentPOST /admin/tenants/{slug}/clients
ridm client iat create--description TEXT, --expires-in SECS (default: never), --max-uses N (default: no limit)POST /admin/tenants/{slug}/dcr/initial-access-tokens; the token is printed once
ridm client iat list—GET /admin/tenants/{slug}/dcr/initial-access-tokens: id, description, uses/limit, expiry, revocation; never the token
ridm client iat revoke ID—DELETE /admin/tenants/{slug}/dcr/initial-access-tokens/{id}

Anything not given takes the server’s type-driven defaults; --grant and --scope left out mean “the defaults for this type”, not “none”. A confidential client’s secret is printed once. private_key_jwt needs a JWKS, which the CLI cannot set: create the client in the console or through the API instead.

client iat manages the initial access tokens that dynamic client registration demands when the tenant’s dcr.mode is initial_access_token. See Registering clients.

Output

Without --output json, results are aligned tables and short sentences on stdout; prompts, progress and “shown once” notes go to stderr, so ridm … > file captures only the result. With --output json, the API’s response is printed as it arrived.

Errors from the admin API are printed from their RFC 9457 problem document, with per-field messages on their own lines:

ridm: POST https://id.example.com/admin/tenants/acme/clients → 400 Bad Request: unknown scope(s): billing

Exit codes

CodeMeaning
0Success
1The command ran and failed: the API refused it, the network failed, an import reported item errors
2The command line or the configuration behind it was wrong: bad flags, no server URL, no credential, a confirmation needed on a pipe without --yes. Nothing ran
3ridm tenant diff --exit-code found changes; the plan has been printed

Bootstrap

A new deployment has a master tenant and no administrator. ridm bootstrap creates the first global administrator, a user in master holding the built-in ridm:owner role. It cannot use the admin API, because no token can exist yet, so it reads the server’s own environment (DATABASE_URL, REDIS_URL, MASTER_KEY and the rest; see Server configuration) and runs the same code as the server’s own ridm-api bootstrap and its start-up bootstrap from BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD.

Unlike ridm-api bootstrap, which migrates only when MIGRATE_ON_START=true, ridm bootstrap applies pending migrations first unless --no-migrate is given. That needs a DATABASE_URL whose role owns the schema; against the DML-only app role, run ridm-api migrate as the migrator beforehand and pass --no-migrate. It does not create the BOOTSTRAP_SAMPLE_CLIENT sample client; the server does that.

source .env     # the server's DATABASE_URL, REDIS_URL, MASTER_KEY, PUBLIC_URL, ...
printf '%s' 'a-long-passphrase' | ridm bootstrap --email admin@example.com --password-stdin
  • It is idempotent: once any live user in master holds ridm:owner, it changes nothing and says so.
  • The password must satisfy the master tenant’s password policy (twelve characters by default); a refused password leaves nothing half-created.
  • The administrator must change the password at the first sign-in unless --no-must-change is given.

Then sign in to the admin console through master, mint a personal access token in the account console, and run ridm login --url <server URL>.

A token without a browser

--issue-token NAME also mints a personal access token for the administrator named by --username (default admin, or BOOTSTRAP_ADMIN_USERNAME), carrying every admin permission that user holds and expiring after --token-days (30 by default, and at most the master tenant’s personal_token_max_days). The token is printed alone on stdout, everything else goes to stderr, so a script can capture it:

RIDM_TOKEN=$(ridm bootstrap --no-migrate --issue-token ci-setup --token-days 1)

It works on an already-bootstrapped database too — then it creates nothing and needs no password, only the user to mint for. It is meant for development stacks and automated setup: anyone who holds DATABASE_URL and MASTER_KEY already controls every tenant, so it grants nothing new, but the token is as powerful as the administrator. It is recorded in the audit log as a token created by the system, and appears in the administrator’s account console where it can be revoked.

Tenants and tenant settings

A tenant is an isolated identity realm with its own issuer (https://id.example.com/t/acme), users, clients, keys and settings. This page covers creating and managing tenants and every key of the tenant settings document. For what a tenant is, see Tenants and issuers.

The master tenant

Every deployment has a master tenant, created by the first migration. Its users are global administrators (see Administrator access). It cannot be disabled or deleted. Keep ordinary users and applications in their own tenants; master is for the people who run the deployment.

Creating a tenant

Creating tenants needs ridm:tenants:create, which only a global owner holds.

WhereHow
ConsoleTenants (/console/tenants/) → New tenant; you land on the new tenant’s settings
CLIridm tenant create acme --name "Acme Corp"
APIPOST /admin/tenants
POST /admin/tenants
Authorization: Bearer rpat_…
Content-Type: application/json

{ "slug": "acme", "display_name": "Acme Corp", "settings": { "registration": { "enabled": true } } }
  • slug is 1–63 lowercase letters, digits or hyphens, not starting or ending with a hyphen. It is part of every URL of the tenant (/t/acme/…) and cannot change.
  • display_name is 1–255 characters; it is shown on the login pages and in the console.
  • settings is optional: anything left out takes the defaults below.
  • data_region is optional: on a deployment with data regions, the region whose database keeps the tenant’s data (GET /admin/regions lists them; absent or home: the home database). It is fixed at creation; ridm-api move-tenant moves a tenant later.

A new tenant is seeded with the standard scopes (openid, profile, email, phone, address, offline_access, features), the urn:ridm:admin and urn:ridm:account resource servers, the six built-in admin roles, and the two built-in console clients (ridm-admin-console, ridm-account-console). Its first signing key is made on first use. It has no users: invite or create an administrator for it next (see Users, invitations and bulk import).

Managing a tenant

TaskConsoleAPIPermission
List tenantsTenantsGET /admin/tenants?cursor=&limit=ridm:tenants:read; tenant administrators see only their own
Read a tenantSettingsGET /admin/tenants/{slug}ridm:tenants:read
Rename, disable, change settingsSettingsPATCH /admin/tenants/{slug}ridm:tenants:write
DeleteSettings → delete zone (global owners)DELETE /admin/tenants/{slug}ridm:tenants:delete, global
Export / import configurationExport & importGET …/export, POST …/importridm:tenants:export / ridm:tenants:import

PATCH takes display_name, status (active or disabled) and settings. The settings are a JSON merge patch (RFC 7396) over the stored document: send only what changes, and null resets a key to its default. A field name the settings document does not have is refused with 400 (“unknown settings fields: …”) rather than silently dropped, because it is almost certainly a typo; that includes the removed registration.captcha. Tagged settings switch variant cleanly: {"mfa": {"mode": "optional"}} replaces a required_for_roles policy without clearing roles by hand.

curl -s -X PATCH "https://id.example.com/admin/tenants/acme" \
  -H "Authorization: Bearer $RIDM_TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"password": {"min_length": 14}, "mfa": {"mode": "required_for_admins"}}}'

A disabled tenant refuses every sign-in and token request, and its tokens stop working at the admin API; administrators can still read it through the admin API and re-enable it. Deleting a tenant permanently removes it and everything in it, with no soft-delete period.

The console’s Settings page (/console/settings/) shows every setting on one page and saves as you go: changes are joined into one merge patch sent once typing pauses. The header shows “Unsaved changes”, “Saving…”, “Saved”, or the API’s reason for refusing, after which the stored settings are reloaded.

To keep settings in version control, export the tenant with ridm tenant export acme -o acme.json and apply edits with ridm tenant import; see Configuration as code.

The settings document

Every key has a default, and a stored document written by an older version keeps loading. The top-level keys:

KeyGovernsDetails
passwordPassword rules and historybelow
sessionBrowser sessions, trusted devices, default token lifetimesbelow
mfaWhen a second factor is demandedMFA policy
mfa_methodsWhich second factors users may enrolMFA policy
riskScoring sign-ins, and what an unusual one costsAdaptive authentication
impersonationWhether administrators may sign in as users, and for how longImpersonation
authWhich first-factor sign-in methods are offeredbelow
registrationSelf-registrationbelow
lockoutBrute-force lockoutbelow
captchaWhen a CAPTCHA is demandedRate limits, IP rules and CAPTCHA
rate_limitsRequest ceilings on the OAuth and sign-in endpointsRate limits, IP rules and CAPTCHA
notificationsSecurity notices to usersbelow
accountWhat users may do in the account consolebelow
localeLanguages of the login pagesbelow
brandingLook of the login pagesbelow
keysSigning key algorithm and rotationbelow
discoveryWebFinger issuer discoverybelow
dcrDynamic client registration: disabled, open or initial_access_tokenRegistering clients
auditAudit log retentionbelow
custom_domainThe tenant’s own issuer hostCustom domains
featuresFeature flags applications readFeature flags

password

KeyDefaultMeaning
min_length12Minimum characters
max_length128Maximum characters
require_uppercasefalseNeeds an uppercase letter
require_lowercasefalseNeeds a lowercase letter
require_digitfalseNeeds a digit
require_symbolfalseNeeds a character that is neither alphanumeric nor whitespace
history5A new password must differ from this many previous ones, the current one included (0 off)
max_age_daysnullDays until a password expires and must be changed (null never)
check_breachedfalseRefuse passwords found in breach corpora

The policy applies to users’ own changes, registration, invitations, admin-set passwords (unless skip_policy is sent) and imported plaintext passwords. Imported hashes are not checked, since the plaintext is unknown.

session

KeyDefaultMeaning
idle_timeout_secs1800 (30 min)A browser session ends after this long without use
absolute_timeout_secs43200 (12 h)A session ends this long after sign-in, whatever its use
max_concurrent0Live sessions per user; the oldest is signed out first, with back-channel logout to its clients (0 unlimited)
remember_device_days30How long a “remember this device” cookie skips the second factor
access_token_ttl_secs300Default access token lifetime
refresh_token_ttl_secs2592000 (30 days)Default refresh token lifetime
id_token_ttl_secs300Default ID token lifetime

A client may set its own token lifetimes, which take precedence over these defaults (see Registering clients).

The session timeouts bound refresh tokens too. A refresh token issued without the offline_access scope belongs to the browser session it came from: it stops working once that session ends, whether by sign-out, idle_timeout_secs or absolute_timeout_secs, and each refresh counts as activity that extends the idle window. A refresh token issued with offline_access outlives the session’s timeouts and lasts its own refresh_token_ttl_secs, though an explicit sign-out still revokes it. Clients that need long-lived access without a browser, device-flow clients among them, should request offline_access. See Tokens.

auth

First-factor methods shown on the login page.

KeyDefaultMeaning
passwordtrueUsername or email and password
magic_linkfalseA sign-in link by email
email_otpfalseA one-time code by email
sms_otpfalseA one-time code by text message (needs an SMS gateway, see Email, SMS and templates)
passkeyfalsePasswordless sign-in with a passkey; also enables passkeys as a second factor

registration

KeyDefaultMeaning
enabledfalseShow “create an account” and accept self-registration
require_email_verificationtrueNew accounts stay pending until the emailed link is followed
require_termsfalseRegistrants must accept the terms
terms_urlnullTerms of service link, shown to registrants and on the terms step
privacy_urlnullPrivacy policy link
allowed_email_domains[]Only these email domains may register (empty: any)

Whether registration demands a CAPTCHA is captcha.on_registration (see Rate limits, IP rules and CAPTCHA); in the console it sits under Settings → Passwords & lockout. The earlier registration.captcha key is gone: a migration carried any true value over to captcha.on_registration, a PATCH naming it is refused, and a tenant import ignores it.

The fields a registrant fills in beyond username, email and password come from the tenant’s profile schema (see Users).

lockout

KeyDefaultMeaning
max_failures10Consecutive failed sign-ins before the account is locked temporarily (0 off)
lock_minutes15How long that lock lasts
ip_max_failures100Failures from one address within the window before it is throttled (0 off)
ip_window_minutes15That window

An administrator clears a lock with POST /admin/tenants/{slug}/users/{user}/unlock or the Unlock button on the user page.

notifications

Security notices sent to the user by email, or by text message when they have no email address. All default to true.

KeySent when
new_deviceSomeone signs in from a device not seen before
password_changedThe password changes
mfa_changedA second factor is added or removed
email_changedThe email address changes (sent to the previous address)

account

What users may do to their own account in the account console. These keys are not on the console’s Settings page; change them with PATCH /admin/tenants/{slug} or a tenant import.

KeyDefaultMeaning
self_deletiontrueUsers may delete their own account
deletion_retention_days30Days a deleted account (by the user or an administrator) is kept before the daily purge removes it
personal_tokenstrueUsers may mint personal access tokens
personal_token_max_days365Longest, and default, personal access token lifetime (0 no limit)

locale

KeyDefaultMeaning
default"en"Language used when neither the request nor the user names a supported one
supported["en"]BCP 47 tags the login pages and messages may use

Tags are normalised on save (pt_BR becomes pt-BR); a malformed tag, or a default outside supported, is refused. An empty supported list becomes [default]. The language of a page or message is negotiated from the OIDC ui_locales parameter, then the user’s stored locale, then default; a tag matches exactly or by language (de-CH matches a supported de). Right-to-left languages (Arabic, Hebrew, Persian, Urdu and others) set dir="rtl". The flow state carries the result as locale, dir (ltr or rtl) and the selectable locales; every email or SMS the flow sends is rendered in that locale, and a self-registered user is stored with it.

The bundled UI ships English strings only (ui/src/i18n/en.json). An added bundle falls back key by key (de-CH, then de, then English), so a partial translation still renders; plurals follow Intl.PluralRules, and the stylesheets use logical properties so right-to-left languages mirror the layout. Message templates can be overridden per locale; see Email, SMS and templates.

branding

How the tenant looks: the sign-in pages, the account console, and the admin console while it administers this tenant. Settings → Appearance in the console edits it with a live preview.

KeyDefaultMeaning
logo_urlnullLogo on the login pages
favicon_urlnullBrowser tab icon
primary_colornullThe theme’s primary colour: buttons, navigation, links, focus. A hex colour (#rrggbb) drives a full set of light and dark shades; any other CSS colour literal sets the plain accent only
contrast_colornullThe theme’s second colour, for what must read as different from the primary: selected rows, the tenant mark. Hex; derived from the primary when unset
background_colornullPage background behind the card (a CSS colour literal)
skin"signal-v2"The visual skin: signal-v2 (default), signal, vault or registry
mode"system"Light/dark policy: system follows each person’s device, light and dark force one
allow_theme_switchtrueShow a light/dark switch so people can override mode on their device
support_urlnull“Need help?” link
headlinenullOne line for the sign-in page’s brand panel (at most 120 characters). With the Signal v2 skin the page splits into the panel and the form once this is set; the other skins keep the single card
taglinenullA sentence under the headline (at most 240 characters)
custom_cssnullStylesheet applied to the login pages after the theme
links[]Extra footer links, each {"label": "…", "url": "…"}

Colours are checked on write: a value that is not a CSS colour literal is refused, and contrast_color must be hex. The console shows the contrast ratios the two colours achieve on the page and on buttons in both modes, and flags anything under 4.5:1, but it does not refuse a brand colour over it.

Branding, the display name, locales and the offered sign-in methods are published at GET /t/{slug}/branding, a public, cacheable document every end-user page loads. The console’s appearance editor (Settings → Appearance) frames the real login page in preview mode and applies each draft change as you type. Images are loaded from their URLs over https; the pages’ Content-Security-Policy allows images and fonts from any https origin, so host them anywhere.

keys

KeyDefaultMeaning
default_alg"RS256"Algorithm for keys made by rotation: RS256, RS384, RS512, ES256, EdDSA
rsa_bits"B2048"RSA key size: B2048, B3072, B4096
rotation_interval_days90Rotate the active key automatically after this many days (0 only on demand)
retire_overlap_hours24How long a retired key stays published so tokens it signed still verify

See Rotating keys.

discovery

KeyDefaultMeaning
email_domains[]WebFinger acct:user@<domain> lookups for these domains resolve to this tenant’s issuer

audit

KeyDefaultMeaning
retention_days365Days to keep audit rows (0 forever)

See Webhooks and the audit log.

features

A map of flag name to flag, default {}: each with enabled, an optional description and optional per-organization values. rIDM itself doesn’t act on them; applications read them through the features scope or GET /t/{slug}/features. Edit them on the console’s Feature flags page. See Feature flags.

{ "features": { "beta_dashboard": { "enabled": true, "organizations": { "globex": false } } } }

custom_domain

A hostname such as login.acme.example (optionally with a port), lower-cased on save. It must not be the deployment’s own host and must not be used by another tenant. Default null, meaning the issuer is {PUBLIC_URL}/t/{slug}. See Custom domains.

Settings kept outside the document

Some tenant configuration holds secrets and so lives behind its own endpoints, stored encrypted and never returned:

ConfigurationEndpointsPage
CAPTCHA provider (site key, secret)GET/PUT/DELETE /admin/tenants/{slug}/captchaRate limits, IP rules and CAPTCHA
Email and SMS providers, templates/admin/tenants/{slug}/messaging/…Email, SMS and templates
User profile schemaGET/PUT /admin/tenants/{slug}/profile-schemaUsers
IP allow and deny rules/admin/tenants/{slug}/ip-rulesRate limits, IP rules and CAPTCHA

Limits

Some configuration is read whole (by the console, the tenant document and token issuance), so each tenant has a ceiling on it. Creating one more past the ceiling is a 400:

CollectionPer tenant
Groups10,000
Roles5,000
Scopes1,000
Resource servers1,000
IP rules1,000
Webhooks100
SCIM tokens (live)100
Trusted certificate authorities (mutual TLS)50
Feature flags200

Users, clients, organizations and the audit log have no ceiling: their lists are paginated.

Users, invitations and bulk import

Users belong to one tenant. This page covers managing them from the admin console, the ridm CLI and the admin API: creating, editing, disabling and deleting accounts, their credentials and sessions, invitations, self-registration, the profile schema, and bulk import and export. For how users relate to groups and roles, see Users, groups and roles.

All routes below are under /admin/tenants/{slug}/users and need ridm:users:read to read and ridm:users:write to change, unless noted. {user} is the user’s id.

Finding users

Console: Users (/console/users/) is a table with prefix search on username and email, a status filter and, on request, deleted users; further pages load as you scroll.

GET /admin/tenants/acme/users?search=ali&status=active&limit=50
ParameterMeaning
searchCase-insensitive prefix of the username or email
statusactive, disabled, locked, pending or deleted
password_algoOnly users whose password hash is in this format: argon2id, pbkdf2-sha512, pbkdf2-sha256, bcrypt, sha256, sha512, md5, … (who still has an imported hash, or hasn’t been re-hashed after a move to the FIPS build)
include_deletedInclude soft-deleted users (default false)
cursor, limitPaging: limit defaults to 50, at most 500; pass next_cursor back as cursor

GET …/users/{user} returns the user with a password summary (set, algorithm, changed and expiry dates, must-change flag), direct and effective roles, and groups.

Creating a user

WhereHow
ConsoleUsers → New user: a temporary password revealed once, a chosen password, or none
CLIridm --tenant acme user create alice --email alice@example.com --temporary-password
APIPOST /admin/tenants/{slug}/users
POST /admin/tenants/acme/users
Content-Type: application/json

{
  "username": "alice",
  "email": "alice@example.com",
  "email_verified": true,
  "locale": "en",
  "attributes": { "department": "finance" },
  "temporary_password": true
}
FieldDefaultNotes
usernamerequiredTrimmed and lower-cased; 1–255 characters, no whitespace; unique in the tenant
emailnullLower-cased; unique in the tenant
email_verifiedfalse
phonenullE.164, such as +14155550100
phone_verifiedfalse
statusactiveactive, disabled or pending; locked and deleted are refused with 400
attributes{}Profile attributes, validated against the profile schema
localenullThe user’s language, used when the request names none
external_idnullThe identifier in the system the user came from (SCIM externalId); unique in the tenant
password—An initial password, checked against the tenant’s password policy
temporary_passwordfalseGenerate a password, return it once as temporary_password, and force a change at first sign-in

Send either password or temporary_password, not both. With neither, the account has no password until the user sets one through recovery or signs in another way the tenant offers.

To bring someone in without handling a password at all, send an invitation instead.

Editing, disabling, unlocking and deleting

PATCH …/users/{user} is a partial update: absent fields are unchanged, null clears an optional field, and unknown fields are refused. It accepts username, email, email_verified, phone, phone_verified, status, attributes, locale, must_change_password and external_id. attributes replaces the whole attribute object, subject to the profile schema. In the console, the user page’s Profile tab saves as you go.

TaskConsole (user page header)API
DisableDisablePATCH …/users/{user} with {"status": "disabled"}; every session ends at once, with back-channel logout
Re-enableEnablePATCH with {"status": "active"}
Clear a lockoutUnlockPOST …/users/{user}/unlock: resets the failure counter and the locked status
DeleteDeleteDELETE …/users/{user}

status can only be set to active or disabled: locked is set by the lockout policy and deleted by deletion.

Disabling or deleting a user by any path (the admin API, SCIM, a user deleting their own account) signs them out everywhere at once and sends back-channel logout to the clients of each session. A session of a disabled or deleted user that somehow survives gets no further authorization codes.

Deletion is a soft delete. Sessions and trusted devices end at once, the username and email are freed, and the record is purged by a daily job after settings.account.deletion_retention_days (default 30). Users may also delete their own account from the account console, unless settings.account.self_deletion is off, but an administrator must be removed by another administrator.

Passwords and credentials

The console’s “Password & credentials” tab shows the password summary, the enrolled factors, linked upstream identities and personal access tokens.

Set or reset a password: PUT …/users/{user}/password, or ridm user reset alice.

{ "password": "a-new-long-passphrase", "must_change": true, "notify": true, "revoke_sessions": true }
FieldDefaultMeaning
passwordabsentAbsent: generate a temporary password, returned once as temporary_password
must_changefalseForce a change at the next sign-in (always on for a temporary password)
skip_policyfalseAccept a password the policy and history would refuse
notifyfalseTell the user their password changed
revoke_sessionsfalseEnd every session, with back-channel logout, so the new password is needed everywhere

Note that the CLI flips the must_change default: ridm user reset forces a change unless --no-must-change is given.

Force a change without setting a password: POST …/users/{user}/force-password-change.

Second factors: GET …/users/{user}/credentials lists the password summary and factor rows (type, label, timestamps, never the secret material); DELETE …/credentials/{credential_id} removes one; removing the last second factor removes the recovery codes with it. See MFA policy.

Linked identities: GET …/users/{user}/identities and DELETE …/identities/{idp_id} list and unlink upstream accounts (see Identity brokering).

Personal access tokens: GET …/users/{user}/pats and DELETE …/pats/{token_id} list and revoke the user’s tokens (see Administrator access).

Sessions, devices and consents

The “Sessions & devices” and “Consents” tabs of the user page, or:

RouteDoes
GET …/users/{user}/sessionsLive SSO sessions
DELETE …/users/{user}/sessionsEnd every session (answers {"revoked": n})
DELETE …/users/{user}/sessions/{session_id}End one
GET …/users/{user}/devicesTrusted devices (“remember this device”)
DELETE …/users/{user}/devices[/{device_id}]Revoke all, or one: the second factor is asked again
GET …/users/{user}/consentsScopes the user granted, per client
DELETE …/users/{user}/consents/{client_id}Withdraw consent; the next sign-in to that client asks again

Ending a session revokes the refresh tokens issued in it, offline_access ones included, sends back-channel logout to the clients that took part in it, and makes any authorization code issued in it unredeemable. Admin tokens issued in a browser session stop working with it.

Roles and groups

The Roles and Groups tabs show direct and effective memberships, with assign and remove. Through the API: GET …/users/{user}/roles, PUT or DELETE …/roles/{role_id}, and likewise …/groups/{group_id}. Granting a role or group that carries admin permissions the caller does not hold is refused (see Administrator access).

Invitations

An invitation emails a link; the invitee opens it on /invite/, sets a password (when the tenant offers password sign-in) and optionally a username (the email address otherwise), and gets an account whose email is already verified, with the roles and groups the invitation named. Roles and groups are granted on acceptance.

WhereHow
ConsoleUsers → Invite; open invitations under Users → ?view=invitations, with resend and revoke
APIPOST /admin/tenants/{slug}/invitations (ridm:invitations:write)
{ "email": "bob@example.com", "roles": ["<role id>"], "groups": ["<group id>"], "expires_days": 14 }
  • expires_days defaults to 7 and is clamped to 1–90.
  • An email that already belongs to a user is refused with 409.
  • The token only travels in the email. POST …/invitations/{id}/resend issues a new token and expiry and emails it again; the previous link stops working. DELETE …/invitations/{id} revokes it.
  • GET …/invitations?open_only=true lists the ones not yet accepted.

Invitations need an email provider (see Email, SMS and templates). They work whether or not self-registration is enabled.

Self-registration

Self-registration is off by default. Turn it on under Settings → Sign-in, or:

{ "settings": { "registration": { "enabled": true, "require_email_verification": true, "allowed_email_domains": ["acme.example"] } } }

With require_email_verification (the default), a new account is pending until the emailed link is followed. The registration form asks for the attributes the profile schema declares as editable by the user. All registration settings are listed in Tenants and tenant settings.

Profile schema

The profile schema declares the custom attributes users carry in attributes: their type, validation, who may edit them and which tokens they appear in. Edit it under Settings → Profile attributes, or with GET and PUT /admin/tenants/{slug}/profile-schema (ridm:tenants:read / write). PUT replaces the whole schema; existing values are not rewritten.

{
  "allow_undeclared": false,
  "attributes": [
    {
      "name": "department",
      "type": "enum",
      "label": "Department",
      "required": true,
      "editable_by": "admin",
      "visible_in": ["id_token", "userinfo"],
      "validation": { "values": ["finance", "engineering", "sales"] },
      "order": 1
    }
  ]
}
FieldDefaultMeaning
namerequired^[a-zA-Z][a-zA-Z0-9_]{0,63}$, unique
typestringstring, number, boolean, email, url (http or https), phone, date (YYYY-MM-DD), enum, json
label, descriptionnullShown in forms
requiredfalseMust be present and non-empty
multivaluedfalseAn array of values of the type
editable_byuseruser (the user and administrators), admin (administrators and the admin API only), none (only bulk import and SCIM provisioning; the admin API’s create and PATCH cannot set it)
visible_in[]Which of id_token, userinfo, access_token carry the attribute, under its own name
validation{}min_length, max_length, pattern (an anchored regular expression), min, max, values (for enum)
order0Position in forms

At most 200 attributes. An enum needs at least one value, and a required attribute cannot be editable_by: none.

An attribute listed in visible_in appears in those tokens as a claim named after the attribute, when the user has a value for it. It never overwrites a claim a granted scope released or a protected claim (sub, iss, aud and the like), but a claim mapper may override it. See Token claims. With allow_undeclared (default false), attributes the schema does not declare are stored as given, writable by administrators but not by users.

Bulk import

POST /admin/tenants/{slug}/users/import creates users in bulk. It needs both ridm:users:write and ridm:invitations:write. In the console: Users → Import, paste or pick a file, run the dry run, then import.

  • Content-Type: application/json or text/csv; anything else is refused.
  • At most 10 000 rows and 32 MiB per request.
  • ?dry_run=true validates every row, and checks usernames and emails against existing users, without writing anything.
  • A username or email that an earlier row of the same import already uses fails the later row (“already used by row N”), with or without dry_run.
  • Rows are independent: a bad row is reported and skipped, good rows are created (a few at a time; the report keeps the rows’ order).
  • A row that grants a role or group carrying admin permissions the importing administrator does not hold fails with “cannot grant permissions you do not hold”, in a dry run too. The same rule protects role assignment in the admin API.
  • Imports may set attributes marked editable_by: none.

The response reports every failure by its 1-based row number:

{ "dry_run": false, "total": 3, "created": 2, "failed": 1,
  "errors": [ { "row": 2, "username": "bob", "error": "unknown role `billing`" } ] }

JSON

An array of users, or an object {"users": [...]}. Unknown fields are refused.

[
  {
    "username": "alice",
    "email": "alice@example.com",
    "email_verified": true,
    "attributes": { "department": "finance" },
    "roles": ["accountant"],
    "groups": ["finance"],
    "password_hash": "$2b$12$KIXQJ2zLx0R3pNN4mQJb6eBu3b9a3m1FJqPq3r2t9t8kJ0bK1dQ6a"
  },
  { "username": "bob", "email": "bob@example.com", "password": "correct horse battery staple" }
]
FieldDefaultMeaning
usernamerequiredAs for a created user
email, email_verified, phone, phone_verified, locale, attributesas for a created user
statusactiveactive, disabled or pending
roles[]Tenant-wide role names (not client roles)
groups[]Group names
password—Plaintext, checked against the password policy
password_hash—A hash from another system, in a supported format
must_change_passwordfalseForce a change at first sign-in

Send password or password_hash, not both; with neither, the user has no password.

CSV

A header row, then one user per row. Known columns: username (required), email, email_verified, phone, phone_verified, locale, status, roles, groups, password, password_hash, must_change_password. Any other column must be named attr.<name> and becomes the profile attribute <name>; any other header is refused.

  • Booleans are true for 1, true, yes or y (any case), false otherwise.
  • roles and groups are lists separated by ; or |.
  • Empty cells mean “not given”.
  • An attr. cell that parses as JSON (a number, true, an array, an object, a quoted string) is stored as that value; anything else is stored as a string.
username,email,email_verified,roles,groups,password_hash,attr.department,attr.employee_no
alice,alice@example.com,true,accountant;auditor,finance,$2b$12$KIXQJ2zLx0R3pNN4mQJb6eBu3b9a3m1FJqPq3r2t9t8kJ0bK1dQ6a,finance,1042
bob,bob@example.com,yes,,,,sales,1043
curl -s -X POST "https://id.example.com/admin/tenants/acme/users/import?dry_run=true" \
  -H "Authorization: Bearer $RIDM_TOKEN" -H "Content-Type: text/csv" \
  --data-binary @users.csv

Legacy password hashes

An imported password_hash is stored as it is and verified in its own format at the user’s first sign-in, then replaced with a fresh argon2id hash. The accepted formats, recognised by prefix:

FormatExampleNotes
argon2 (PHC)$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>$argon2i$ and $argon2d$ too; rehashed when weaker than the server’s parameters
bcrypt$2b$12$<22-char salt><31-char hash>$2a$ and $2y$ too
PBKDF2 (passlib PHC)$pbkdf2-sha256$29000$<salt b64>$<hash b64>$pbkdf2-sha512$ too; passlib’s .-for-+ base64 is accepted
PBKDF2 (Django)pbkdf2_sha256$600000$<salt>$<hash b64>pbkdf2_sha512$ too; the salt is used as raw text
Salted SHA-2$sha256$<salt>$<hex of sha256(salt ‖ password)>$sha512$ too
Unsalted SHA-2$sha256$<hex of sha256(password)>$sha512$ too
MD5$md5$<hex> or $md5$<salt>$<hex of md5(salt ‖ password)>For migration only

PBKDF2 iteration counts above 10 000 000 are refused. A hash in any other format fails its row with “unsupported hash format”. Systems that store SHA or MD5 digests in another layout (hash then salt, base64 instead of hex) need their values rewritten into the layout above before import. See Migrating to rIDM.

Export

GET /admin/tenants/{slug}/users/export?format=json (the default) or ?format=csv streams every live user, page by page, without credentials. In the console: Users → Export.

JSON is an array of objects; CSV has the header:

id,username,email,email_verified,phone,phone_verified,status,locale,attributes,must_change_password,last_login_at,created_at

In CSV, attributes is one column holding the JSON object, and timestamps are RFC 3339. The export’s columns differ from the import’s (no roles, groups or password), so a round trip between tenants needs the file reshaped.

Registering clients

A client is an application that asks rIDM for tokens. This page covers registering and maintaining clients: every field, the type-driven defaults, secrets and their rotation, private_key_jwt, and dynamic client registration. For the concepts, see Clients.

Client routes are under /admin/tenants/{slug}/clients and need ridm:clients:read or ridm:clients:write. {client} is either the internal id or the public client_id.

Where to register one

WhereHow
ConsoleClients (/console/clients/) → New client: a four-step wizard (kind of client; grants and authentication; URIs; scopes and audiences) with the type’s defaults preselected. The result shows the client ID and any secret once
CLIridm --tenant acme client create --name "Acme SPA" --type spa --redirect-uri https://app.acme.example/callback
APIPOST /admin/tenants/{slug}/clients
Configuration as codeA clients entry in the tenant document
The client itselfDynamic client registration, when the tenant allows it

The console’s client detail page (?client=<id>) saves every field as you go, and the playground (/console/playground/) runs the client’s flow for real and shows the tokens it gets back. Its sign-in opens in a popup (allow pop-ups for the console); the console tab keeps the run in memory, a pasted client secret and the tokens included, and writes none of it to browser storage.

Client types and their defaults

client_type picks the defaults for everything not given. Anything given explicitly wins.

TypeForAuth methodGrantsPKCEScopes
spaBrowser appsnone (public)authorization_code, refresh_tokenrequiredstandard scopes
web (default)Server-side web appsclient_secret_basicauthorization_code, refresh_tokenrequiredstandard scopes
nativeMobile and desktop appsnone (public)authorization_code, refresh_tokenrequiredstandard scopes
machineService-to-serviceclient_secret_basicclient_credentialsnot requirednone
deviceTVs, CLIs, kiosksnone (public)device code, refresh_tokenrequiredstandard scopes

The standard scopes are openid, profile, email, phone, address and offline_access. PKCE (S256) is always required of a public client, whatever require_pkce says.

Fields

POST /admin/tenants/acme/clients
Content-Type: application/json

{
  "name": "Acme Portal",
  "client_type": "web",
  "redirect_uris": ["https://portal.acme.example/auth/callback"],
  "post_logout_redirect_uris": ["https://portal.acme.example/"],
  "allowed_scopes": ["openid", "profile", "email", "offline_access"],
  "allowed_audiences": ["https://api.acme.example"],
  "backchannel_logout_uri": "https://portal.acme.example/auth/backchannel-logout",
  "require_consent": false
}

Identity and display

FieldDefaultNotes
client_idgenerated1–128 characters of A-Z a-z 0-9 . _ : -, starting alphanumeric; cannot change later
namerequired1–255 characters; shown on the consent screen
client_typewebSee the table above
descriptionnullFor administrators
logo_uri, client_uri, tos_uri, policy_urinullShown on the consent screen and in the account console’s application list
statusactivePATCH only: disabled stops the client from getting tokens

Authentication

FieldDefaultNotes
token_endpoint_auth_methodby typenone, client_secret_basic, client_secret_post, private_key_jwt, tls_client_auth, self_signed_tls_client_auth. The client must authenticate with exactly the method registered
tls_client_auth_subject_dn, tls_client_auth_san_dns, tls_client_auth_san_uri, tls_client_auth_san_ip, tls_client_auth_san_emailnullFor tls_client_auth, exactly one: the name its certificate must carry. See Mutual TLS
jwksnullInline JWK set ({"keys": [...]}) for private_key_jwt, signed request objects and ID token encryption
jwks_urinullThe same, fetched over HTTPS

client_credentials needs client authentication, so it cannot be combined with none. private_key_jwt needs jwks or jwks_uri; self_signed_tls_client_auth needs its certificate in jwks (a key’s x5c) or a jwks_uri.

Redirects, logout and CORS

FieldDefaultNotes
redirect_uris[]At least one when authorization_code is allowed. Matched as exact strings, except that a native client’s http loopback redirect may use any port (RFC 8252 §7.3)
post_logout_redirect_uris[]Where RP-initiated logout may return to
cors_origins[]Browser origins (scheme://host[:port], no path) allowed to call the token, userinfo and other endpoints from JavaScript
backchannel_logout_urinullReceives a signed logout token when a session the client took part in ends
frontchannel_logout_urinullLoaded in an iframe on the logout page when a session ends. Browsers that block third-party cookies (Safari, Firefox, private windows in every browser, Android WebView) do not send the application’s own session cookie to that iframe, so it cannot end the application’s session there: use backchannel_logout_uri wherever the application can receive it
initiate_login_urinullWhere a third party may start a login at the client

Redirect and post-logout URIs must be https, http only on a loopback host (localhost, 127.0.0.1, [::1]), or, for native clients only, a custom scheme (RFC 8252). Fragments are refused.

Grants, scopes and audiences

FieldDefaultNotes
allowed_grantsby typeAny of authorization_code, refresh_token, client_credentials, urn:ietf:params:oauth:grant-type:device_code, urn:ietf:params:oauth:grant-type:token-exchange, urn:openid:params:grant-type:ciba (confidential clients only; see Backchannel sign-in). An empty list means no grant at all
backchannel_token_delivery_modepoll with the CIBA grantpoll or ping; only with the CIBA grant
backchannel_client_notification_endpointnullWhere a ping client is told; required in ping mode
allowed_scopesby typeScopes the client may ask for; each must exist in the tenant
allowed_audiences[]Resource servers (by identifier) the client may get access tokens for; each must exist. Empty means any resource server except the built-in ones. urn:ridm:admin makes admin API tokens possible (see Administrator access)

A scope or audience that does not exist is refused at registration, rather than surfacing later as invalid_scope or invalid_target at the token endpoint.

A token request without a resource parameter gets the client’s allowed_audiences as its audience; a client with an empty list then gets a token whose audience is the client itself. A request that names resource may only name audiences the client is allowed. The built-in resource servers (the admin and account APIs) are never implied by an empty list: a client must list them explicitly. A scope bound to a resource server adds that server’s audience when it is requested, and is refused with invalid_scope if the client may not target it. See Resource servers, scopes and permissions.

FieldDefaultNotes
require_consenttrueShow the consent screen. Set false for first-party applications
require_pkceby typeOnly meaningful for confidential clients; public clients always need PKCE

Token lifetimes and format

FieldDefaultNotes
access_token_ttl_secsnullOverrides the tenant’s session.access_token_ttl_secs (default 300)
refresh_token_ttl_secsnullOverrides session.refresh_token_ttl_secs (default 30 days)
id_token_ttl_secsnullOverrides session.id_token_ttl_secs (default 300)
access_token_formatjwtjwt, or opaque: see Opaque access tokens. The built-in console clients refuse opaque
subject_typepublicpairwise gives each client (or sector) its own sub for a user
sector_identifier_urinullGroups clients that should share pairwise subjects
id_token_scope_claimsfalseAlso put the claims released by the granted scopes (each scope’s claims list, profile, email, address and phone included) in the ID token; by default they are only at /userinfo (OIDC Core §5.4)
id_token_encryptionnull{"alg": "RSA-OAEP-256" | "RSA-OAEP", "enc": "A256GCM" | "A128GCM"}; needs the client’s jwks or jwks_uri

A resource server may cap the lifetime of access tokens issued for it, choose the algorithm they are signed with, and refuse offline_access; see Resource servers, scopes and permissions. What the tokens contain is in Token claims.

Opaque access tokens

With access_token_format: "opaque" the client receives access tokens of the form at_<random> instead of JWTs. The claims a JWT would have carried are kept in Valkey until the token expires; the token itself carries nothing.

  • A resource server learns what the token stands for from POST /t/{slug}/introspect. It cannot validate the token locally, so ridm-auth, which validates JWTs only, does not accept opaque tokens.
  • rIDM’s own endpoints accept them: /userinfo, /introspect, /revoke, token exchange (as urn:ietf:params:oauth:token-type:access_token), and the account and admin APIs.
  • Losing Valkey’s data ends every opaque token early; the client refreshes or signs in again. See Postgres and Valkey.
  • Refresh tokens and ID tokens are unchanged.

Choose opaque tokens when the audience must not be able to read the token’s contents, or when you want every use checked centrally (revocation takes effect at the next introspection), and accept the introspection round trip that costs.

PAR, JAR, DPoP, mTLS and FAPI 2.0

FieldDefaultNotes
dpop_bound_access_tokensfalse (true under FAPI 2.0 unless certificate-bound)Every token request must carry a DPoP proof, and the tokens are bound to its key
tls_client_certificate_bound_access_tokensfalseEvery token request must come over mutual TLS, and the access tokens are bound to the client certificate (RFC 8705 §3). See Mutual TLS
require_pushed_authorization_requestsfalse/authorize refuses the client’s requests unless they came through PAR (RFC 9126 §6)
security_profilenonefapi2 holds the client to the FAPI 2.0 Security Profile: see Backchannel sign-in and FAPI 2.0

Pushed authorization requests (POST /t/{slug}/par) and JWT-secured authorization requests (a request parameter signed with one of the client’s jwks keys) are available to every client; require_pushed_authorization_requests (or the FAPI 2.0 profile) makes PAR the only way in. Any client may also present DPoP proofs voluntarily; dpop_bound_access_tokens makes them compulsory. See Tokens.

Changing a client

PATCH /admin/tenants/{slug}/clients/{client} is a merge patch over the client’s metadata: send only what changes. null clears an optional field or resets a defaulted one to its type’s default. client_id cannot change. Switching to a secret-based auth method mints a secret and returns it once in client_secret.

DELETE …/clients/{client} removes the client with its client-scoped roles, refresh tokens and consents.

The built-in ridm-admin-console and ridm-account-console clients cannot be deleted and are left out of tenant exports. Their redirect URIs follow UI_URL and are restored at start-up; other fields, such as token lifetimes and CORS origins, may be changed.

Secrets

A confidential client using client_secret_basic or client_secret_post has a secret (cs_…, 256 random bits) generated at creation. It is returned once, in the 201 response’s client_secret, and stored only as a SHA-256 hash. GET shows each secret’s id, creation time and expiry, never the value.

Rotate with POST …/clients/{client}/secrets:

{ "grace_secs": 86400 }

The response carries the new secret, once. The previous secret keeps working for grace_secs (default 86 400, one day; 0 retires it at once; at most 30 days), so deployments can move to the new one without an outage. Any older secret is dropped. In the console: client detail → Secrets → Rotate, then revoke the retiring secret once everything has moved.

Revoke one secret early with DELETE …/clients/{client}/secrets/{secret_id}. The last secret of a confidential client cannot be revoked; rotate instead.

private_key_jwt

With token_endpoint_auth_method: "private_key_jwt", the client proves itself with a JWT signed by its own private key (RFC 7523) instead of a shared secret. Register the public half as jwks or jwks_uri:

{
  "name": "Acme Billing",
  "client_type": "machine",
  "token_endpoint_auth_method": "private_key_jwt",
  "jwks_uri": "https://billing.acme.example/.well-known/jwks.json",
  "allowed_audiences": ["https://api.acme.example"]
}

At the token endpoint, send client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion, a JWT that:

  • is signed with an asymmetric algorithm, by a key in the registered set;
  • has iss and sub equal to the client_id;
  • has aud equal to the token endpoint URL or the tenant’s issuer;
  • carries exp and a jti, lives no more than 600 seconds, and is not replayed (each jti is accepted once).

A jwks_uri is fetched over HTTPS and cached for an hour. An unknown kid triggers one re-fetch, at most once a minute per client, so a rotated client key is picked up without an administrator.

Service accounts

A client with the client_credentials grant can have a service account: a user named svc-<client_id> that its tokens are issued for, so it can hold roles and groups like anyone else. PUT …/clients/{client}/service-account creates it (or returns the existing one), DELETE removes it. See Administrator access for giving a service account admin permissions, and Machine-to-machine access.

Dynamic client registration

Applications can register themselves at POST /t/{slug}/register (RFC 7591) and manage their registration at GET, PUT and DELETE /t/{slug}/register/{client_id} (RFC 7592) with the registration access token they received. The tenant’s settings.dcr decides whether that is allowed. In the console: Settings → Keys, discovery & audit.

KeyDefaultMeaning
modedisableddisabled: registration answers 403. open: anyone may register, within the tenant’s rate limits. initial_access_token: registration needs a bearer initial access token
allowed_grants[]Grant types a registered client may ask for; empty allows every supported grant
require_pkcetrueWhether registered confidential clients must use PKCE (public clients always must). The OpenID Connect basic profile registers confidential clients without PKCE, so conformance testing needs it off
{ "settings": { "dcr": { "mode": "open", "allowed_grants": ["authorization_code", "refresh_token"] } } }

How registered metadata maps onto a client:

  • token_endpoint_auth_method defaults to client_secret_basic; none, client_secret_post and private_key_jwt are accepted.
  • grant_types defaults to ["authorization_code"]; only the code response type is supported.
  • The type is native for application_type: "native", else derived from the grants and auth method (device, machine, spa or web).
  • scope sets the allowed scopes; audiences, CORS origins and token lifetimes cannot be registered.
  • Every registered client gets require_consent: true.
  • require_pushed_authorization_requests is stored and enforced.
  • backchannel_token_delivery_mode (poll or ping) and backchannel_client_notification_endpoint register a CIBA client; signed backchannel requests and user codes are refused. security_profile cannot be registered: an administrator sets it.

The response carries the client_id, any client_secret (with client_secret_expires_at: 0), and a registration_access_token for the management endpoints. An administrator can issue a new registration access token for any client with POST /admin/tenants/{slug}/clients/{client}/registration-token (or on the console’s client detail page), which replaces the previous one; it is shown once.

Registrations are recorded in the audit log with the system as actor, and are ordinary clients afterwards: administrators see, edit and delete them like any other.

Initial access tokens

With dcr.mode set to initial_access_token, POST /t/{slug}/register demands a bearer token issued by an administrator (RFC 7591 §1.2). A missing token is answered 401 with invalid_token; an unknown, revoked, expired or used-up one the same way. Each successful registration spends one use; the check and the count are one statement, so two registrations racing for a token’s last use cannot both succeed.

POST /t/acme/register
Authorization: Bearer iat_Vt4cN9…
Content-Type: application/json

{ "client_name": "Build agent", "redirect_uris": ["https://ci.acme.example/cb"] }

Tokens are managed under the client permissions, since a token lets its holder create clients:

RoutePermissionDoes
GET /admin/tenants/{slug}/dcr/initial-access-tokensridm:clients:readList the tenant’s tokens, newest first, never their values
POST /admin/tenants/{slug}/dcr/initial-access-tokensridm:clients:writeIssue a token; answers 201 with the record and token, shown this once
DELETE /admin/tenants/{slug}/dcr/initial-access-tokens/{id}ridm:clients:writeRevoke it (204)

The request body is optional in every field:

FieldDefaultRules
descriptionnullWhat the token is for; at most 200 characters
expires_in_secsno expiry1 second to ten years
max_usesno limitRegistrations it allows in all; at least 1
curl -X POST https://id.example.com/admin/tenants/acme/dcr/initial-access-tokens \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"description": "CI agents", "expires_in_secs": 86400, "max_uses": 5}'

A listed token shows id, description, max_uses, uses, expires_at, last_used_at, revoked_at and created_at. Tokens are stored in Postgres as SHA-256 hashes; the value (iat_…) exists only in the issuing response. Issuing and revoking raise dcr_token.created and dcr_token.revoked (see Webhooks and the audit log).

The same is available as ridm client iat create | list | revoke (see The ridm command line) and in the console under Settings → Keys, discovery & audit, where an Initial access tokens block appears while the mode is initial_access_token: issue a token with a description, lifetime and use limit, see each token’s state (active, expired, used up, revoked), and revoke it.

Administrator access

Administration in rIDM is not a separate account system. An administrator is an ordinary user of some tenant whose roles grant permissions on a built-in resource server, urn:ridm:admin, and the admin API accepts an access token issued for that audience. This page covers the permissions, the built-in roles, how far a grant reaches, how an organization’s own administrator fits in, and the tokens automation uses.

Permissions

Admin permissions are named ridm:<resource>:<action>. Every tenant carries the same catalogue, seeded on the urn:ridm:admin resource server when the tenant is created; it cannot be extended or trimmed. GET /admin/permissions returns the catalogue and the built-in roles.

PermissionAllows
ridm:tenants:readView tenant settings, branding, feature flags, IP rules, CAPTCHA configuration, profile schema, dashboard statistics
ridm:tenants:writeChange tenant settings, branding, feature flags, IP rules, CAPTCHA configuration, profile schema
ridm:tenants:createCreate tenants (global administrators only)
ridm:tenants:deleteDelete tenants (global administrators only)
ridm:tenants:exportExport tenant configuration
ridm:tenants:importImport tenant configuration
ridm:users:readView users, their sessions, credentials, devices, tokens, consents, linked identities
ridm:users:writeCreate, change, disable and delete users; manage their sessions, credentials, devices, tokens, consents, roles and groups
ridm:users:impersonateSign in as a user where the tenant allows it (impersonation)
ridm:invitations:readView invitations
ridm:invitations:writeCreate, resend and revoke invitations; bulk import users
ridm:groups:read / writeView groups and members / create, change, delete groups and manage membership
ridm:roles:read / writeView roles, composites and assignments / create, change, delete roles and composites
ridm:clients:read / writeView OAuth clients / create, change, delete them; generate and rotate secrets
ridm:scopes:read / writeView / manage scopes
ridm:mappers:read / writeView / manage claim mappers
ridm:resource-servers:read / writeView / manage resource servers and their permissions; grant permissions to roles
ridm:idps:read / writeView / manage identity providers
ridm:keys:readView signing keys and master-key rotation status
ridm:keys:writeCreate, rotate, activate, retire and revoke signing keys; re-encrypt under the master key
ridm:messaging:read / writeView / change messaging settings and templates; send test messages
ridm:audit:readView and export the audit log
ridm:webhooks:read / writeView / manage webhooks; redeliver events
ridm:scim:read / writeView / create and revoke SCIM provisioning tokens

The permission each admin route needs is listed in the admin API reference.

A custom role may also be granted a wildcard: a granted name ending in :* covers every permission that shares the preceding segments, so ridm:users:* covers ridm:users:read and ridm:users:write, and ridm:* covers the whole catalogue. Wildcards only work on the granted side.

Built-in roles

Six roles are seeded in every tenant. They can be assigned, used as composites of other roles and attached to groups, but not renamed, deleted or given different permissions.

RoleHolds
ridm:ownerEvery permission, including tenant lifecycle
ridm:adminEvery permission except ridm:tenants:create, ridm:tenants:delete, ridm:tenants:import and ridm:users:impersonate
ridm:user-managerridm:tenants:read, ridm:users:read, ridm:users:write, ridm:invitations:read, ridm:invitations:write, ridm:groups:read, ridm:groups:write, ridm:roles:read, ridm:audit:read, ridm:scim:read, ridm:scim:write
ridm:org-adminridm:orgs:read, ridm:orgs:write, ridm:invitations:read, ridm:invitations:write, ridm:roles:read — meant to be granted inside one organization
ridm:client-managerridm:tenants:read, ridm:clients:read, ridm:clients:write, ridm:scopes:read, ridm:scopes:write, ridm:mappers:read, ridm:mappers:write, ridm:resource-servers:read, ridm:resource-servers:write, ridm:roles:read, ridm:audit:read
ridm:viewerEvery *:read permission (not ridm:tenants:export)

Permissions reach a user through their effective roles: direct assignments, roles of the groups they belong to (and of those groups’ ancestors), and composites of either. They are resolved on every request (cached, and invalidated by any role, group, assignment or grant change), so removing a role takes effect on the next request, not when the token expires.

Global and tenant scope

Where a role is held decides how far it reaches:

  • A user of the master tenant is a global administrator. Their permissions apply to every tenant.
  • A user of any other tenant is a tenant administrator. Their permissions apply to that tenant only; a request naming another tenant is refused with 403 (“this token is scoped to another tenant”).

A few operations need a global administrator whatever the permissions say: creating and deleting tenants, the master-key routes (GET /admin/master-key, POST /admin/master-key/rotate) and the global audit chain (/admin/audit). A tenant administrator listing GET /admin/tenants sees only their own tenant.

GET /admin/me reports the caller’s user, tenant, scope (global or tenant), roles and permissions; ridm whoami prints it.

To make someone the administrator of acme only, assign a built-in role to their acme user:

ROLE_ID=$(curl -s -H "Authorization: Bearer $RIDM_TOKEN" \
  "https://id.example.com/admin/tenants/acme/roles?realm_only=true" |
  jq -r '.[] | select(.name == "ridm:admin") | .id')
curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "https://id.example.com/admin/tenants/acme/users/$USER_ID/roles/$ROLE_ID"

In the console: Users → the user → Roles → assign ridm:admin. They then sign in to the console through acme.

Organization administrators

A role assignment may carry an organization (role_assignments.org_id, see Organizations). Such a grant applies only to a session acting in that organization — the org_id claim the sign-in put on the token — and only to the admin routes of that one organization:

  • everything under /admin/tenants/{slug}/organizations/{org}: the organization record, its members, its domains, the roles granted inside it, its invitations;
  • nothing else. An org-scoped grant never satisfies a tenant-wide check, so the users, groups, clients, settings and audit routes stay closed, and so does the list of the tenant’s organizations.

Three operations on the organization itself stay with the tenant’s administrators, because they are the tenant’s business rather than the organization’s: creating an organization, deleting one, and changing an existing one’s slug or status. Adding an existing user as a member is theirs too — an organization’s own administrator adds people by inviting an email address (POST /admin/tenants/{slug}/organizations/{org}/invitations, which creates the membership when the invitation is accepted) or through a verified auto-join domain. Such an invitation may not carry tenant roles or groups; roles are granted inside the organization afterwards, and GET …/{org}/grantable-roles lists what the caller may grant there.

To appoint one, grant ridm:org-admin (or any role) to a member within the organization:

curl -s -X PUT -H "Authorization: Bearer $RIDM_TOKEN" \
  "https://id.example.com/admin/tenants/acme/organizations/$ORG_ID/members/$USER_ID/roles/$ROLE_ID"

In the console: Organizations → the organization → Roles inside this organization. They sign in to the console as usual; with one membership the organization is chosen silently, and the console then opens their organization instead of the tenant’s pages. GET /admin/me reports it as organization, with organization_permissions beside the tenant-wide permissions.

Two limits are worth knowing. A personal access token belongs to a user rather than to a sign-in, so it carries no organization and no org-scoped permission; org administrators work through the console or a browser-issued token. And the MFA-for-administrators policy counts them: a user who administers any organization is an administrator for mfa.policy = required_for_admins.

No escalation through role management

An administrator cannot hand out more than they hold. Assigning a role to a user or group, adding a user to a group, adding a composite, granting an admin permission to a role, and inviting someone into roles or groups are all refused with 403 (“cannot grant permissions you do not hold”) when the grant would carry an admin permission the caller lacks. A user manager can therefore manage users but cannot make anyone an owner. Inside an organization the same rule is measured against what the caller holds there: an organization’s administrator can appoint a peer, but cannot grant a role that carries a tenant-wide admin permission.

The admin token

Admin endpoints take a bearer token in the Authorization header only, never in a query or form parameter. Authorization: DPoP <token> with a proof is accepted too, and a DPoP-bound token must come with one. The token is either an access token or a personal access token.

An access token, a JWT or an opaque at_… token of a client registered for opaque tokens, may be issued by any tenant, and must:

  • carry urn:ridm:admin in aud. The token endpoint only puts it there for clients that list it in allowed_audiences; it is never implied, even for clients with no audience restriction;
  • belong to an active, unlocked user, or to a machine client’s service-account user, whose effective roles grant at least one ridm:* permission;
  • if it was issued in a browser session, come from a session that is still alive, so signing out ends admin access before the token expires;
  • come from a tenant that is not disabled.

A missing or invalid token gets 401 with WWW-Authenticate: Bearer realm="ridm-admin"; a valid token without the permission a route needs gets 403 with an application/problem+json body naming it (see Errors).

Every tenant carries a built-in public client, ridm-admin-console, that the admin console signs in with: authorization code with PKCE, no consent step, and urn:ridm:admin as its only audience. It cannot be deleted and is left out of tenant exports. See The admin and account consoles.

Automation

Three ways to give a script or pipeline admin access, in order of how little they need:

Personal access tokens

A user mints a personal access token (rpat_…) in the account console, under Security, or through POST /t/{slug}/account/tokens with an account-console token:

{ "name": "ci-config-sync", "scopes": ["ridm:tenants:export", "ridm:tenants:import", "ridm:tenants:read"], "expires_in_days": 90 }
  • Scopes are account (the self-service API as the user) and any admin permission the user holds when the token is minted. At every use they are narrowed again to what the user still holds, so removing a role narrows every token at once, and a disabled or locked user’s tokens stop working.
  • The token is shown once and stored as a SHA-256 hash. It inherits the scope of its user: a master user’s token is global.
  • A token acts without a browser session, so nothing that needs a recent sign-in works with it, and a token can never mint another token.
  • settings.account.personal_tokens (default true) switches minting off for a tenant; settings.account.personal_token_max_days (default 365, 0 for no limit) caps the lifetime and is the default when none is asked for.
  • last_used_at is recorded at most once a minute. Administrators list and revoke a user’s tokens with GET /admin/tenants/{slug}/users/{user}/pats and DELETE …/pats/{token_id}, or on the user’s “Password & credentials” tab; the tokens themselves are never readable again. Deleting the account revokes whatever tokens are left. Minting and revoking raise personal_token.created and personal_token.revoked; introspection reports a live token as token_type: personal_access_token with its subject, username, scope and expiry.

A personal access token is the simplest credential for CI: put it in RIDM_TOKEN and the ridm CLI needs no login.

A machine client with a service account

For a credential that is not tied to a person:

  1. Register a machine client (client credentials) with urn:ridm:admin in allowed_audiences.
  2. Create its service account: PUT /admin/tenants/{slug}/clients/{client}/service-account. This makes a user named svc-<client_id> that client_credentials tokens are issued for.
  3. Assign that user the roles it needs, such as ridm:viewer or a custom role.
  4. Request tokens with grant_type=client_credentials and resource=urn:ridm:admin, or run ridm login --client-id <id> --client-secret-stdin.

Register the client in master for a global credential, or in the tenant it should manage. See Machine-to-machine access for the client side.

The device grant

A device client that lists urn:ridm:admin in its audiences lets an operator approve a CLI session in a browser: ridm login --client-id <id> --device. The approving user’s permissions apply, and the CLI keeps a refresh token.

Organizations

Organizations group a tenant’s users into customers, business units or teams; the concept page explains what they are and how they differ from tenants. This page is how to run them.

Everything here needs ridm:orgs:read to look and ridm:orgs:write to change. Owners, administrators and user managers hold both; viewers only read. Those permissions can also be granted inside one organization, which makes their holder that organization’s administrator and nobody else’s.

In the console

Identity → Organizations (or g then z). The list searches by name and slug. Choosing one opens its detail, which saves as you type:

FieldNotes
Nameshown to members on the login page’s picker
Sluglowercase letters, digits and hyphens. Clients may name it in an organization request parameter, so treat it as an identifier, not a label
Statusdisabled takes no new members and cannot be signed in to; its members keep their accounts
Descriptionfor administrators

Below the fields: Members (add with the user picker, remove, the primary organization marked), Invitations (invite an email address into the organization, revoke an open one), Email domains (add, verify, turn auto-join on or off), and Roles inside this organization.

An organization’s own administrator sees the same page opened on their organization, without the list, the New organization button, the delete button, the slug and status fields, and the Add member picker.

The admin API

GET    /admin/tenants/{slug}/organizations           # ?search= &status= &cursor= &limit=
POST   /admin/tenants/{slug}/organizations           # {slug, display_name, description?, attributes?}
GET    /admin/tenants/{slug}/organizations/{org}     # with member_count and domains
PATCH  /admin/tenants/{slug}/organizations/{org}
DELETE /admin/tenants/{slug}/organizations/{org}

GET    /admin/tenants/{slug}/organizations/{org}/members     # ?search= &cursor= &limit=
PUT    /admin/tenants/{slug}/organizations/{org}/members/{user_id}
DELETE /admin/tenants/{slug}/organizations/{org}/members/{user_id}

GET    /admin/tenants/{slug}/organizations/{org}/roles       # ?cursor= &limit=
GET    /admin/tenants/{slug}/organizations/{org}/grantable-roles   # with `grantable` per role
PUT    /admin/tenants/{slug}/organizations/{org}/members/{user_id}/roles/{role_id}
DELETE /admin/tenants/{slug}/organizations/{org}/members/{user_id}/roles/{role_id}
PUT    /admin/tenants/{slug}/organizations/{org}/groups/{group_id}/roles/{role_id}
DELETE /admin/tenants/{slug}/organizations/{org}/groups/{group_id}/roles/{role_id}

GET    /admin/tenants/{slug}/organizations/{org}/invitations    # ?open_only= &cursor= &limit=
POST   /admin/tenants/{slug}/organizations/{org}/invitations    # {email, expires_days?}
DELETE /admin/tenants/{slug}/organizations/{org}/invitations/{invitation}

GET    /admin/tenants/{slug}/organizations/{org}/domains
POST   /admin/tenants/{slug}/organizations/{org}/domains        # {domain, auto_join}
PATCH  /admin/tenants/{slug}/organizations/{org}/domains/{id}   # {auto_join}
POST   /admin/tenants/{slug}/organizations/{org}/domains/{id}/verify
DELETE /admin/tenants/{slug}/organizations/{org}/domains/{id}

The API reference has the bodies. Member lists (an organization’s and a group’s) come a page at a time in the order people joined, each member with joined_at; search keeps those whose username or email starts with it. Role grants come a page at a time too, a user’s with their username.

ORG=$(curl -fsS -X POST "$API/admin/tenants/acme/organizations" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"slug": "northwind", "display_name": "Northwind Traders"}' | jq -r .id)

curl -fsS -X PUT "$API/admin/tenants/acme/organizations/$ORG/members/$USER_ID" \
  -H "authorization: Bearer $TOKEN"

Verifying a domain

Adding a domain returns a verification value. Publish it as a TXT record at _ridm-challenge.<domain>, then ask rIDM to look:

curl -fsS -X POST "$API/admin/tenants/acme/organizations/$ORG/domains" \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"domain": "northwind.example", "auto_join": true}'
# → {"id": "…", "verification": "ridm-domain-verification=0192f…", "verified_at": null, …}

# after the record is published and has propagated
curl -fsS -X POST "$API/admin/tenants/acme/organizations/$ORG/domains/$DOMAIN_ID/verify" \
  -H "authorization: Bearer $TOKEN"

The check is one DNS TXT lookup from the rIDM server, so its host needs a resolver that can reach public DNS:

AnswerMeaning
200the record was found; the domain is verified from now on
400no matching record yet (also what a wrong value looks like)
503the server has no usable resolver configuration; an operator problem, worth retrying

Once verified, auto-join adds every user with a verified address at that domain as they sign in, including users who already existed. Turning auto-join off stops new joins and leaves existing members alone; so does removing the domain.

Roles inside an organization

A grant scoped to an organization applies only while a session acts there:

curl -fsS -X PUT \
  "$API/admin/tenants/acme/organizations/$ORG/members/$USER_ID/roles/$ROLE_ID" \
  -H "authorization: Bearer $TOKEN"

The user must already be a member (400 otherwise), and you must hold every permission the role carries (403 otherwise). Removing the membership removes the grants that hung off it.

GET …/organizations/{org}/roles lists the organization’s grants; each row names a role_id and either a user_id or a group_id. A group grant reaches every member of the group and of its descendants, while they act in this organization.

Organization administrators

Granting a role inside an organization makes its holder an administrator of that organization alone. ridm:org-admin is the built-in role for it (ridm:orgs:read, ridm:orgs:write, ridm:invitations:read, ridm:invitations:write, ridm:roles:read), but any role works: the reach comes from the grant, not the role.

ROLE=$(curl -fsS "$API/admin/tenants/acme/organizations/$ORG/grantable-roles" \
  -H "authorization: Bearer $TOKEN" | jq -r '.[] | select(.name=="ridm:org-admin") | .id')
curl -fsS -X PUT \
  "$API/admin/tenants/acme/organizations/$ORG/members/$USER_ID/roles/$ROLE" \
  -H "authorization: Bearer $TOKEN"

What they may do is everything on this page for that organization: read and rename it, manage members, domains, invitations and the roles granted inside it. What they may not do:

RefusedWhy
GET /organizations, POST /organizations, DELETE /organizations/{org}an organization’s existence is the tenant’s business
PATCH with slug or statusthe identity others may have stored, and the lifecycle
PUT /organizations/{org}/members/{user_id}they add people by invitation, or through a verified auto-join domain
an invitation carrying roles or groupsthose are granted tenant-wide on acceptance
any route outside /organizations/{org}users, groups, clients, settings, audit — all tenant-wide
granting a role that carries an admin permission they do not hold herethe same no-escalation rule as everywhere else

Their permissions come from the session’s organization, so the token must carry the org_id claim: they sign in normally (one membership is chosen silently) and GET /admin/me reports organization and organization_permissions. A personal access token belongs to the user rather than to a sign-in, carries no organization, and therefore holds none of this.

What members see

Members with more than one organization choose one on the login page; with one, it is chosen for them. The choice becomes the org_id claim in their tokens and decides which org-scoped roles they hold. The account console’s Organizations tab lists what a user belongs to, marking their primary one; it is read-only, since membership is granted, not chosen.

Events

organization.created, organization.updated, organization.deleted, organization.member_added, organization.member_removed, organization.domain_added, organization.domain_verified and organization.domain_removed all reach the audit log and webhooks (Webhooks and the audit log). An auto-join arrives as organization.member_added by the system actor.

MFA policy

This page covers the settings that decide when rIDM asks for a second factor, which factors users may enrol, passkeys, trusted devices, recovery codes, step-up requests from clients, and what an administrator does when a user loses a device. For how the second step fits into sign-in, see MFA and passkeys.

All of it is tenant configuration: Settings → Sign-in in the console, or PATCH /admin/tenants/{slug} with ridm:tenants:write (see Tenants and tenant settings).

When a second factor is asked: settings.mfa

settings.mfa is an object tagged by mode. The default is {"mode": "off"}.

ModeWho is asked for a second factor
offNobody, unless the client asks for it with acr_values
optionalUsers who have enrolled a second factor
requiredEveryone. A user with no factor enrols one during sign-in before continuing
required_for_adminsUsers holding any admin permission in the tenant, as for required; everyone else as for optional
required_for_rolesHolders of any of the listed roles, as for required; everyone else as for optional
{ "settings": { "mfa": { "mode": "required_for_roles", "roles": ["finance", "ridm:admin"] } } }
  • required_for_roles takes roles, a list of role names. A role counts however it is held: directly, through a group, or as a composite of another role.
  • required_for_admins looks at the user’s permissions on urn:ridm:admin in their own tenant. In master that is every global administrator. Setting it on master is the usual way to make console access need two factors.
  • Under off, a user who has enrolled a factor anyway (the account console allows it) is not asked for it at sign-in; optional is the mode that honours voluntary enrolment.

Switching mode needs only the new mode: a patch such as {"settings": {"mfa": {"mode": "required"}}} leaves nothing of a previous required_for_roles behind.

Policy is evaluated at sign-in, and again whenever an existing session is used. Every /authorize request that would reuse a live session, and every device-code approval, checks the session against the policy as it stands now: a session that never passed a second factor, held by a user the current policy asks for one (a policy tightened after sign-in, or a role granted since, counts), is sent to the second step before any code is issued, unless the browser is a trusted device. A pending forced password change is owed the same way and comes first. With prompt=none such a request fails with login_required. Sessions of users who have since been disabled or deleted get no codes at all; they sign in again. Changing the policy does not end sessions by itself.

Which factors: settings.mfa_methods

KeyDefaultFactor
totptrueAn authenticator app: six-digit codes, 30-second period
email_otpfalseA one-time code to the account’s email address (offered only to users with one)
sms_otpfalseA one-time code by text message to a verified phone number

Passkeys are not in this list: they follow settings.auth.passkey (below). Email and SMS codes need a working provider (see Email, SMS and templates).

A method switched off is no longer offered for enrolment. Factors already enrolled with it are left in place.

Keep at least one method on whenever mfa.mode requires a second factor, so users who have none have something to enrol.

Passkeys: settings.auth.passkey

settings.auth.passkey (default false) turns on WebAuthn passkeys in two roles at once:

  • as a first factor: the login page offers “sign in with a passkey”, with no username or password. When the authenticator verifies the user (a PIN or biometric), the sign-in already counts as two factors and no second step follows;
  • as a second factor: users may enrol a passkey on the second step or in the account console, and use it after a password.

The relying party ID is the tenant’s custom domain when it has one, else the host of UI_URL. Passkeys are bound to that host, so changing a tenant’s custom domain (or moving the UI to another host) strands the passkeys registered before; users have to enrol again. See Custom domains.

Trusted devices

On the second step, a user may tick “remember this device”. The browser then gets a long-lived cookie, and later sign-ins from it skip the policy-driven second factor.

SettingDefaultMeaning
settings.session.remember_device_days30How long a trusted device lasts (minimum 1)
  • The cookie holds a random secret; only its hash is stored. It is HttpOnly, host-only with Path=/, and named per tenant (__Host-ridm_device_acme, or ridm_device_acme with COOKIE_SECURE=false).
  • A step-up request from a client (below) is honoured even on a trusted device.
  • Users list and revoke their trusted devices in the account console (Security); administrators do so on the user page (Sessions & devices) or with GET and DELETE /admin/tenants/{slug}/users/{user}/devices[/{device_id}]. Deleting a user revokes them all.

Recovery codes

A user’s first second factor comes with ten single-use recovery codes, shown once. Each is ten characters from a lower-case base-32 alphabet; case, spaces and dashes are ignored when typed. On the second step, “use a recovery code” is offered while unused codes remain.

  • Users generate a new set in the account console (Security), or with POST /t/{slug}/account/mfa/recovery-codes; the old set stops working.
  • Removing the user’s last second factor removes the codes with it, whether the user does it in the account console or an administrator does it through the admin API.
  • The codes are stored encrypted as one recovery_code credential row per user.

Step-up with acr_values

A client that needs a second factor for a particular request, whatever the tenant policy, asks for it in the authorization request:

https://id.example.com/t/acme/authorize?client_id=acme-portal&response_type=code
  &scope=openid&redirect_uri=https%3A%2F%2Fportal.acme.example%2Fcb
  &code_challenge=…&code_challenge_method=S256
  &acr_values=urn:ridm:acr:mfa
  • acr_values is a preference list, most preferred first. rIDM honours the first class it recognises: a class ending in :mfa is a step-up request, and a weaker class ahead of it (urn:ridm:acr:single) means the client will settle for that. Classes rIDM cannot assert are skipped.
  • A step-up is honoured even on a trusted device and even under mode: off. A user with no factor enrols one first.
  • A live session whose acr is not one of the requested values goes straight to the second step, without the password again; one that already carries the class does not ask again. With prompt=none the request fails with login_required instead. Add max_age=0 or prompt=login to force a fresh sign-in as well.

The session, and the ID and access tokens from it, report acr urn:ridm:acr:mfa (or the :mfa class the client named) with mfa in amr once a second factor has passed, and urn:ridm:acr:single otherwise. Discovery advertises acr_values_supported: ["urn:ridm:acr:single", "urn:ridm:acr:mfa"]. A relying party should check acr in the ID token rather than assume its request was met; see Token claims.

The account console uses the same mechanism: a security change there needs a sign-in from the last fifteen minutes, with the second step once the user has a factor, and the console sends the user through sign-in with max_age=0 and acr_values when it is missing.

Failed attempts

Five wrong second-factor codes in one sign-in discard the flow; the user starts again from the first step. Codes sent by email or SMS are single-use and expire after ten minutes. First-factor failures count towards the account lockout in settings.lockout (see Tenants and tenant settings).

Resetting a user’s MFA

When a user has lost their authenticator and their recovery codes:

  1. Remove the factors. Console: Users → the user → Password & credentials → remove each factor. API: list them, then delete each one:

    curl -s -H "Authorization: Bearer $RIDM_TOKEN" \
      "https://id.example.com/admin/tenants/acme/users/$USER_ID/credentials"
    curl -s -X DELETE -H "Authorization: Bearer $RIDM_TOKEN" \
      "https://id.example.com/admin/tenants/acme/users/$USER_ID/credentials/$CREDENTIAL_ID"
    

    Removing the last second factor removes the recovery_code row with it, as in the account console. The user is notified when settings.notifications.mfa_changed is on.

  2. Revoke trusted devices, so no remembered browser skips the new enrolment: DELETE /admin/tenants/{slug}/users/{user}/devices.

  3. End sessions if the device may be in someone else’s hands: DELETE /admin/tenants/{slug}/users/{user}/sessions.

At the next sign-in, a user under a required policy enrols a new factor (and gets new recovery codes); under optional they sign in with one factor and may enrol again from the account console.

Verify the person’s identity out of band before removing their factors: this is the step an attacker who has taken over an email account would ask a help desk for.

Adaptive authentication

Risk-based adaptive authentication scores every sign-in against what the user has done before, and lets the tenant decide what an unusual one costs: nothing, a second factor, or a refusal. It is off until an administrator turns it on, and it changes nothing about how correct credentials are checked — a wrong password is still a wrong password.

All of it is tenant configuration: Settings → Adaptive auth in the console, or PATCH /admin/tenants/{slug} with ridm:tenants:write (see Tenants and tenant settings). The location signals additionally need a geo source on the deployment, which is operator configuration (Geo-IP).

The signals

Each signal a sign-in raises adds its weight to the score.

SignalRaised whenNeeds
new_deviceThe browser sent no live trusted-device cookie and the user has signed in before, but never from this user agentNothing
new_countryThe user has never signed in from this country beforeA geo source
impossible_travelThe distance from where the user was last seen cannot be covered in the time since, at impossible_travel_kmhA geo source that yields coordinates
velocityThe address is behind velocity_max_failures or more failed sign-ins in the last velocity_window_minutesNothing

A signal that cannot be computed is not raised. A user’s very first sign-in raises nothing at all: there is no history to be unlike. This is deliberate, and it is why turning the policy on does not step up every user at once — the history fills in as people sign in.

Two points less than 100 km apart never count as travel (city coordinates move around without the user doing so), and a sign-in whose country is known but whose coordinates are not can still raise new_country.

The score and the two thresholds

{
  "settings": {
    "risk": {
      "enabled": true,
      "weights": { "new_device": 20, "new_country": 50, "impossible_travel": 60, "velocity": 40 },
      "step_up_at": 50,
      "block_at": 100,
      "impossible_travel_kmh": 900,
      "velocity_window_minutes": 15,
      "velocity_max_failures": 10
    }
  }
}
ScoreOutcome
Below step_up_atNothing: the sign-in proceeds as it always would
step_up_at or aboveStep up. The sign-in must pass a second factor
block_at or aboveBlock. The sign-in is refused

Either threshold at 0 switches that outcome off: block_at: 0 runs the policy in step-up-only mode, and both at 0 scores sign-ins without ever acting on them (useful while tuning, since the score still reaches the audit log the moment either threshold is crossed — set them high rather than to zero if you want to watch first).

With the defaults above, a new country alone steps up; a new country from a new browser after impossible travel blocks.

What a step-up does

The second factor is demanded for that sign-in whatever settings.mfa says, and a trusted-device cookie does not waive it: the cookie says which browser this is, not who is holding it. A user with no second factor enrols one then and there, exactly as under mfa.mode: "required" — so a tenant that blocks or steps up should offer at least one factor under Settings → Sign-in.

What a block does

The flow ends: no session is opened (or, on a resumed session, none is usable for this request), the login flow is discarded, and the browser goes back to the client with error=access_denied. A device-code approval is denied so the waiting device stops polling. The attempt is written to the login-attempt log as a failure with the reason risk_blocked, which also feeds the velocity signal — a run of blocked attempts from one address raises it.

A blocked sign-in teaches the history nothing: its country does not become a place the user is known to sign in from.

When a sign-in is scored

Twice:

  1. When a first factor passes — password, passwordless code, magic link, passkey, registration, or a brokered sign-in from an upstream provider. The score is decided once and carried for the rest of the flow.
  2. When a live session is reused at /authorize or at a device-code approval. A session that opened somewhere safe can be used from anywhere, so a silent sign-in from a new country steps up, and one the policy blocks answers access_denied. The session is the browser’s own history, so new_device never fires on this path.

Personal access tokens, client credentials and refresh-token exchanges are not sign-ins and are not scored.

Where the history comes from

A sign-in the policy allows records the country it came from (and its coordinates, when the source has them) against the user. Nothing is recorded while the policy is off, and the rows go with the user when the account is deleted. Each row holds the country, the last coordinates seen in it, a count and the first and last times it was seen.

In the audit log

A step-up and a block each write one event; an ordinary sign-in writes none.

EventPayload
risk.step_upuser_id, score, signals, country
risk.blockeduser_id, score, signals, country

Both carry the request’s address and user agent like every other event, and both reach webhooks subscribed to risk.*. See Webhooks and the audit log.

The ridm_risk_decisions_total counter (labelled action) tracks the same two outcomes for dashboards.

Turning it on safely

  1. Configure a geo source, unless you only want the device and velocity signals.
  2. Make sure at least one second-factor method is enabled.
  3. Start with block_at: 0, so the worst that happens is a second factor.
  4. Watch risk.step_up in the audit log for a week; the history is filling in during that time, so expect more step-ups early on than later.
  5. Lower block_at into place once the signals look right for your users.

Impersonation

Impersonation lets an administrator sign in as one of a tenant’s users, to see what they see: the apps they reach, the pages the account console shows them, what a relying party makes of their token. Tokens from the session name the administrator, the audit log records every step, and the user’s credentials, consent and account are kept out of reach.

It is off until an administrator turns it on for the tenant.

Turning it on

Settings → Impersonation in the console, or with ridm:tenants:write:

curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"settings": {"impersonation": {"enabled": true, "max_minutes": 60}}}'
FieldDefaultMeaning
enabledfalseWhether anyone may impersonate this tenant’s users
max_minutes60How long an impersonated session lives (1–480). It never outlives the tenant’s absolute session timeout either

Who may, and whom

  • The caller needs ridm:users:impersonate for the tenant. Among the built-in roles only ridm:owner holds it, and ridm:admin deliberately does not. A custom role can be given it, so a support team can impersonate without owning the tenant. A global administrator whose role in master holds it reaches every tenant that allows impersonation.
  • The user must be active, and must hold no admin permission at all, whether tenant-wide or inside an organization. Taking on someone’s identity can never widen what the administrator may do.
  • Nobody impersonates themselves.
  • A reason is required (1–500 characters). It is stored with the session and recorded in the audit log, so a support ticket number is a good choice.

All of this is checked when the link is asked for, and again when it is opened.

Starting

In the console, open the user and choose Impersonate. The button appears only when you hold the permission, the tenant allows impersonation, and the user is active. Give a reason and you get a link that opens a session as the user in a new tab. Or through the API:

curl -X POST https://id.example.com/admin/tenants/acme/users/$USER_ID/impersonate \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"reason": "Ticket 4711: the user cannot see the billing app"}'
{
  "url": "https://id.example.com/t/acme/impersonate?ticket=…",
  "expires_at": "2026-09-21T15:02:10Z"
}

The link works once, and only for 60 seconds. Opening it in a browser sets the tenant’s session cookie for a new session as the user and lands on the account console, which signs in through it. From there, any client the user has already consented to signs in silently as the user, the same way it would for them.

In the browser that opens it, the link replaces whatever session that browser had in the tenant. The browser’s own session is remembered, and it comes back when the impersonation ends. This matters when a tenant’s own administrator impersonates a user of that same tenant. A global administrator’s session lives in master and is never touched.

What the session is

It is an ordinary SSO session of the user, with these differences:

  • It carries no authentication method. The user proved nothing, so amr is empty and there is no acr.
  • It owes none of the user’s steps. The MFA policy, a forced password change and risk scoring judge the user’s own sign-ins. The administrator couldn’t pass them, and opening the session was checked and audited instead. A client that demands an MFA class in acr_values still asks for the second step, which the administrator can’t give.
  • It never counts against the user’s concurrent-session cap, so opening it never signs the user out anywhere.
  • It ends after max_minutes, and so does everything minted from it.

What its tokens carry

Every access token and ID token minted from the session, including those from its refresh tokens and from a device-flow approval, carries an act claim naming the administrator (RFC 8693 §4.1):

{
  "sub": "5b0c…",
  "act": {
    "sub": "0192…",
    "iss": "https://id.example.com/t/master"
  }
}

act.sub is the administrator’s user id and act.iss their tenant’s issuer, which is often master. A relying party that must not act on an impersonation, such as a payments service, can refuse any token carrying act. No token outlives the session: access tokens expire by its end and refresh-token families end with it, even ones granted offline_access.

The admin API refuses every token carrying act, and every token from an impersonated session. Administration is done as oneself.

What it may not do

The account API answers 403 with urn:ridm:error:impersonation-forbidden to anything only the user may do:

  • everything that needs a recent sign-in: the password, second factors and passkeys, email and phone changes, linked identities, trusted devices, ending sessions, personal access tokens, the data export, deleting the account;
  • withdrawing an app’s consent.

Consent can’t be given either. A client the user hasn’t consented to sends the browser back with access_denied instead of showing a consent page. Looking at everything, and editing ordinary profile attributes, works.

The same refusals apply to any account-API token that names an actor, such as one obtained by token exchange.

Ending

The account console shows a banner on every page while it’s impersonating, with an End impersonation button. The button posts to POST /t/{slug}/impersonation/end, which:

  1. ends the session, its tokens with it, and tells its relying parties (back-channel and front-channel logout, as for any sign-out);
  2. puts back the browser’s own session in the tenant, if it had one and it is still live;
  3. returns to the administrator’s console.

Revoking the session anywhere else also ends it: from the user’s page in the console (Sessions), through DELETE /admin/tenants/{slug}/users/{user}/sessions/{id}, or by the user in their own account console, where it’s listed as opened by the administrator (with the administrator’s address and browser withheld). A session that runs out its time simply expires.

The audit trail

EventActorPayload
impersonation.requestedthe administratoruser_id, reason
impersonation.startedthe administratoruser_id, session_id, impersonator_id, impersonator_tenant_id, reason
impersonation.endedthe administrator when they ended it from the session, otherwise systemuser_id, session_id, impersonator_id

Everything else recorded while the session is in use, such as the authorizations it grants, the tokens it refreshes and the profile changes it makes, keeps the user as its actor and names the administrator in impersonator_id. That field is part of the audit row, is covered by the hash chain, appears in webhook payloads as impersonator, and can be filtered on:

# Everything one administrator did while impersonating
curl -G https://id.example.com/admin/tenants/acme/audit \
  -H "Authorization: Bearer $TOKEN" --data-urlencode "impersonator_id=$ADMIN_ID"

user_id= matches rows where the user is the actor, the subject or the impersonator, so an administrator’s own audit trail includes what they did as someone else.

Backchannel sign-in and FAPI 2.0

Two things for clients with stricter needs than a browser redirect:

  • Backchannel sign-in (CIBA). An application that already knows who the user is (a call centre, a point-of-sale terminal, a device with no browser) asks rIDM to have that user sign in on their own device. It never sees a browser; it collects the tokens once the user approves. This is OpenID Client-Initiated Backchannel Authentication (CIBA) Core 1.0.
  • The FAPI 2.0 Security Profile. A client can be held to the profile open-banking and other high-assurance APIs ask for: pushed requests, PKCE, sender-constrained tokens, strong signatures, and nothing weaker accepted from it.

Both are per client. Neither changes how any other client works.

Backchannel sign-in (CIBA)

Registering a client for it

The client needs the grant urn:openid:params:grant-type:ciba in allowed_grants, and it must be confidential: CIBA requests are always authenticated. In the console, tick Backchannel (CIBA) under Grants & authentication and choose the delivery.

FieldDefaultNotes
backchannel_token_delivery_modepoll when the grant is allowedpoll: the client asks the token endpoint until the user answers. ping: rIDM calls the client’s notification endpoint once the user answers, and the client then collects the tokens. push is not offered: it would put the tokens themselves on an outbound call
backchannel_client_notification_endpointnullRequired in ping mode, refused in poll mode. HTTPS (plain HTTP only for loopback addresses)

Both fields are refused on a client without the grant; remove them in the same change that removes the grant. Signed authentication requests and user codes are not supported, and a dynamic registration that asks for either (backchannel_authentication_request_signing_alg, backchannel_user_code_parameter: true) is refused.

The request

curl https://id.example.com/t/acme/bc-authorize \
  -u "bank:$SECRET" \
  -d scope="openid profile" \
  -d login_hint=alice@example.com \
  -d binding_message="K7 R2"
{ "auth_req_id": "Vh2…", "expires_in": 600, "interval": 5 }
ParameterRequiredMeaning
scopeyesMust include openid; the client’s default scopes are not used
login_hintone hintThe user’s username or email address, as they sign in with
id_token_hintone hintAn ID token this tenant issued to this client (expired is fine)
login_hint_token—Refused: it has no standard format
binding_messagenoUp to 64 letters, digits, spaces and -_.:#, shown to the user and in their notice. The client shows the same text on its own screen so the user can check they match. Anything else (a link, markup, a line break) is invalid_binding_message
client_notification_tokenping modeThe bearer token rIDM sends to the notification endpoint (up to 1024 characters)
requested_expirynoSeconds the request stays open, 30–1800; 600 by default
acr_valuesnoRecorded with the request
resourcenoAudiences, as at the token endpoint (RFC 8707)

Exactly one hint must be present. A hint that names no active, unlocked user is unknown_user_id. At most five requests may wait on one user at a time; a sixth is access_denied, so no client can flood someone’s inbox.

What the user sees

rIDM emails the user (or texts them, if they have a verified phone and no email) the backchannel_request message: who is asking, the binding message, and a link to Requests in the account console, /account/approvals/. The link carries the request’s own id, never the auth_req_id. The notice is sent whatever settings.notifications says, because without it nobody learns there is anything to approve. Its template can be overridden like any other; see Email, SMS and templates.

The page lists what waits on the user: the application, its binding message, the scopes, and when the request expires. The user signs in to the account console if they are not already, then approves or denies. An approval:

  • gives the client tokens as if the user had signed in to it from the session they approved in: the ID token’s sid, auth_time, amr and acr are that session’s, and a refresh token without offline_access ends with it;
  • is remembered as consent, so the application appears under Applications and can be removed there like any other.

A request is answered once. Approving needs a signed-in session: a personal access token cannot approve (403), or a leaked one could turn itself into the user’s tokens at any CIBA client. A session opened by impersonation cannot answer at all (403), and one user can never see or answer another’s request.

Collecting the tokens

curl https://id.example.com/t/acme/token \
  -u "bank:$SECRET" \
  -d grant_type=urn:openid:params:grant-type:ciba \
  -d auth_req_id=Vh2…

Until the user answers, the token endpoint says authorization_pending; asking again within the interval says slow_down and adds five seconds to it. A denial is access_denied and an expired request expired_token, each once. An approval is handed over at once, whatever the interval, and only once: after that the auth_req_id is invalid_grant. Only the client that asked can collect.

In ping mode rIDM POSTs, as soon as the user answers:

POST /cb HTTP/1.1
Authorization: Bearer <client_notification_token>
Content-Type: application/json

{"auth_req_id": "Vh2…"}

It tries three times over about five seconds through the same outbound guard as back-channel logout (no private addresses unless the operator opened them with OUTBOUND_ALLOW_NETWORKS). A ping client that never hears can still poll.

Audit

backchannel.requested (actor: the client) when a request opens, authorization.granted when the user approves, backchannel.denied when they refuse. The ciba_requests table keeps every request’s outcome for the tenant’s retention period.

The FAPI 2.0 Security Profile

Set security_profile: "fapi2" on a client (in the console: Security profile → FAPI 2.0 Security Profile). Registration then refuses anything outside the profile, and every request from the client is held to it.

RuleWhere it is enforced
Confidential client (web or machine) authenticating with private_key_jwt, tls_client_auth or self_signed_tls_client_authregistration
Grants limited to authorization_code, refresh_token, client_credentials and CIBAregistration
HTTPS redirect URIs, exact matchregistration, /authorize
PKCE, which cannot be switched off, and sender-constrained tokens: DPoP-bound, or certificate-bound (at least one)registration; /authorize, /par, /token
Authorization requests only through PAR/authorize refuses anything else with invalid_request to the redirect URI
Client assertions signed with PS256, ES256 or EdDSA, with the issuer identifier as aud, as a stringevery endpoint that authenticates the client
Request objects and DPoP proofs signed with PS256, ES256 or EdDSA/par, /token
ID tokens, access tokens and JARM responses signed with ES256 or EdDSAthe tenant’s default algorithm when it is one of these, otherwise ES256; a key is created on demand
Refresh tokens are not rotated/token answers with the same refresh token, which stays valid
Authorization codes live 60 seconds; PAR request URIs 60 secondsalways so in rIDM

A resource server that asks for RS256 access tokens cannot be a FAPI client’s audience: such a request is invalid_target.

With mutual TLS the client may authenticate with its certificate instead of private_key_jwt, and a client registered for certificate-bound tokens is sender-constrained by its certificate: it needs no DPoP proof, and DPoP defaults to off for it. Without either binding the registration is refused.

Requiring PAR without the profile

require_pushed_authorization_requests: true (RFC 9126 §6) makes /authorize refuse any request of that client that did not come through /par, with none of the profile’s other rules. A FAPI 2.0 client always requires PAR. Dynamic registration accepts the field and stores it.

Mutual TLS

A client can prove who it is with a TLS client certificate instead of a secret or a signed assertion, and its access tokens can be bound to that certificate so that a stolen token is useless without the certificate’s key. Both come from RFC 8705:

  • tls_client_auth: the client presents a certificate issued by a certificate authority the tenant trusts, carrying a subject registered for the client;
  • self_signed_tls_client_auth: the client presents a certificate it registered itself, in its JWK Set;
  • certificate-bound access tokens: every access token carries the SHA-256 thumbprint of the client’s certificate (cnf.x5t#S256), and a resource accepts it only over a connection with that certificate.

Machine-to-machine integrations, partner APIs and open-banking deployments use it where a shared secret is not good enough, and the FAPI 2.0 profile accepts it in place of private_key_jwt and DPoP.

Getting certificates to rIDM

A client certificate is part of the TLS handshake, so whatever terminates TLS has to ask for it. There are two ways, and either one turns the feature on (see Server configuration).

rIDM’s own mTLS listener

MTLS_BIND=0.0.0.0:8443
MTLS_CERT=/etc/ridm/tls/mtls-fullchain.pem   # else TLS_CERT
MTLS_KEY=/etc/ridm/tls/mtls-privkey.pem      # else TLS_KEY
MTLS_PUBLIC_URL=https://mtls.id.example.com:8443

A second listener serves the same routes and asks every connection for a client certificate without requiring one. It does not judge the chain (which CA, which certificate, is decided per client once the request names it), but the handshake does prove the client holds the certificate’s private key. Put it behind a layer-4 load balancer that passes TCP through, or expose it directly.

A reverse proxy that forwards the certificate

CLIENT_CERT_HEADER=X-Client-Cert
TRUSTED_PROXIES=10.0.0.0/8
MTLS_PUBLIC_URL=https://mtls.id.example.com

The proxy asks for the certificate (without verifying the chain: rIDM does that per client), then forwards it in CLIENT_CERT_HEADER. rIDM reads the header only from a TRUSTED_PROXIES peer, and the proxy must overwrite it on every request, so a caller can never supply its own. PEM, URL-encoded PEM and base64 DER are all accepted, leaf first. For example:

# nginx: a separate server block for the mTLS host.
server {
    listen 443 ssl;
    server_name mtls.id.example.com;
    ssl_certificate     /etc/nginx/tls/mtls-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/mtls-privkey.pem;
    ssl_verify_client   optional_no_ca;   # ask, verify possession, leave the CA to rIDM

    location / {
        # Empty without a certificate, and nginx then drops the header: a
        # caller's own X-Client-Cert never reaches rIDM.
        proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://ridm;
    }
}
# Caddy
mtls.id.example.com {
	tls {
		client_auth {
			mode request
		}
	}
	reverse_proxy ridm:8080 {
		header_up X-Client-Cert {http.request.tls.client.certificate_der_base64}
	}
}
# Traefik (dynamic configuration). passTLSClientCert removes any
# X-Forwarded-Tls-Client-Cert the caller sent; set
# CLIENT_CERT_HEADER=X-Forwarded-Tls-Client-Cert.
http:
  routers:
    ridm-mtls:
      rule: "Host(`mtls.id.example.com`)"
      service: ridm
      middlewares: [client-cert]
      tls:
        options: mtls
  middlewares:
    client-cert:
      passTLSClientCert:
        pem: true
tls:
  options:
    mtls:
      clientAuth:
        clientAuthType: RequestClientCert

Kubernetes

The Helm chart’s mtls values cover both ways. Behind an ingress controller or gateway that asks for the client certificate without verifying its chain and forwards it, as the proxies above do, name its header:

trustedProxies: 10.0.0.0/8        # the controller's pod network
mtls:
  publicUrl: https://mtls.id.example.com
  clientCertHeader: X-Client-Cert

For rIDM’s own listener, the chart mounts a kubernetes.io/tls Secret, sets MTLS_BIND=0.0.0.0:8443 with its certificate, and adds a second Service (<release>-mtls) for it. That Service has to be reached at layer 4 (a LoadBalancer, or an ingress controller’s TLS passthrough): an ingress that terminates TLS would take the client certificate with it.

mtls:
  publicUrl: https://mtls.id.example.com
  listener:
    enabled: true
    tlsSecret: ridm-mtls-tls
    service:
      type: LoadBalancer
      annotations:
        service.beta.kubernetes.io/aws-load-balancer-type: nlb

The chart refuses publicUrl without either, a listener without its Secret, and a header without trustedProxies.

Endpoint aliases

Asking for a certificate on the host users sign in on makes browsers with a certificate installed show a picker on the login page. RFC 8705 §5 solves this with a second host: with MTLS_PUBLIC_URL set, discovery lists mtls_endpoint_aliases for the endpoints a client authenticates at (token, PAR, introspection, revocation, userinfo, device authorization and CIBA), under {MTLS_PUBLIC_URL}/t/{slug}. Browsers keep using the ordinary endpoints; mTLS clients use the aliases. A private_key_jwt assertion sent to the alias token endpoint may name it as its aud, and a DPoP proof may name the alias URL as its htu.

Trusting certificate authorities

tls_client_auth clients need at least one of the tenant’s certificate authorities: Security → Client certificates in the console, or the admin API:

curl -X POST "$RIDM/admin/tenants/acme/mtls/trust-anchors" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d "{\"name\": \"Partner CA\", \"certificate_pem\": $(jq -Rs . < partner-ca.pem)}"

Each entry is one CA certificate: a root, or an intermediate on its own (a client’s chain only has to reach one of them). rIDM refuses a certificate that is not a CA or has expired, and lists each with its subject, expiry and SHA-256 thumbprint. A client certificate passes when it chains to one of them through the intermediates the client sent, is valid now, and, when it has an extended key usage, allows client authentication. A tenant trusts at most 50 authorities; they travel in the tenant document as mtls_trust_anchors. Adding and removing one is audited as mtls_trust_anchor.created and .deleted.

rIDM does not check revocation (CRLs or OCSP). To cut off a certificate, remove its authority, or change the client’s registered subject; keep client certificates short-lived.

Registering a client

In the console, pick Mutual TLS (CA-issued certificate) or Mutual TLS (self-signed certificate) as the client’s authentication; the creation wizard leaves both to the detail page, where their extra settings are.

tls_client_auth needs exactly one of these, naming what the certificate must carry:

FieldMatched against
tls_client_auth_subject_dnthe subject, as an RFC 4514 string with the most specific name first (CN=billing,O=Acme,C=US), attribute by attribute; values compare without regard to case or repeated spaces. openssl x509 -noout -subject -nameopt RFC2253 prints it in this form.
tls_client_auth_san_dnsa DNS name in the subject alternative names, without regard to case
tls_client_auth_san_uria URI SAN, exactly (a SPIFFE ID, for instance)
tls_client_auth_san_ipan IP address SAN
tls_client_auth_san_emailan email SAN, without regard to case

self_signed_tls_client_auth needs the certificate in the client’s JWK Set: the first entry of a key’s x5c, inline in jwks or served at jwks_uri (re-fetched once when an unknown certificate arrives, like a key for private_key_jwt). No chain is checked; the certificate itself is the registration.

At the token endpoint (and PAR, introspection, revocation, device authorization and CIBA) the client sends client_id in the body and nothing else; the certificate is the credential:

curl --cert billing.pem --key billing.key \
  https://mtls.id.example.com/t/acme/token \
  -d grant_type=client_credentials -d client_id=billing

A missing, untrusted or mismatched certificate is invalid_client. Both methods are also accepted by dynamic registration, with the metadata names above.

Certificate-bound tokens

Switch on Certificate-bound access tokens (tls_client_certificate_bound_access_tokens) and the client’s token requests must come over mutual TLS; every access token then carries

"cnf": { "x5t#S256": "base64url(SHA-256(the certificate's DER))" }

This works whatever the client authenticates with, a secret included. The token type stays Bearer: the binding is checked on the connection, not in a header. For a public client, its refresh tokens are bound to the certificate too; a confidential client’s are bound by its credentials, and each refresh binds the new access token to the certificate of that request, so certificates can be rolled.

rIDM checks the binding wherever it accepts an access token (userinfo, the account and admin APIs), and token exchange refuses a certificate-bound subject token unless the request carries the same certificate and the exchanging client is itself registered for bound tokens, so the result is bound again.

A resource server built on the ridm-auth crate checks the binding with Validator::validate_with_certificate, handing it the connection’s DER certificate; validate alone refuses a bound token.

FAPI 2.0

A FAPI 2.0 client may authenticate with tls_client_auth or self_signed_tls_client_auth instead of private_key_jwt, and be sender-constrained by its certificate instead of DPoP: a FAPI client registered for certificate-bound tokens does not need DPoP proofs. It needs at least one of the two.

SAML identity provider

rIDM is a SAML 2.0 identity provider for applications that sign users in with SAML rather than OpenID Connect: older enterprise software and SaaS tools whose “single sign-on” setting asks for an IdP’s metadata. It implements the Web Browser SSO profile, service-provider-initiated over HTTP-Redirect or HTTP-POST (and IdP-initiated for applications that opt in), and front-channel Single Logout.

A SAML application is registered as a service provider (SP). Under the hood it is a client of type saml, so the things clients have work the same way for it: the sign-in page, second factors and step-up, risk policy, consent, organizations, client roles, IP rules, the audit log and the dashboard’s sign-in counts. It has no redirect URIs, grants or secret, so no OAuth endpoint will serve it, and the client routes refuse to edit its settings; the SAML routes and the console’s SAML page do.

The XML signature and encryption code is rIDM’s own, with no C XML library underneath. CI checks its signatures and encryption against xmlsec1 both ways, and the message parser is fuzzed (api/fuzz, target saml_message).

What to give the service provider

Every tenant is an IdP. Most SPs take the metadata URL, or an upload of the document:

Value
Metadata{issuer}/saml/metadata
Entity ID{issuer} (the tenant’s OIDC issuer)
Single sign-on{issuer}/saml/sso (HTTP-Redirect and HTTP-POST)
Single logout{issuer}/saml/slo (HTTP-Redirect and HTTP-POST)
Signing certificatein the metadata; also on the console’s SAML page

On a custom domain the issuer is the custom host, and these URLs follow it.

The console’s SAML page shows all of these with copy buttons, and so does GET /admin/tenants/{slug}/saml.

Registering a service provider

In the console, SAML → New service provider. Paste or upload the SP’s metadata to fill in its entity ID, consumer URLs, logout URL and certificates, review, and create; or enter the entity ID and assertion consumer service URL by hand. Every other setting is then edited on the SP’s page and saves as you go.

Through the API, POST /admin/tenants/{slug}/saml/service-providers takes the registration below (POST …/service-providers/metadata with {"metadata": "<xml>"} turns a metadata document into one without saving anything), PUT …/{sp} replaces it, and DELETE …/{sp} removes the SP. They need ridm:clients:read / ridm:clients:write. Disabling an SP is the client’s status: PATCH /admin/tenants/{slug}/clients/{sp} with {"status": "disabled"}, or the switch on its page.

FieldDefaultNotes
namerequiredShown on the sign-in and consent pages
entity_idrequiredThe SP’s entity ID, unique in the tenant
acs_urlsrequiredHTTP-POST assertion consumer services, 1–32. The first is the default; a request may name another by URL or index, never an unregistered one
slo_url, slo_bindingnone, redirectWhere rIDM sends logout requests, and how (redirect or post)
name_id_formatpersistentpersistent (opaque, stable, different for every SP), transient (new on every sign-in), email, or unspecified (the user’s id)
allowed_scopesopenid, profile, emailWhose claims may be released as attributes
attributes[]See Attributes
require_consentfalseAsk the user before releasing attributes the first time. Off by default: an SP is an application an administrator connected
signing_certificates[]PEM or base64 certificates the SP signs requests with (up to four, for its own key rollover)
require_signed_requestsfalseRefuse requests not signed with one of them
sign_response, sign_assertiontrue, trueAt least one stays on
encrypt_assertion, encryption_certificatefalse, noneEncrypt the assertion to the SP’s RSA certificate
data_encryptionaes256-gcmAlso aes128-gcm, and aes256-cbc / aes128-cbc for SPs that cannot read GCM
key_transportrsa-oaep-mgf1pRSA-OAEP with SHA-1 (every SAML stack reads it; SHA-1 is sound inside OAEP), or rsa-oaep-sha256 (XML Encryption 1.1; SPs built on xmlsec 1.2 cannot read it)
allow_idp_initiated, default_relay_statefalse, noneSee IdP-initiated sign-in
assertion_ttl_secs300How long the SP may accept the assertion (30–3600)
client_idgeneratedrIDM’s own id for the SP: roles, audit, consent

Sign-in

An AuthnRequest is checked before the browser is shown anything: its issuer must be a registered, enabled SP; a signature, if present, must verify against the SP’s registered certificates (in the query string for HTTP-Redirect, enveloped in the XML for HTTP-POST); Destination must be rIDM’s SSO URL (and is required when the request is signed); IssueInstant must be within the last ten minutes, three minutes’ clock skew allowed; the consumer URL must be registered; and a request ID is accepted once. A failure there is shown to the user as an error page, never sent anywhere. What the SP may hear is sent to it as a signed status response: UnsupportedBinding (anything but HTTP-POST for the response), InvalidNameIDPolicy (a NameID format other than the configured one, or an email NameID for a user without an address), NoAuthnContext, and NoPassive when IsPassive would need a sign-in page.

The request then runs through the same sign-in machinery as an OpenID Connect authorization. ForceAuthn is prompt=login. A RequestedAuthnContext naming https://refeds.org/profile/mfa or http://schemas.microsoft.com/claims/multipleauthn asks for a second factor, as acr_values would; PasswordProtectedTransport, Password and unspecified take any sign-in. The assertion’s AuthnContextClassRef says what happened: the MFA class after a second factor, PasswordProtectedTransport after a password, unspecified otherwise.

The answer is a Response posted to the consumer URL, with the assertion’s subject confirmation, audience restriction and conditions set to the SP and its URL, the session’s SessionIndex, and the RelayState the SP sent. If the user cancels, refuses consent, or the risk policy refuses the sign-in, the SP gets a signed RequestDenied or AuthnFailed response instead.

An HTTP-POST request from the SP arrives without the user’s session cookie (it is SameSite=Lax, and the post is cross-site), so rIDM checks it, keeps it for ten minutes and sends the browser to {issuer}/saml/sso?continue=…, where the cookie is present.

An administrator impersonating a user gets no SAML assertion: SAML has no act claim, so the SP could not tell who it was really dealing with.

Attributes

With no attribute list, every claim the SP’s allowed scopes release (and profile attributes shown in ID tokens, and what claim mappers add) goes out under its own name, NameFormat basic. With a list, only the listed claims go out, under the names the SP expects:

"attributes": [
  { "claim": "email", "name": "urn:oid:0.9.2342.19200300.100.1.3", "name_format": "uri", "friendly_name": "mail" },
  { "claim": "groups", "name": "memberOf" }
]

roles and groups (the user’s effective role and group names) are available to a list, as they are in access tokens. Arrays become one AttributeValue each; objects are sent as JSON.

IdP-initiated sign-in

Some applications expect to be launched from the IdP (an app launcher) rather than to send a request. With allow_idp_initiated on, {issuer}/saml/init?sp=<entity ID or client id> signs the user in and posts an unsolicited response (no InResponseTo) to the SP’s default consumer URL, with RelayState from the link or else default_relay_state. It is off by default: an unsolicited response is the easier kind to replay or to push a user into an application with, so leave it off unless the SP needs it.

Single Logout

Logout is front-channel only: the user’s browser carries each message.

  • The SP starts it. Its LogoutRequest (either binding; signed if the SP has a signing certificate, and it must be when require_signed_requests is on) names the NameID and SessionIndex rIDM gave it. rIDM ends that session, sends the browser through every other SAML SP that took part with a signed LogoutRequest of its own, waits for each LogoutResponse, and finally answers the SP that asked, with PartialLogout under Success if any did not confirm. OpenID Connect clients hear through back-channel logout, and front-channel ones in hidden frames on the way.
  • rIDM starts it. An RP-initiated logout (/end_session, and the sign-out confirmation page) walks the browser through the session’s SAML SPs the same way before going on to where it was going.

An SP that never answers its logout request leaves the user on its page; that is the nature of front-channel logout. Sessions ended with no browser in hand — an administrator’s revocation, a password change, a user disabled — end at rIDM, and SAML SPs keep their own sessions until those expire. There is no SOAP back-channel logout.

Signing keys

The SAML signing keys are not the JWT signing keys. SPs pin the certificate from the metadata, often by hand, so these keys never rotate on a timer. The first key (RSA, the tenant’s key size, a self-signed certificate valid for ten years) is made on first use. A rollover is three steps, in the console’s SAML page or through the API (ridm:keys:write):

  1. Start a rollover — POST /admin/tenants/{slug}/saml/keys. A pending key is listed in the metadata at once, signing nothing.
  2. When every SP has the new metadata (or certificate), activate it — POST …/saml/keys/{key}/activate. It signs from then on; the previous key stays in the metadata as retiring.
  3. Delete the retiring key — DELETE …/saml/keys/{key}.

The keys are encrypted under the master key like every other secret, and a master-key rotation rewrites them. The metadata lists every key, the active one first.

In the tenant document

SAML SPs are in the tenant document under saml_service_providers, keyed by client_id, each with the registration fields above and status; the clients section leaves them out, and a saml client there is refused. Certificates are exported, keys are not.

Keycloak as the service provider

Every build is tested against Keycloak 26 in this role (a realm brokering to rIDM through a SAML identity provider): sign-in with signed and with encrypted assertions, attribute import, Keycloak’s signed requests, and Single Logout started from either side. The settings that test uses:

Keycloak (identity provider, SAML)Value
Identity provider entity ID{issuer}
Single Sign-On service URL{issuer}/saml/sso
Single logout service URL{issuer}/saml/slo
NameID policy formatPersistent
Principal typeSubject NameID
HTTP-POST binding responseOn (rIDM answers by HTTP-POST only)
Want AuthnRequests signedOn, RSA_SHA256
Validate signaturesOn, with the certificate from rIDM’s metadata
Want assertions signedOn
Want assertions encryptedEither; rIDM encrypts when the SP’s metadata carries an encryption key and encrypt_assertion is set

Register Keycloak in rIDM from {keycloak}/realms/{realm}/broker/{alias}/endpoint/descriptor: its metadata says it signs requests, so rIDM requires that. With attributes named for their LDAP OIDs (urn:oid:0.9.2342.19200300.100.1.3 for email, urn:oid:2.5.4.42 for given_name, urn:oid:2.5.4.4 for family_name, name format URI), Keycloak’s “Attribute Importer” mappers fill in the user’s email, first and last name, and its first-broker-login flow creates the user without asking anything.

Events

A SAML sign-in raises authorization.granted, as an OpenID Connect one does; registering, changing and deleting an SP raise the client.* events. The keys raise saml_key.created and saml_key.status_changed (active, or deleted).

Limits

  • No HTTP-Artifact binding, no SOAP (back-channel logout, attribute queries), no ECP.
  • Responses go by HTTP-POST only.
  • EncryptedID in logout requests is not accepted, and rIDM publishes no encryption key.
  • SHA-1 signatures and digests are refused, and signatures must use exclusive canonicalization.
  • One consumer URL list per SP; no per-request consumer URLs outside it.

SAML identity providers (upstream)

A SAML identity provider can be an upstream of a tenant: users press “Continue with Corp SSO” on the login page, sign in at the company’s Entra ID, ADFS, Okta, Shibboleth or another SAML 2.0 IdP, and come back signed in to rIDM. rIDM is then the service provider (SP). It is an identity provider of kind saml, so everything brokering does applies unchanged: the login-page button, account linking and its link_policy, claim mappers, the account console’s linked identities, MFA, terms, consent and the risk policy after the upstream sign-in. Your applications keep speaking OpenID Connect (or SAML, to rIDM as their IdP); only rIDM talks to the upstream IdP.

rIDM implements the Web Browser SSO profile over HTTP-Redirect or HTTP-POST (requests), answers over HTTP-POST, IdP-initiated sign-in for providers that opt in, and front-channel Single Logout in both directions.

Adding a provider

In the console, Identity providers → New provider, choose SAML 2.0, give an alias and a display name, and the IdP’s metadata URL (or paste or upload the metadata document). rIDM reads the entity ID, the single sign-on and logout services, the signing certificates and the NameID formats from it; everything is then editable on the provider’s page and saves as you go.

Through the API:

POST /admin/tenants/{slug}/identity-providers/saml-metadata
{"url": "https://login.example.com/federationmetadata.xml"}

answers the provider’s settings, read from the metadata, without saving anything ({"metadata": "<xml>"} takes the document itself). Create the provider with them:

POST /admin/tenants/{slug}/identity-providers
{"alias": "corp", "kind": "saml", "display_name": "Corp SSO", "saml": { …the settings… }}

PATCH …/identity-providers/{alias} with {"saml": {…}} replaces the SAML settings as a whole (the console sends the whole object on every change). A provider cannot change between SAML and another kind; create another one. The routes need ridm:idps:read and ridm:idps:write.

What to give the identity provider

Each SAML provider gets its own SP identity in rIDM, so two IdPs never share an entity ID:

Value
Metadata{issuer}/broker/{alias}/saml/metadata
Entity IDthe metadata URL
Assertion consumer service{issuer}/broker/{alias}/saml/acs (HTTP-POST)
Single logout service{issuer}/broker/{alias}/saml/slo (HTTP-Redirect and HTTP-POST)
Certificatesin the metadata, for signing and for encryption

Most IdPs take the metadata URL. The console’s provider page shows every value with a copy button, and the admin API returns them as saml_sp (and the consumer URL as callback_url) on the provider.

rIDM signs with the tenant’s SAML key, the same one it signs with as an IdP. It never rotates on a timer. An IdP that pins rIDM’s certificate follows the rollover described there: add a pending key (the SP metadata lists it at once), let the IdP re-read the metadata, activate it. Assertions may be encrypted to any published certificate, so an IdP that has not caught up yet still works.

Settings

FieldDefaultNotes
entity_idrequiredThe IdP’s entity ID, the Issuer of its messages; unique in the tenant
sso_url, sso_bindingrequired, redirectWhere AuthnRequests go, and how (redirect or post)
slo_url, slo_bindingnone, redirectThe IdP’s logout service; without one, sign-out stays local to rIDM
signing_certificatesrequiredPEM or base64 certificates the IdP signs with, one to ten (two during its key rollover). Nothing unsigned is ever accepted
name_id_formatnoneAsked for in NameIDPolicy: persistent, email, transient, unspecified, or none to leave it to the IdP. Import picks persistent when the IdP offers it
sign_requeststrueSign AuthnRequests. Logout messages are always signed
want_assertions_signedtrueThe assertion itself must carry a signature; a signed Response around an unsigned assertion is refused
require_encrypted_assertionsfalseRefuse plaintext assertions
force_authnfalseAsk the IdP to authenticate the user again every time (ForceAuthn)
authn_context_class_refs[]Authentication context classes to ask for (Comparison exact), e.g. https://refeds.org/profile/mfa
allow_unsolicitedfalseAccept IdP-initiated sign-in (below)
unsolicited_client_idnoneWhere an IdP-initiated sign-in lands: this client’s initiate_login_uri; the account console when unset
metadata_urlnoneRe-read daily (below)

The provider also carries the usual link_policy, trust_email and mappers. SAML has no “email verified” attribute, so trust_email is what lets verified_email linking work with most IdPs. Set it only for an IdP that really does vouch for its addresses, such as your own corporate directory.

What rIDM checks

A response is accepted only when all of the following hold:

  • it is signed by one of the registered certificates. The key never comes from the message’s KeyInfo. The signature covers the assertion that is read: either the Response around it, or the assertion itself (required when want_assertions_signed). The shapes behind signature wrapping are refused (two assertions, a reference to another element, a duplicated ID), and so are SHA-1 and comments in the canonicalization;
  • the assertion’s Issuer is the IdP’s entity ID, its audience is this provider’s SP entity ID, and its bearer confirmation names this assertion consumer service;
  • it answers the AuthnRequest rIDM sent (InResponseTo), within its validity window, allowing three minutes of clock skew. The Response itself may be at most ten minutes old;
  • its assertion has not been seen before. Assertion IDs are remembered for as long as the assertion could be presented;
  • an encrypted assertion decrypts with one of the tenant’s SAML keys (AES-GCM or AES-CBC, RSA-OAEP).

A failure Status from the IdP (the user cancelled, NoPassive, …) brings the user back to the login page with broker_error=denied or upstream. The reason for any refusal is logged, not shown.

The response arrives as a cross-site POST, which carries no SameSite=Lax cookie. rIDM therefore checks it at once, parks the proven identity for five minutes, and continues at a same-site …/saml/acs?continue= link, where the session and trusted-device cookies are sent. That link works only in the browser that started the sign-in: a cookie set at /broker/{alias}/start binds them, so a link handed to someone else signs nobody in. OpenID Connect and OAuth 2.0 providers are bound the same way (see Identity brokering).

Who the user is

The subject is the NameID, unless the subject mapper names an attribute. A transient NameID changes on every sign-in and cannot identify anyone: with one, map the subject to a stable attribute (an employee number, eduPersonUniqueId), or rIDM refuses the sign-in.

Attributes are available to the mappers under their Name, and under their FriendlyName too. One value is a string, several are an array. The well-known names also count as the claims the broker reads by default, so most IdPs need no mappers:

ClaimAttributes that count
emailmail, emailAddress, urn:oid:0.9.2342.19200300.100.1.3, urn:oid:1.2.840.113549.1.9.1, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress; else an emailAddress NameID
preferred_usernameuid, username, urn:oid:0.9.2342.19200300.100.1.1, eduPersonPrincipalName, urn:oid:1.3.6.1.4.1.5923.1.1.1.6, http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn
given_namegivenName, urn:oid:2.5.4.42, …/claims/givenname
family_namesn, surname, urn:oid:2.5.4.4, …/claims/surname
namedisplayName, cn, urn:oid:2.16.840.1.113730.3.1.241, urn:oid:2.5.4.3, http://schemas.microsoft.com/identity/claims/displayname

The NameID itself is nameid (and its format nameid_format), for a mapper that wants it.

IdP-initiated sign-in

Responses rIDM did not ask for are refused unless allow_unsolicited is on. It is off by default for a reason: anyone able to post a response to the consumer URL, such as a page the victim visits, could sign the victim in to the attacker’s account. A response that answers some request (it carries InResponseTo) is never accepted as unsolicited.

When it is on, an unsolicited response runs through the same checks, link policy and risk scoring as a solicited one, then lands on the initiate_login_uri of unsolicited_client_id (with iss, as in OpenID Connect third-party-initiated login) or on the account console. The session is set. Whatever it still owes (a second factor, terms) is asked when an application resumes it at /authorize.

Single Logout

rIDM keeps the NameID and SessionIndex of every session brokered through a SAML IdP.

  • The IdP signs the user out (a LogoutRequest at …/saml/slo): it must be signed with a registered certificate (forged logout requests are refused), name rIDM’s logout URL and be fresh and new. rIDM ends every session of that NameID through this IdP (only those with a named SessionIndex, when it names any), takes the browser through the SAML applications of those sessions (as when an SP signs out, see SAML identity provider), and answers with a signed LogoutResponse. OpenID Connect applications hear of it by back-channel logout.

  • The user signs out at rIDM (an application’s RP-initiated logout, or the logout page): once the session’s own SAML applications have had their turn, rIDM sends the IdP a signed LogoutRequest for the session, and the IdP’s answer takes the browser on to where the sign-out was going.

  • A downstream SAML application signs the user out (a LogoutRequest at rIDM’s IdP endpoint): after the session’s other SAML applications, the browser goes through the upstream IdP too, and only then does the application get its LogoutResponse.

All of these are front-channel: they need the user’s browser. A session that ends without one (expiry, an administrator revoking it, a password change) does not reach the IdP; there is no SOAP back channel.

Keeping the metadata current

With a metadata_url, rIDM re-reads the IdP’s metadata every day (the saml_metadata_refresh job, hourly, picks up providers last read more than a day ago) and takes the endpoints and signing certificates from it, so the IdP’s own key rollover needs nobody on rIDM’s side. The entity ID in the metadata must stay the same. A document naming another entity is refused. Refresh now on the provider’s page, or POST /admin/tenants/{slug}/identity-providers/{alias}/saml/refresh, does it at once.

A failed refresh keeps the settings as they were and shows the error on the provider (saml.metadata_error) until one succeeds; failed providers are retried every hour. The metadata is fetched like every upstream endpoint: https (plain http for loopback only), public addresses only, no redirects, at most 256 KiB.

Keycloak as the identity provider

Every build is tested against Keycloak 26 in this role: sign-in, tampered and replayed responses refused, Single Logout started from either side, and a Keycloak key rollover picked up by a metadata refresh. To set it up:

  1. Add the provider in rIDM from {keycloak}/realms/{realm}/protocol/saml/descriptor. rIDM asks for a persistent NameID.
  2. In Keycloak, import rIDM’s SP metadata ({issuer}/broker/{alias}/saml/metadata) as a new SAML client. Keycloak picks up rIDM’s certificate for signed requests and for encryption, so it signs the response and encrypts the assertion (AES-256-GCM).
  3. On that client, set the Name ID format to persistent and turn on Force name ID format, so the NameID rIDM links the account by is an opaque identifier that survives a rename in Keycloak.
  4. Add User Property mappers for email, firstName (as givenName) and lastName (as sn). rIDM reads these names without any mappers of its own.

Signing out of Keycloak (its own logout page) reaches rIDM through the browser as a signed LogoutRequest and ends the rIDM session; signing out of rIDM sends Keycloak one.

Monitoring

MetricLabelsMeaning
ridm_saml_sp_responses_totaloutcome: accepted, status, invalid, replay, no_request, no_subjectResponses at the assertion consumer services
ridm_saml_metadata_refresh_totaloutcome: changed, unchanged, failedMetadata refreshes

Events: the brokering ones (login.brokered, identity.linked, …), logout.upstream when an IdP’s LogoutRequest ended sessions, and identity_provider.updated when a refresh changed the settings.

LDAP and Active Directory

An LDAP directory (Active Directory, OpenLDAP, 389 Directory Server, FreeIPA, …) can be the home of a tenant’s users. They sign in on rIDM’s login page with their directory username or email and their directory password; rIDM checks the password by binding to the directory as them, so it never holds a copy. The directory is an identity provider of kind ldap, which gives it account linking and its link_policy, mappers, and the linked identities on a user’s page. It gets no button on the login page: directory users use the ordinary password form.

Adding a directory

In the console, go to Identity providers → New provider and choose LDAP / Active Directory. Pick the server type, then give the URL, the base DN users are found under, and a service account (its bind DN and password). The provider page then has every setting, saved as you go. Run Test connection to connect, bind as the service account and list a few users and groups.

Through the API:

POST /admin/tenants/{slug}/identity-providers
{
  "alias": "corp",
  "kind": "ldap",
  "display_name": "Corp directory",
  "ldap": {
    "vendor": "active_directory",
    "url": "ldaps://dc1.corp.example",
    "bind_dn": "CN=ridm,OU=Service Accounts,DC=corp,DC=example",
    "bind_password": "…",
    "users_dn": "OU=Staff,DC=corp,DC=example",
    "groups_dn": "OU=Groups,DC=corp,DC=example"
  }
}

PATCH …/identity-providers/{alias} with {"ldap": {…}} replaces the directory settings as a whole. Leave bind_password out to keep the stored one, or send "" to clear it. The password is encrypted under the master key and never returned: the provider shows ldap.bind_password_set. POST …/{alias}/ldap/test runs the connection test.

SettingDefaultMeaning
vendorotheractive_directory, openldap or other. It picks the defaults below and how passwords are written
url—ldaps://host[:636], or ldap://host[:389] with starttls. Plain LDAP is refused unless the host is loopback
starttlsoffUpgrade an ldap:// connection to TLS before anything is sent
ca_certificatenonePEM certificate(s) the directory’s certificate must chain to (an internal CA). None trusts the platform’s roots
bind_dn, bind_passwordnoneThe service account that searches (and, when writable, writes). Without one, searches are anonymous
users_dn, search_scope—, subtreeWhere users are searched (one for direct children only)
user_object_filterAD: (&(objectCategory=person)(objectClass=user)), else (objectClass=inetOrgPerson)Which entries are users
username_attributeAD: sAMAccountName, else uidA new account’s username
login_attributesthe username attribute and mailWhat a typed identifier is matched against (up to five)
uuid_attributeAD: objectGUID, else entryUUIDThe attribute that never changes for an entry. The link between entry and account
edit_moderead_onlySee Write-back
sync_interval_minutes60Incremental sync every N minutes (5–10080); 0 turns the periodic sync off
full_sync_interval_hours24A full sync every N hours (1–720)
groups_dnnoneWhere groups are searched. None turns group sync off
group_object_filterAD: (objectClass=group), else (objectClass=groupOfNames)Which entries are groups
group_name_attributecnA synced group’s name
group_membership, group_member_attributedn, memberWhether members are DNs (groupOfNames, AD) or usernames (username, memberUid for posixGroup)
group_parent_idnoneThe rIDM group synced groups are created under
timeout_secs10For connecting and for each operation (1–60)

The mappers name directory attributes. The username comes from the username attribute and the email from mail, unless mappers.username or mappers.email names another. mappers.attributes maps profile attributes to directory attributes, e.g. {"given_name": "givenName"}. A directory says nothing about whether an address is verified, so addresses count as verified only with trust_email on. That is also what linking an existing account by email (link_policy: verified_email) needs.

Reaching the directory

rIDM connects from the server. Tenant administrators choose the URL, so the connection follows the same outbound rules as webhooks and upstream endpoints: the host must resolve to a public address. A directory on a private network, which is where directories live, needs the operator to open that network with OUTBOUND_ALLOW_NETWORKS (for example 10.20.0.0/16). The connection goes to the address that check vetted, and TLS verifies the certificate against the host name in the URL.

Signing in

A directory user keeps no local password. When they sign in, rIDM finds their entry by its uuid attribute and binds as it with the password typed. A password changed, or an account disabled, in the directory therefore counts at the next sign-in. Wrong passwords count toward the tenant’s lockout like any other.

An identifier no rIDM account has is looked up in the tenant’s enabled directories, in provider sort_order. The first directory with exactly one matching entry decides:

  • A bind with the typed password imports the user, following the provider’s link policy, as a brokered first sign-in does.
  • A wrong password fails the sign-in, and no other directory is asked.
  • An identifier matching several entries is refused.

Local accounts are never checked against a directory.

Every successful bind refreshes the user’s email, username, mapped attributes and groups. The session then continues like any password sign-in: second step, terms, consent, the risk policy.

A directory that cannot be reached answers 503, and sign-in is never allowed to fall back to anything local. A disabled provider signs its users in no more.

Sync

The ldap_sync job wakes every five minutes and syncs each enabled directory whose interval has passed. Sync now and Full sync on the provider page, or POST …/{alias}/ldap/sync with {"full": true}, run one at once. One sync per directory runs at a time; a second one answers 409.

  • An incremental sync reads the entries modified since the last one (modifyTimestamp, or whenChanged on AD). It imports new users, updates changed ones, and disables or re-enables users whose AD account was disabled or enabled.
  • A full sync reads every entry. It also disables the users whose entries are gone (deleted, moved out of the base, or no longer matching the filter), and enables them again when they come back. A user an administrator disabled stays disabled. A full sync that reads no entries at all disables nobody, because a wrong base DN must not lock everyone out.

Deleting a directory user in rIDM is undone by the next sync. Remove them from the directory, or from the filter, instead.

Groups. With groups_dn set, every directory group becomes an rIDM group that the directory owns: the sync creates, renames, fills and deletes it. Its members are kept in step for directory users only, so members added in rIDM who are not directory users stay. A group an administrator made with the same name is never taken over; the synced one is called {name} ({alias}). Large AD groups, served in member ranges, are read in full. Roles given to a synced group apply to its members like any group’s.

The outcome of the last sync is on the provider: last_sync_at, last_full_sync_at, last_sync_error and last_sync_stats (read, created, updated, disabled, enabled, skipped, group and membership changes). Each sync also emits directory.synced.

Write-back

edit_mode decides whether rIDM writes to the directory.

  • read_only (the default): the directory is the only source. rIDM refuses to change a directory user’s password (change, reset, temporary password), email or mapped attributes. The message names the directory, where those change. Other fields, such as locale and roles, stay editable.
  • writable: rIDM writes the directory first, as the service account, then stores what it keeps itself.
    • Passwords are written with the Password Modify operation (RFC 3062), or as unicodePwd on Active Directory, which accepts that only over TLS. The tenant’s password policy applies first, and the directory’s own policy may still refuse (shown as a validation error).
    • A temporary password is written to the directory and flagged locally, so the user changes it at the next sign-in.
    • Email and mapped attributes are written as they change.

A writable directory needs a bind DN, and the service account needs write access to those attributes.

The username always comes from the directory, in both modes.

Removing a directory

Deleting the provider removes its links. The users keep their accounts but have no password until one is set (an administrator’s reset, or the user’s own recovery). The groups it synced stay as ordinary groups.

Metrics and events

MetricLabelsCounts
ridm_ldap_syncs_totaloutcome: success, failureSync passes
ridm_ldap_sync_seconds—Their duration

Events: directory.synced, identity.linked for each user imported, user.updated when the sync changes, disables or enables a user, and the group.* events of synced groups.

Kerberos desktop sign-in

On a Windows domain, or a Linux or macOS desktop signed in to a Kerberos realm, users already proved who they are when they logged on. A Kerberos provider lets the browser hand that proof to rIDM’s login page (HTTP Negotiate, “SPNEGO”). The user is signed in without typing anything, or with one click. The realm is an identity provider of kind kerberos. rIDM validates the ticket itself, against the keytab of its service principal. It never talks to the KDC, and it needs no system Kerberos library.

The released image and binaries include Kerberos. A build of your own needs the kerberos cargo feature (cargo build -p ridm-api --features embedded-ui,kerberos). Without it a Kerberos provider can be configured, which keeps tenant documents portable, but nobody can sign in with it; the provider page says so.

The service principal

Browsers ask the KDC for a ticket to HTTP/<host>, where <host> is the name in the address bar. That is rIDM’s public host, or the tenant’s custom domain. That principal needs a key, and rIDM needs the key in a keytab file.

  • Use the host’s own name. Browsers resolve a CNAME to its target before building the principal name. Give rIDM an A/AAAA record, or register the principal for the name the CNAME points to (or turn the lookup off with Chrome’s DisableAuthNegotiateCnameLookup policy). An address typed as an IP never gets Kerberos. Use a fully qualified name: MIT Kerberos clients (1.20+) append the resolver’s search domain to a name with no dots, and ask for a principal you did not register.
  • AES keys only. rIDM accepts tickets of the AES types: aes256-cts-hmac-sha1-96 and aes128-cts-hmac-sha1-96 (RFC 3962, what Active Directory issues), and aes256-cts-hmac-sha384-192 and aes128-cts-hmac-sha256-128 (RFC 8009, MIT’s and RHEL IdM’s default). RC4 and DES are refused as broken. Prefer the RFC 8009 types where your KDC has them; a KDC in FIPS mode may allow only those.

Active Directory. Create a service account (a user with a strong random password that never expires), allow AES on it, register the principal and export the keytab:

Set-ADUser svc-ridm -KerberosEncryptionType AES128,AES256
setspn -S HTTP/sso.corp.example CORP\svc-ridm
ktpass -princ HTTP/sso.corp.example@CORP.EXAMPLE -mapuser CORP\svc-ridm `
  -crypto AES256-SHA1 -ptype KRB5_NT_PRINCIPAL -pass * -out ridm.keytab

ktpass sets the account’s password (and so its key). Changing the password afterwards invalidates the keytab, so export a new one each time.

MIT Kerberos.

kadmin -q "addprinc -randkey -e aes256-cts-hmac-sha384-192:normal,aes256-cts-hmac-sha1-96:normal HTTP/sso.corp.example"
kadmin -q "ktadd -k ridm.keytab HTTP/sso.corp.example"

Keep the aes256-cts-hmac-sha1-96 key too if some clients’ Kerberos libraries predate RFC 8009 (MIT before 1.15, for example).

Adding the provider

In the console, go to Identity providers → New provider and choose Kerberos / SPNEGO. Pick the keytab file. rIDM reads it and shows the services it holds AES keys for, and the service principal comes from it. Optionally, add the networks where the login page should try Kerberos on its own. The provider page then has every setting, saved as you go. The keytab has its own Replace keytab control; a new key version (after a password change) is a new keytab.

Through the API, the keytab is base64:

POST /admin/tenants/{slug}/identity-providers
{
  "alias": "windows",
  "kind": "kerberos",
  "display_name": "Windows sign-in",
  "kerberos": {
    "keytab": "BQIAAABhAAIAD0NPUlAuRVhBTVBMRS…",
    "trusted_networks": ["10.0.0.0/8"]
  }
}

POST …/identity-providers/kerberos-keytab with {"keytab": "…"} reads a keytab without storing it: its entries (principal, key version, encryption type, never a key) and the services it can be used for. PATCH …/identity-providers/{alias} with {"kerberos": {…}} replaces the settings as a whole. Leave keytab out to keep the stored one, or send "" to remove it. The keytab is encrypted under the master key and never returned: the provider shows kerberos.keytab_set and kerberos.keytab_entries.

SettingDefaultMeaning
keytab—The service’s keytab file, base64 (write-only)
service_principalfrom the keytabHTTP/<host>@<REALM>. Required when the keytab holds several services. The keytab must have an AES key for it. One provider per service principal in a tenant
realmsthe service’s realmThe client realms whose users may sign in (up to 20). Add a trusted realm only if its user names cannot collide with this realm’s (see Accounts)
name_formlocal_partHow a principal names a user: local_part (alice) or principal (alice@CORP.EXAMPLE)
ldap_idp_idnoneAn LDAP provider that owns these users. See Accounts
ldap_attributeby vendor and name formThe directory attribute holding the name. AD: sAMAccountName (local part) or userPrincipalName (principal); other directories: uid or krbPrincipalName
match_usernameonWithout a directory: sign in the local account whose username is the name
create_usersoffWithout a directory: create an account, named by the principal, for one no account matches
trusted_networksnoneCIDRs (or addresses) from which the login page tries Kerberos on its own (up to 100)
max_skew_seconds300Allowed difference between a client’s clock and rIDM’s (30–900)

hidden removes the login page’s button; automatic sign-in from the trusted networks goes on. enabled: false turns the provider off entirely.

Browsers

A browser answers a Negotiate challenge only for servers its policy trusts:

  • Chrome and Edge on Windows trust the Local intranet zone. Add rIDM’s host to the zone (Group Policy: Site to Zone Assignment List), or to the AuthServerAllowlist policy. That policy is also the only way on macOS and Linux.
  • Firefox: network.negotiate-auth.trusted-uris, set to sso.corp.example or a domain such as .corp.example.
  • Safari on macOS uses the system’s tickets for any host in a realm it knows.

A browser outside that policy, or without a ticket, simply does not answer. Chromium-based browsers may then offer NTLM instead, which rIDM does not accept.

Signing in

The login page posts to its flow’s Kerberos step (POST /t/{slug}/flows/{id}/kerberos).

  • On its own: when the login page opens from one of the provider’s trusted_networks, it asks at once. Unless the client asked for a fresh sign-in (prompt=login or max_age=0, typically to switch accounts), rIDM answers 401 with WWW-Authenticate: Negotiate. A browser with a ticket sends it back and is signed in. One without a ticket gives up quietly, and the page shows its usual form. Elsewhere rIDM answers 204 and nothing happens.
  • The button: “Continue with display name” always asks. When nothing comes back, the page says that the computer offered no Kerberos ticket.

The trusted networks decide when to ask, not who may sign in: a valid ticket signs its user in from anywhere. Behind a reverse proxy, set TRUSTED_PROXIES so rIDM sees the client’s address. Proxies must pass the Authorization and WWW-Authenticate headers through. Kerberos completes in one round trip, so no connection affinity is needed.

rIDM accepts a ticket when:

  • it is for the provider’s service principal and decrypts under the keytab;
  • it is valid now, within the allowed clock skew, and not marked invalid;
  • its client comes from one of the provider’s realms;
  • the authenticator decrypts under the ticket’s session key, names the same client, and was made within the skew of rIDM’s clock;
  • the authenticator has not been seen before. Accepted authenticators are remembered in Valkey for twice the skew, so a captured Negotiate header signs nobody in a second time.

When the browser asks for mutual authentication (browsers do), the successful answer carries rIDM’s own token in WWW-Authenticate, proving that the server holds the service key. User-to-user tickets, NTLM and NegoEx are refused.

A Kerberos sign-in is a first factor. The tenant’s MFA policy, adaptive authentication, terms and consent follow as for a password. The session’s amr is ["kerberos"], and a SAML assertion states the Kerberos authentication context class.

Accounts

A ticket names a principal and nothing else: no email and no attributes. The name (alice, or alice@CORP.EXAMPLE with name_form: principal) finds the account.

With a directory (ldap_idp_id), the name is looked up in that LDAP provider by ldap_attribute, as its service account. One entry imports or refreshes the user exactly as a password sign-in through the directory does, with email, mapped attributes, groups and the directory’s link policy. The directory decides: a name it does not have signs nobody in, and an account disabled in Active Directory is refused. This is the usual set-up for Active Directory, where the same directory is both KDC and LDAP server.

Without a directory:

  1. a principal linked to an account before signs that account in, even after a rename;
  2. with match_username, the local account whose username is the name is linked and signed in;
  3. with create_users, a new account is created (username from the name, no email, no password) and linked;
  4. otherwise nobody is signed in.

Linked principals appear among the user’s linked identities. With name_form: local_part, alice@CORP.EXAMPLE and alice@PARTNER.EXAMPLE are the same name: accept a second realm only when that is what you mean, or use principal.

Deleting a directory the provider names leaves the provider matching local accounts.

Errors

The Kerberos step answers 403 with one of these error codes when a token came back but signed nobody in:

errorMeaning
kerberos_ntlmThe browser offered NTLM: it had no ticket for this host (principal not registered, a CNAME, a machine off the domain)
kerberos_unsupportedNo Kerberos token, or Kerberos was not the browser’s first choice
kerberos_invalidThe ticket was refused. The server log says why: another service or key version, expired, clock skew, a realm not accepted, an encryption type rIDM does not support
kerberos_replayThe authenticator was used before
kerberos_no_accountNo account matches the principal
account_disabledThe account is disabled or locked

Configuration as code

Kerberos providers are part of the tenant configuration document without their keytab: a new provider created by an import reports that its secret is missing, and the keytab is uploaded afterwards. The directory is named by its alias (ldap_provider), so the document means the same in another tenant.

Metrics and events

MetricLabelsCounts
ridm_kerberos_negotiations_totaloutcome: success, ntlm, unsupported, invalid, replay, no_account, disabledNegotiate tokens received

Events: login.succeeded (method kerberos), login.brokered, identity.linked for a principal linked on first sign-in, and those of the directory’s import when one owns the users. Refused principals are recorded as failed login attempts.

Feature flags

Feature flags are switches your applications read: a new checkout for everyone, a beta report for one customer organization, a kill switch you can throw without a deploy. rIDM stores them per tenant, and tells an application which ones are on for the person signed in. rIDM itself doesn’t act on them.

Managing flags

In the console: Feature flags (/console/features/, or g then f). It needs ridm:tenants:read to look and ridm:tenants:write to change anything. Each flag has:

FieldMeaning
name1–64 characters: lowercase letters, digits, ., _ and -, starting with a letter or digit. Up to 200 flags per tenant.
enabledThe tenant-wide value. A new flag starts off.
descriptionWhat turning it on changes, for the people who toggle it (up to 500 characters).
organizationsValues for particular organizations, by organization slug. They win over enabled for anyone signed in to that organization.

Flags live in the tenant’s settings (settings.features), so they’re also edited through the admin API and travel with configuration as code:

curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"settings": {"features": {
        "checkout.v2":  {"enabled": true, "description": "The rebuilt checkout"},
        "beta.reports": {"enabled": false, "organizations": {"globex": true}}
      }}}'

The body is a merge patch: {"beta.reports": {"organizations": {"globex": null}}} removes one override, and {"beta.reports": null} removes the flag. A bare true/false is still accepted for a flag, which is how flags were written before they had descriptions, and is stored as {"enabled": …}.

Reading flags from an application

Only flags that are on are listed, so a flag that doesn’t exist reads as off.

In tokens. Ask for the features scope, which every tenant has alongside the standard OIDC scopes. The access token and the ID token then carry the flags that were on at sign-in:

{ "sub": "…", "org_id": "…", "features": ["beta.reports", "checkout.v2"] }

They are worked out for the organization the sign-in acts in (org_id), or tenant-wide without one. A refreshed token is worked out again, so a change reaches the application at the next refresh at the latest. No claim mapper may write features.

Live. GET /t/{slug}/features with any access token the tenant issued (as Authorization: Bearer, or DPoP for a sender-constrained token) answers with the current values for that token’s organization:

{ "features": ["beta.reports", "checkout.v2"], "org_id": "…" }

A token of another tenant gets 403, and no token or an invalid one gets 401.

Email, SMS and templates

rIDM sends email and text messages for verification links, password resets, magic links, one-time codes, invitations and security notices. Each tenant chooses where its messages go, may override the wording of every message per language, and can watch the outbound queue. This page covers the delivery settings, templates, the queue and its log, and the security notices.

Everything here is per tenant and lives under /admin/tenants/{slug}/messaging in the admin API, guarded by ridm:messaging:read and ridm:messaging:write (owners and administrators hold both; viewers hold the read permission). In the admin console it is Messaging (/console/messaging/).

Where email goes

Email for a tenant is resolved in this order:

  1. The tenant’s own configuration: SMTP, or an HTTP endpoint that receives each message as JSON.
  2. The deployment’s SMTP defaults, from the SMTP_* environment variables.
  3. Nothing: email is not sent, and every queued email fails at once (see The outbound queue).

GET /admin/tenants/{slug}/messaging/email reports which one applies in source: tenant, server_default or none.

Deployment defaults

VariableDefaultMeaning
SMTP_HOSTunsetSMTP server. Unset means there are no deployment defaults
SMTP_PORT587Port
SMTP_USERNAME, SMTP_PASSWORDunsetCredentials; both must be set for authentication to be used
SMTP_FROMrequired when SMTP_HOST is setSender, e.g. rIDM <no-reply@example.com>
SMTP_SECURITYstarttlsstarttls, tls (implicit TLS, usually port 465) or none

Tenant SMTP

curl -X PUT https://id.example.com/admin/tenants/acme/messaging/email \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
        "type": "smtp",
        "host": "smtp.example.com",
        "port": 587,
        "username": "acme-mailer",
        "password": "…",
        "from": "Acme <no-reply@acme.example>",
        "security": "starttls"
      }'

security defaults to starttls. host, port and from are required, and from must parse as a mailbox. The password is stored encrypted under the master key and is never returned: reads show password_set: true instead. A PUT that omits the password, or sends it empty, keeps the stored one, so a form can be saved without re-entering it.

A tenant’s SMTP host is chosen by a tenant administrator, so it is held to the outbound request policy: a private IP literal is refused when saved (400), and a host name is resolved just before each connection to public addresses only. rIDM connects to the first allowed address and TLS still verifies the certificate against the configured host name. The deployment’s own SMTP_HOST is the operator’s choice and is not filtered.

Tenant HTTP endpoint

For a mail service with an HTTP API, or a relay of your own, rIDM can POST every message as JSON:

{ "type": "http", "url": "https://mail-relay.example.com/send",
  "auth_header": "Bearer …", "from": "no-reply@acme.example" }

The URL must be https; plain http is accepted only for localhost, 127.0.0.1 and [::1]. Requests follow the outbound request policy: public addresses only, no redirects, no proxy from the environment. auth_header, when set, is sent verbatim as the Authorization header and, like the SMTP password, is write-only (auth_header_set on read; omitted on PUT keeps the stored value). Each message arrives as:

{
  "from": "no-reply@acme.example",
  "to": ["alice@example.com"],
  "subject": "Sign in to Acme",
  "text": "Hi alice, …",
  "html": "<p>Hi alice, …</p>",
  "reply_to": null,
  "headers": []
}

A 2xx answer means sent. A 4xx is a permanent rejection and the message is dead-lettered; a 5xx, a timeout (15 seconds) or a connection failure is retried.

DELETE /admin/tenants/{slug}/messaging/email removes the tenant configuration, which falls back to the deployment defaults.

SMS

SMS always goes through an HTTP gateway of your choosing; there is no built-in carrier integration, and no deployment-wide default.

curl -X PUT https://id.example.com/admin/tenants/acme/messaging/sms \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://sms-gateway.example.com/send", "auth_header": "Bearer …", "from": "Acme"}'

The same URL rules and write-only auth_header apply. Each text is posted as {"to": "+15551234567", "body": "…", "from": "Acme"} (from is null when unset), with the same status handling as the email endpoint. Phone numbers are E.164. GET answers {"configured": false, …} when nothing is set.

Test sends

POST /admin/tenants/{slug}/messaging/email/test and …/sms/test with {"to": "alice@example.com"} (or a phone number) send a fixed test message straight through the configured sender, bypassing the queue, so a misconfiguration comes back as an error in the response (503 with the sender’s reason) instead of a dead message in the log. The answer names the backend that took it: {"sender": "smtp", "to": "…"}. The console has a test button beside each form.

Templates

rIDM sends messages for ten events:

EventSent whenEmailSMS
verify_emailself-registration needs the address confirmedyes
password_reseta user asks for a reset linkyes
magic_linkpasswordless sign-in by linkyesyes
otpa one-time code (sign-in, second factor, contact change)yesyes
invitationan administrator invites someoneyes
new_devicesecurity notice: sign-in from a new browseryesyes
password_changedsecurity noticeyes
mfa_changedsecurity noticeyes
email_changedsecurity notice, sent to the previous addressyes
backchannel_requestan application asks, over the back channel, to sign the user in (CIBA); sent whatever settings.notifications saysyesyes

Every email event has a built-in English template; the four marked for SMS have built-in SMS templates too. An event with no template for the channel cannot be sent on that channel.

Overrides and language fallback

A tenant override is stored per channel, event and locale:

# The template an editor starts from: the override, or the built-in text
curl https://id.example.com/admin/tenants/acme/messaging/templates/email/magic_link/de \
  -H "Authorization: Bearer $TOKEN"

curl -X PUT https://id.example.com/admin/tenants/acme/messaging/templates/email/magic_link/de \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
        "subject": "Bei {{tenant.display_name}} anmelden",
        "body_text": "Hallo {{user.username}},\n\n{{link}}\n\nGültig für {{expires_minutes}} Minuten.",
        "body_html": "<p>Hallo {{user.username}},</p><p><a href=\"{{link}}\">Anmelden</a></p>"
      }'

Email templates need subject and body_text; body_html is optional and, when present, the message is sent as multipart/alternative. SMS templates take body_text only. The locale is a BCP 47 tag such as de or pt-BR. DELETE on the same path removes the override; GET …/messaging/templates lists every event, the channels and all stored overrides.

When a message is sent, rIDM picks the user’s locale (the flow’s negotiated locale, or the locale stored on the user) and looks for an override in this order:

  1. the exact tag, lower-cased (de-ch);
  2. its language (de);
  3. the tenant’s default locale (settings.locale.default);
  4. en;
  5. the built-in English template.

So a single de override serves de-DE, de-AT and de-CH users.

Syntax and variables

Templates are Handlebars. Values are HTML-escaped in body_html only; the subject and body_text are rendered as plain text. A template is validated by rendering it with sample data when it is saved, so a syntax error is refused with 400.

VariableAvailable in
tenant.display_name, tenant.slugevery event
user.usernameevery event except invitation
linkverify_email, password_reset, magic_link, invitation, backchannel_request (the account console’s approvals page)
codeotp
expires_minutesverify_email, password_reset, magic_link, otp, backchannel_request
expires_days, inviterinvitation
whenthe four security notices and backchannel_request (YYYY-MM-DD HH:MM UTC)
ip, user_agentnew_device
changemfa_changed (for example “TOTP enrolled”)
new_emailemail_changed
client_name, binding_messagebackchannel_request (binding_message is empty when the application sent none)

Preview

POST /admin/tenants/{slug}/messaging/templates/preview renders a template without sending anything:

{ "channel": "email", "event": "otp", "locale": "de",
  "draft": { "subject": "Ihr Code", "body_text": "Code: {{code}}" },
  "vars": { "code": "000000" } }

Without draft it renders what would be sent for that locale; vars is merged over the sample data, and the answer returns the variables it used. The sample data has exactly the variables of the table above for each event, from the same definition the real sends use, so a template that previews cleanly renders the same variables when it is sent. The console’s editor calls this on every change and shows the text, the HTML rendering and the variables.

The outbound queue

Every message is rendered, written to the outbound_messages table as queued, and delivered immediately in the request that caused it. Anything that fails waits for the message_delivery job, which runs every 30 seconds on one node at a time and sends whatever is due.

StatusMeaning
queuedwaiting for its first attempt or its next retry
sendingclaimed by a delivery pass; a message stuck here for 10 minutes (a node died mid-send) is queued again
sentthe provider accepted it
deadgave up; kept for inspection and redelivery

A message gets six attempts. After each failure it waits 1 minute, then 5 minutes, 30 minutes, 2 hours and 6 hours (12 hours for any attempt beyond). A permanent failure dies at once without using its remaining attempts: an SMTP permanent (5xx) reply, a 4xx from an HTTP endpoint, or no sender configured for the channel. Transient failures (timeouts, connection errors, 5xx from an HTTP endpoint, SMTP temporary replies) are retried.

Because a missing sender is permanent, a tenant that has neither its own settings nor deployment defaults dead-letters every email the moment it is queued. Configure a sender, then redeliver.

The Prometheus gauge ridm_messages_queued reports the backlog across all tenants. Sent and dead messages are deleted by the hourly cleanup job once they are older than RETENTION_DAYS (default 30).

The delivery log

GET /admin/tenants/{slug}/messaging/log?status=dead&limit=100 lists recent messages, newest first: channel, event, recipient, subject, status, attempts, next attempt, last error and timestamps. limit defaults to 100 and is capped at 500; status is queued, sending, sent or dead. Message bodies are deliberately left out: they carry sign-in links and codes that must not be readable after the fact.

POST /admin/tenants/{slug}/messaging/log/{message}/redeliver puts a dead message back in the queue with its attempts reset (202); the next delivery pass sends it. Only dead messages can be redelivered. Remember that a redelivered link or code may have expired in the meantime.

Security notices

Users are told about security-relevant changes to their account, in their own locale, through the tenant’s messaging settings:

Setting (settings.notifications)DefaultSent when
new_devicetruea sign-in from a browser the user has not used before
password_changedtruethe password changes through recovery, a forced change, the account console, or an administrator’s reset with notify set (never the initial password)
mfa_changedtruea second factor is added or removed, or a recovery code is used
email_changedtruethe email address changes; sent to the previous address, which is the one that can still object

A notice goes to the account’s email address. An account with no email but a verified phone gets the new_device notice (and a backchannel_request) by SMS; the other notices have no SMS template and are skipped. Sending a notice never fails the action that triggered it: a failure is logged and the notice is dropped. Switch notices off in the console under Settings → Locale & notices, or with a merge patch:

curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"notifications": {"new_device": false}}}'

Development with Mailpit

The dev profile of the bundled compose file starts Mailpit, which accepts all mail and shows it in a web UI, and points the API’s deployment defaults at it (SMTP_HOST=mailpit, SMTP_PORT=1025, SMTP_SECURITY=none):

docker compose -f deploy/docker-compose.yml --profile dev up -d
# Mailpit web UI: http://localhost:8025   (RIDM_MAILPIT_UI_PORT)
# Mailpit SMTP:   localhost:1025          (RIDM_MAILPIT_SMTP_PORT), for an API run outside compose

An API started with cargo run can use the same Mailpit by setting SMTP_HOST=localhost, SMTP_PORT=1025, SMTP_SECURITY=none and an SMTP_FROM. See docker-compose.

Caveats

  • Provider settings are cached in each node’s memory for up to 60 seconds; a change is evicted everywhere at once, but a node that misses the eviction catches up within a minute.
  • Delivery settings (SMTP, SMS, CAPTCHA) are credentials, not configuration, and are not part of the tenant configuration document. Template overrides are.
  • SMTP_SECURITY=none sends credentials and mail in clear text. Use it only against a local catcher.

SCIM provisioning

Every tenant exposes a SCIM 2.0 server (RFC 7643 and RFC 7644), so an HR system or a workforce identity provider can create, update, disable and remove the tenant’s users and groups on its own schedule. rIDM is the SCIM service provider: the other system pushes, rIDM stores. rIDM does not push to other SCIM servers.

Endpoints

The base URL is {PUBLIC_URL}/scim/v2/{slug}, for example https://id.example.com/scim/v2/acme. It is not under /t/{slug} and has no custom-domain form.

PathMethods
/ServiceProviderConfigGET
/ResourceTypesGET
/SchemasGET
/UsersGET (list, filter), POST
/Users/{id}GET, PUT, PATCH, DELETE
/GroupsGET (list, filter), POST
/Groups/{id}GET, PUT, PATCH, DELETE

Requests and responses use application/scim+json. Errors are SCIM error documents (urn:ietf:params:scim:api:messages:2.0:Error) with status, detail and, where it applies, scimType: invalidFilter, invalidSyntax, invalidValue, invalidPath, noTarget, uniqueness (a 409, for example a username or externalId already taken) or tooMany. POST answers 201 with a Location header; DELETE answers 204.

ServiceProviderConfig advertises what is supported: PATCH and filtering (up to 200 results per page) yes; bulk operations, sorting, ETags and the password-change operation no.

Provisioning tokens

A provisioning system authenticates with a bearer token minted for one tenant. Tokens start with rscim_, carry 256 random bits, are shown once, stored only as a SHA-256 hash, may expire, and can be revoked. A token only works against its own tenant’s base URL; any other tenant answers 401.

In the console: Provisioning (/console/provisioning/), which also shows the base URL to paste into the other system. Through the admin API, under ridm:scim:read and ridm:scim:write (held by owners, administrators and user managers):

# Mint a token (expires_in_days is optional, 1..3650; omit it for no expiry)
curl -X POST https://id.example.com/admin/tenants/acme/scim/tokens \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "Okta", "expires_in_days": 365}'
{
  "id": "0192f5c4-…",
  "tenant_id": "0192f0a1-…",
  "name": "Okta",
  "expires_at": "2027-09-18T09:12:44Z",
  "last_used_at": null,
  "revoked_at": null,
  "created_at": "2026-09-18T09:12:44Z",
  "token": "rscim_Qm9i…"
}

GET /admin/tenants/{slug}/scim/tokens lists the tokens (never their values) with the base_url: every live one (a tenant may have 100), then the most recent revoked ones, 200 rows at most; DELETE /admin/tenants/{slug}/scim/tokens/{token} revokes one at once. last_used_at is updated at most once a minute per token. Creating and revoking a token are audited as scim_token.created and scim_token.revoked, and every change made with a token is recorded in the audit log with actor type client and the token’s id as actor, so provisioning changes can be told apart from administrators’ changes.

Revoked and expired tokens are deleted by the hourly cleanup job after RETENTION_DAYS.

How a SCIM User maps onto a user

SCIM attributerIDM user
idthe user’s id (read-only)
userName (required)username
externalIdexternal_id, unique per tenant
emailsemail: the entry marked primary, else the first; one address is kept
phoneNumbersphone: the primary entry, else the first
activetrue = active, false = disabled (default true)
localelocale
name.givenNameprofile attribute given_name
name.familyNameprofile attribute family_name
displayNameprofile attribute display_name
groupsdirect group memberships, read-only
metacreated, lastModified, location

The three name attributes are stored only when the tenant’s profile schema declares given_name, family_name and display_name, or allows undeclared attributes; otherwise they are dropped silently. name.formatted is produced on read and ignored on write. Other profile attributes are left alone by every SCIM write. SCIM writes count as an import, so they may set attributes the schema marks editable_by: none.

A user created through SCIM:

  • has its email marked verified (the provisioning system is trusted for it) and its phone unverified;
  • has no password. It signs in by whatever the tenant offers that needs none (a magic link, an email code, a brokered identity provider) or sets a password through recovery;
  • is not held back by required profile attributes: they are asked for at the next sign-in instead.

Setting active: false disables the user and DELETE /Users/{id} soft-deletes it, exactly as the admin API does: every session ends at once and the user’s clients get back-channel logout. A deleted account is gone from SCIM and sign-in at once, and the purge job removes it for good after the tenant’s account.deletion_retention_days (default 30). A deleted user answers 404.

PUT replaces the document: an attribute left out is cleared. A PUT without emails removes the user’s email address, one without externalId clears it. Most provisioning systems send the full document, but check before pointing a hand-written integration at PUT.

How a SCIM Group maps onto a group

SCIM attributerIDM group
idthe group’s id
displayName (required)name
externalIdattributes.externalId
membersmember users, by user id (value)

Groups created through SCIM are top-level. Members must be users of the same tenant; a nested group as a member is refused with invalidValue. A POST whose member list names a user that does not exist creates nothing. The group list covers every group of the tenant, including groups created in the console. Roles attached to a group apply to its SCIM-provisioned members like to any other member, which is the usual way to turn an upstream group into permissions: attach roles to the group in rIDM once, let SCIM keep the membership current.

That route stops at administrator access. Adding members to a group whose roles (directly, through its parent groups or through composite roles) grant any ridm:* admin permission is refused with 403 and changes nothing, whether by POST, PUT or PATCH. A provisioning token carries no administrator whose own permissions could bound what it hands out, so admin rights are granted in rIDM, not provisioned. Removing members, and requests that add nobody new, are unaffected.

Listing and filters

GET /Users and GET /Groups accept filter, startIndex (1-based) and count. count defaults to and is capped at 200; a startIndex beyond 2,000 is refused with tooMany. An unfiltered user list reads only the requested page, and its totalResults may lag writes by up to 30 seconds.

GET /Groups and GET /Groups/{id} accept excludedAttributes=members (RFC 7644 §3.9), which leaves the member list out: for a large group it is most of the document, and Microsoft Entra ID asks for groups this way. Without it, a group lists its members only for the groups on the requested page, unless the filter itself tests members.

Filters follow the RFC 7644 grammar: the operators eq ne co sw ew gt ge lt le pr, and, or, not, parentheses, dotted paths (name.givenName), value filters (emails[type eq "work"].value), schema-URN prefixes, and case-insensitive attribute names and string comparisons.

A user filter that is a single equality on userName, externalId, emails (or emails.value) or id is answered from an index, even when it is and-ed with further conditions. Any other user filter is evaluated over the tenant’s users and only works while the tenant has at most 2,000 of them; beyond that it is refused with tooMany. Provisioning systems look users up by userName or externalId before creating them, which is the indexed path. Group filters are evaluated over all groups; an equality on displayName skips the others before anything else is read.

PATCH

PATCH takes an RFC 7644 PatchOp with add, replace and remove operations, case-insensitive op names, with or without path:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [
    { "op": "replace", "path": "active", "value": false },
    { "op": "replace", "path": "emails[type eq \"work\"].value", "value": "alice@acme.example" },
    { "op": "add", "value": { "name.familyName": "Liddell" } }
  ]
}

Simple, dotted and filtered paths work, including members[value eq "<user id>"] to remove one member. Booleans may be sent as the strings "True" and "False", which some clients do. remove needs a path (noTarget otherwise) and add/replace need a value. The operations are applied to the current SCIM document and the result is stored as a full replace, so PATCH and PUT share one code path and one set of rules.

Example with curl

BASE=https://id.example.com/scim/v2/acme
SCIM=rscim_Qm9i…

# Create
curl -X POST "$BASE/Users" \
  -H "Authorization: Bearer $SCIM" -H "Content-Type: application/scim+json" \
  -d '{
        "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
        "userName": "alice",
        "externalId": "00u1a2b3c4",
        "name": {"givenName": "Alice", "familyName": "Liddell"},
        "emails": [{"value": "alice@acme.example", "type": "work", "primary": true}],
        "active": true
      }'

# Look up by externalId (indexed)
curl -G "$BASE/Users" -H "Authorization: Bearer $SCIM" \
  --data-urlencode 'filter=externalId eq "00u1a2b3c4"'

# Deactivate
curl -X PATCH "$BASE/Users/<id>" \
  -H "Authorization: Bearer $SCIM" -H "Content-Type: application/scim+json" \
  -d '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
       "Operations": [{"op": "replace", "path": "active", "value": false}]}'

Configuring a provisioning system

Okta, Microsoft Entra ID and similar systems need the same few things; the field names differ by product:

  • SCIM base URL / tenant URL: https://id.example.com/scim/v2/acme.
  • Authentication: HTTP header / bearer token, the rscim_ token. There is no OAuth or basic-auth mode.
  • Unique identifier for users: userName. Map it to whatever the source uses as a login name, and keep it stable; changing it upstream renames the user in rIDM.
  • Attribute mappings: userName, externalId, emails[type eq "work"].value, name.givenName, name.familyName, displayName, phoneNumbers, active, locale. Anything else the source sends is ignored.
  • Supported operations: create, update (PATCH or PUT), deactivate (active: false), delete, group push. Turn off password synchronisation; rIDM does not accept passwords over SCIM.

Run the product’s connection test first: it reads ServiceProviderConfig and filters /Users by userName, which exercises both the token and the base URL.

Caveats

  • The SCIM endpoints are not behind the tenant’s request ceilings or IP rules, which cover the sign-in and OAuth endpoint families only (see Rate limits, IP rules and CAPTCHA). Treat a provisioning token as an administrator credential: give it an expiry, one per upstream system, and revoke it when the integration goes away.
  • A disabled tenant answers every SCIM request with 403.
  • Bulk requests (/Bulk) are not supported; provisioning systems fall back to one request per change when ServiceProviderConfig says so.

Webhooks and the audit log

Every state-changing action in rIDM raises a domain event with a stable dotted name such as user.created. Two consumers matter to an administrator: webhooks, which POST selected events to your endpoints, signed, with retries; and the audit log, which appends every event to a per-tenant hash chain that can be listed, exported, verified and shipped to an external system. For the event model itself see Events, audit and webhooks.

Event catalogue

These are the names webhooks subscribe to and the audit log records. The names are part of the public contract and do not change once shipped. The payload fields listed are those of the event’s kind document (next to type).

EventPayload fields
tenant.created, tenant.updated, tenant.deletedtenant_id
tenant.movedtenant_id, from_region, to_region (null: the home database)
tenant.profile_schema_updatedtenant_id
system.bootstrappedadmin_user_id
user.created, user.updated, user.deleteduser_id
user.password_changeduser_id, by_user
user.password_hash_upgradeduser_id, from_algo
user.registereduser_id, verified
user.email_verifieduser_id
user.email_changeduser_id, old_email, new_email
user.password_reset_requested, user.password_reset_completeduser_id
user.lockeduser_id, until_secs
user.terms_accepteduser_id
login.succeededuser_id, method
login.failedidentifier, reason
login.passwordless_sentuser_id, method
login.new_deviceuser_id, session_id
login.brokereduser_id, idp_id, provider
logout.upstreamidp_id, provider
directory.syncedidp_id, provider, full, created, updated, disabled, enabled
mfa.changeduser_id, change
risk.step_upuser_id, score, signals, country
risk.blockeduser_id, score, signals, country
impersonation.requesteduser_id, reason
impersonation.starteduser_id, session_id, impersonator_id, impersonator_tenant_id, reason
impersonation.endeduser_id, session_id, impersonator_id
audit.chain_brokenseq, reason (the scheduled check found this tenant’s chain broken)
device.trusted, device.revokeduser_id, device_id
session.created, session.revokedsession_id, user_id
authorization.granteduser_id, client_id, scopes (also raised when the user approves a backchannel request)
backchannel.requestedrequest_id, client_id, user_id, scopes, binding_message (actor: the client)
backchannel.deniedrequest_id, client_id, user_id
consent.granteduser_id, client_id, scopes
consent.revokeduser_id, client_id
token.refresh_reuse_detectedfamily_id, client_id, user_id
token.revokeduser_id, session_id, count
personal_token.createduser_id, token_id, scopes
personal_token.revokeduser_id, token_id
client.createdclient_id, public_id
client.updated, client.deleted, client.secret_rotatedclient_id
dcr_token.created, dcr_token.revokedtoken_id (a dynamic registration initial access token)
group.created, group.updated, group.deletedgroup_id
group.member_added, group.member_removedgroup_id, user_id
organization.created, organization.updated, organization.deletedorg_id
organization.member_added, organization.member_removedorg_id, user_id
organization.domain_added, organization.domain_verified, organization.domain_removedorg_id, domain_id
role.created, role.updated, role.deletedrole_id
role.assigned, role.unassignedrole_id, user_id or group_id
role.composite_added, role.composite_removedparent_role_id, child_role_id
invitation.createdinvitation_id, email
invitation.acceptedinvitation_id, user_id
invitation.revokedinvitation_id
invitation.resentinvitation_id
scope.created, scope.updated, scope.deletedscope_id
claim_mapper.created, claim_mapper.updated, claim_mapper.deletedmapper_id
resource_server.created, resource_server.updated, resource_server.deletedresource_server_id
permission.created, permission.deletedresource_server_id, permission_id
permission.granted, permission.revokedrole_id, permission_id
signing_key.createdkey_id, kid, alg
signing_key.status_changedkey_id, kid, status
master_key.rotatednew_version
master_key.generation_createdversion, backend
saml_key.createdkey_id
saml_key.status_changedkey_id, status (active or deleted)
webhook.created, webhook.updated, webhook.deleted, webhook.secret_rotatedwebhook_id
webhook.testwebhook_id
webhook.delivery_deadwebhook_id, delivery_id, event_name
scim_token.created, scim_token.revokedtoken_id
ip_rule.created, ip_rule.updated, ip_rule.deletedrule_id
mtls_trust_anchor.createdanchor_id, fingerprint
mtls_trust_anchor.deletedanchor_id
identity_provider.created, identity_provider.updated, identity_provider.deletedidp_id
identity.linkeduser_id, idp_id, external_subject
identity.unlinkeduser_id, idp_id

Three of them never reach a webhook: master_key.rotated and master_key.generation_created are global events (they belong to no tenant and are recorded in the global audit chain), and webhook.delivery_dead is kept out of deliveries so that a failing endpoint cannot breed a new delivery for every dead one. All three are in the audit log.

Webhooks

Creating a webhook

In the console: Webhooks (/console/webhooks/). Through the admin API, under ridm:webhooks:read and ridm:webhooks:write (owners and administrators):

curl -X POST https://id.example.com/admin/tenants/acme/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
        "name": "HR sync",
        "url": "https://hooks.acme.example/ridm",
        "events": ["user.*", "group.member_added", "group.member_removed"],
        "headers": {"X-Api-Key": "…"},
        "max_attempts": 8
      }'

The answer is the webhook plus secret (whsec_…), shown this once. Store it with the receiver.

FieldDefaultRules
namerequired1–255 characters
urlrequiredsee Target restrictions
eventsrequiredexact names (user.created), prefixes ending in * (user.*), or * for everything; lower case, at most 64 characters each
enabledtruea disabled webhook receives nothing, and its pending deliveries die
headers{}static string headers sent with every delivery; Host and anything starting X-RIDM- are reserved
max_attempts81–20
RouteDoes
GET /admin/tenants/{slug}/webhookslist
GET, PATCH, DELETE …/webhooks/{webhook}read, change any field above, delete (its deliveries go with it)
POST …/webhooks/{webhook}/secretnew secret, returned once; later deliveries use it
POST …/webhooks/{webhook}/testsend a webhook.test event now, whatever the event filter; returns the delivery
GET …/webhooks/{webhook}/deliveries?status=&limit=delivery log, newest first (limit 50 by default, at most 500)
GET …/webhooks/{webhook}/deliveries/{delivery}one delivery with its payload, last status and the first 512 bytes of the last response
POST …/deliveries/{delivery}/redeliversend one delivered, failed or dead delivery again now
POST …/deliveries/redeliver-deadrequeue every dead delivery of the webhook; answers {"requeued": n}

Webhooks are part of the tenant configuration document; a webhook created by an import gets a fresh secret, reported once.

What a delivery looks like

POST /ridm HTTP/1.1
Host: hooks.acme.example
Content-Type: application/json
User-Agent: rIDM-Webhooks/1
X-RIDM-Event: user.created
X-RIDM-Timestamp: 1789722764
X-RIDM-Delivery: 0192f5d0-6a1e-7c3b-9d2a-4b8e1f0c7a55
X-RIDM-Webhook: 0192f5c4-1b2c-7d3e-8f40-5a6b7c8d9e0f
X-RIDM-Signature: t=1789722764,v1=5f2b9c…
X-Api-Key: …

{
  "delivery_id": "0192f5d0-6a1e-7c3b-9d2a-4b8e1f0c7a55",
  "attempt": 1,
  "event": {
    "id": "0192f5d0-69f0-7a11-b3c4-2d5e6f708192",
    "tenant_id": "0192f0a1-…",
    "occurred_at": "2026-09-18T09:12:44.512Z",
    "actor": {"type": "admin", "id": "0192f0a2-…"},
    "kind": {"type": "user_created", "user_id": "0192f5d0-69e8-…"}
  }
}
HeaderContent
X-RIDM-Eventthe dotted event name, which the body does not repeat. The body’s kind.type is the event’s internal variant name (user_created for user.created, but password_changed for user.password_changed), so route on this header
X-RIDM-TimestampUnix seconds when this attempt was signed
X-RIDM-Deliverythe delivery id; the same on every retry and redelivery of this delivery
X-RIDM-Webhookthe webhook id
X-RIDM-Signaturet=<timestamp>,v1=<hex HMAC-SHA256>

event.actor.type is user, client, admin or system (the last without an id). event.impersonator names the administrator when the event happened in a session they opened as the user (impersonation). event.ip and event.user_agent appear when the event was raised by a request that recorded them, such as sign-in attempts. attempt counts from 1 and restarts at 1 after a manual redelivery. Payloads carry ids, not full records: fetch the current state from the admin API when you need it.

Verifying a delivery

The signature is an HMAC-SHA256, keyed with the whole secret string (including the whsec_ prefix) as UTF-8 bytes, over the timestamp, a full stop, and the raw request body exactly as received: "<t>.<body>". A receiver should:

  1. recompute the HMAC over the raw body before parsing it, and compare in constant time;
  2. reject a timestamp more than a few minutes from its own clock, so a captured request cannot be replayed later (every retry is signed afresh, so an honest delivery is always recent);
  3. remember the X-RIDM-Delivery ids it has processed and acknowledge a repeat without acting on it again. Delivery is at least once: a timeout after your endpoint did the work, or an administrator’s redelivery, sends the same delivery again.
import hashlib, hmac, time

TOLERANCE = 300  # seconds

def verify(secret: str, headers, body: bytes, seen: set) -> bool:
    parts = dict(p.split("=", 1) for p in headers["X-RIDM-Signature"].split(","))
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
        return False                                   # stale or malformed
    mac = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256)
    if not hmac.compare_digest(mac.hexdigest(), sig):
        return False                                   # not from rIDM
    delivery = headers["X-RIDM-Delivery"]
    if delivery in seen:
        return True                                    # duplicate: acknowledge, do nothing
    seen.add(delivery)                                 # use a durable store in production
    return True

The same in Rust with the hmac, sha2, hex and subtle crates:

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

fn signature_ok(secret: &str, header: &str, body: &[u8], now: i64) -> bool {
    let (mut t, mut v1) = (None, None);
    for part in header.split(',') {
        match part.split_once('=') {
            Some(("t", v)) => t = v.parse::<i64>().ok(),
            Some(("v1", v)) => v1 = hex::decode(v).ok(),
            _ => {}
        }
    }
    let (Some(t), Some(v1)) = (t, v1) else { return false };
    if (now - t).abs() > 300 {
        return false;
    }
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
    mac.update(t.to_string().as_bytes());
    mac.update(b".");
    mac.update(body);
    mac.finalize().into_bytes().as_slice().ct_eq(&v1).into()
}
}

Rotating the secret (POST …/secret) switches signing at once; there is no period in which both secrets sign. Have the receiver accept either secret while you roll the new one out, then drop the old one.

Retries and dead deliveries

A matching event is queued as a delivery for every enabled webhook that wants it and sent straight away; up to eight deliveries of a tenant are attempted at a time. Each attempt has a 10-second timeout and does not follow redirects.

OutcomeResult
2xxdelivered
connection error, timeout, 5xx, 408, 425, 429failed, retried after a backoff
any other status (including 3xx and other 4xx)dead at once
attempts reach max_attemptsdead

The backoff after the first failure is 30 seconds, then 2 minutes, 10 minutes, 30 minutes, 2 hours, and 6 hours for every attempt after that. With the default of eight attempts a delivery is given up about 15 hours after the first try. Retries are sent by the webhook_delivery job, which runs every 30 seconds on one node at a time; a delivery stuck in sending for ten minutes (a node died mid-attempt) is picked up again.

A delivery that dies raises webhook.delivery_dead (audited, not delivered) and is logged as a warning. Fix the endpoint, then redeliver from the console’s delivery log or with redeliver-dead. Redelivery resets the attempt count and keeps the delivery id.

Deliveries are not ordered: concurrent attempts and retries mean user.updated can arrive before the user.created it follows. Use event.occurred_at or fetch the current state when order matters.

Delivered and dead deliveries are deleted by the hourly cleanup job once they are older than RETENTION_DAYS (default 30). The counter ridm_webhook_deliveries_total{outcome} (delivered, retry, dead) tracks the outcomes.

Target restrictions

  • The URL must be https. Plain http is allowed only for localhost, 127.0.0.1 and [::1], for development.
  • A literal private address is refused when the webhook is saved (400): RFC 1918 ranges, link-local (including 169.254.169.254), 100.64.0.0/10, 0.0.0.0/8, IPv6 unique-local (fc00::/7) and link-local (fe80::/10), and IPv6 forms that embed one of those.
  • A hostname is checked at delivery time, on every attempt: it is resolved to public addresses only, so a name that resolves (or later rebinds) to an internal address fails the attempt instead of reaching it.
  • Redirects are never followed and HTTP(S)_PROXY is ignored, so an endpoint cannot bounce a delivery elsewhere.

These are the rules of the outbound request policy, which applies to every URL tenant administrators choose.

The audit log

What is recorded

Every event in the catalogue is appended to its tenant’s chain as a row with:

FieldContent
idthe event id
tenant_idthe tenant, or null in the global chain
seqposition in the chain, from 1
occurred_at, recorded_atwhen it happened, when it was written
namethe dotted event name
actor_type, actor_iduser, client, admin or system; a SCIM token acts as client
subject_idthe main entity: the first of user_id, client_id, role_id, group_id, invitation_id, key_id, mapper_id, resource_server_id, scope_id, session_id in the payload
impersonator_idthe administrator behind the event, when it happened in a session they opened as the user (impersonation)
ip, user_agentwhen the event came from a request that recorded them
payloadthe event’s kind document
prev_hash, hashthe chain links, lowercase hex

Global events, today only master_key.rotated and master_key.generation_created, form a chain of their own.

Querying

In the console: Audit log (/console/audit/), with filters, paging, expandable rows, JSON and CSV export and a verify button; global administrators can switch to the global chain. Every built-in administrator role holds ridm:audit:read.

# Newest first; name ending in "." or "*" is a prefix
curl -G https://id.example.com/admin/tenants/acme/audit \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode 'name=login.' \
  --data-urlencode 'from=2026-09-01T00:00:00Z' \
  --data-urlencode 'limit=100'
ParameterMeaning
from, toRFC 3339 bounds on occurred_at
nameexact event name, or a prefix when it ends with . or *
actor_id, subject_id, impersonator_idexact match
user_idrows where the user is the actor, the subject or the impersonator
limitpage size, 50 by default, at most 500
cursorthe previous page’s next_cursor

The answer is {"items": [...], "next_cursor": "..."}; next_cursor is absent on the last page. Tenant listings are served from DATABASE_READ_URL when one is configured.

GET /admin/tenants/{slug}/audit/export?format=json|csv (same filters, no paging) streams the chain oldest first as a download, with the hashes included. GET /admin/audit, /admin/audit/export and /admin/audit/verify do the same for the global chain and require a global administrator.

The hash chain

Each row’s hash is SHA-256(prev_hash || canonical row), where the canonical row is the UTF-8 string

id|chain|seq|occurred_at|name|actor_type|actor_id|subject_id|ip|user_agent|payload

with chain the tenant id (the nil UUID for the global chain), occurred_at in RFC 3339 UTC with exactly six fractional digits and a Z, absent values as empty strings, and payload as compact JSON with its keys sorted. A row with an impersonator_id has |impersonator:<uuid> appended; rows without one hash as they always did. The first row of a chain has no prev_hash. Writers of one chain are serialised with a Postgres advisory lock, so seq has no gaps.

GET /admin/tenants/{slug}/audit/verify walks the retained chain from its oldest row and recomputes every hash:

{
  "checked": 48211, "valid": true, "first_seq": 1, "last_seq": 48211,
  "last_hash": "9f2c…",
  "scheduled": { "verified_seq": 48190, "verified_at": "2026-09-21T03:10:44Z" }
}

A row that was altered, removed from the middle or reordered makes it answer "valid": false with broken_at_seq and a reason (gap in chain after seq …, prev_hash does not link to the previous row, row hash does not match its contents). scheduled is what the daily check (below) last established.

The chain proves that retained history was not edited in place. On its own it can’t prove that the newest rows were not cut off, or that the whole log was not rewritten by someone holding the database. Two things close that gap: keep last_hash somewhere else, and keep a copy outside rIDM (Shipping to an external system).

Checking an export yourself

ridm audit verify --file checks a JSON export with nothing but the file. It never contacts the server, so a server that rewrote its own log can’t vouch for itself. The algorithm is the one above, and it lives in the ridm-core crate, so you can also check exports with your own tooling.

ridm audit export -o acme-2026-09.json         # the whole chain, oldest first
ridm audit verify -f acme-2026-09.json --head 9f2c…
# Intact: 48211 rows (seq 1–48211), ending on 9f2c….
FlagProves
--head <hex>The chain ends on a hash you kept earlier (the last_hash of a previous check), so nothing was cut off or rewritten after that point.
--after <hex>The file’s first row follows that hash, so consecutive exports (--from/--to windows) form one unbroken chain.

It fails, exiting non-zero, at the first row that does not hash or link, and names its seq. An export filtered by event name or user has gaps by design, and verification says so. Export the whole chain, or a time window, to check it. ridm audit verify without --file asks the server to walk its copy, and --global does the same for the global chain. The file is read as a stream, so an export of millions of rows is fine.

The scheduled check

The daily audit_verify job checks every chain that grew since it was last found intact. It starts from that checkpoint rather than from the oldest row, which keeps it cheap on a large log. It rehashes the checkpoint row itself, so a rewrite that reaches the checkpoint is caught; the verify endpoint and ridm audit verify always walk everything. When a chain does not verify, the job:

  • stores where and why on the chain, which the verify endpoint shows as scheduled.broken_at_seq, and checks that chain from the start on every run until it verifies again;
  • logs an error and counts it in ridm_audit_chain_breaks_total, and sets ridm_audit_chains_broken to the number of broken chains (alert on anything above 0);
  • records audit.chain_broken (seq, reason) in that tenant’s chain, once per break, so the tenant’s webhooks hear of it.

Retention

settings.audit.retention_days (default 365; 0 keeps everything) sets how long a tenant’s rows are kept. The global chain follows the master tenant’s setting. The daily audit_retention job removes each chain’s expired prefix: every row up to the newest one older than the cutoff, in batches of 5,000. Removing a prefix rather than scattered rows keeps what remains verifiable; the oldest retained row’s prev_hash then points at a row that no longer exists, which verification accepts. The same job creates the monthly partitions of the audit table two months ahead, and drops a whole month’s partition, instead of deleting its rows, once the month ended before the longest retention of the tenants in that database and every chain with rows in it belongs to a tenant with a retention (a tenant keeping everything, one being moved between regions, or a deleted tenant’s trail keeps its months). The job remembers when each chain’s oldest remaining row was written and only visits the chains whose oldest row is past their retention.

The setting is in the console under Settings → Keys, discovery & audit, or:

curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"audit": {"retention_days": 730}}}'

Shipping to an external system

Set AUDIT_SINK_URL to copy every recorded row, from every tenant, to a SIEM or log store:

AUDIT_SINK_URLTransport
https://… or http://…POST of a JSON array of up to 100 rows of one chain, in chain order. Authorization: Bearer $AUDIT_SINK_TOKEN when that is set. X-RIDM-Signature: t=<unix>,v1=<hex> (HMAC-SHA256 of "<t>.<body>" under AUDIT_SINK_SECRET, the webhook scheme) when that is set.
syslog://host:port (or syslog+udp://)One RFC 5424 message per row over UDP, facility local0, severity informational, with the row as JSON in the message. Port 514 by default.
syslog+tcp://host:portThe same over TCP, newline-delimited.
syslog+tls://host:portThe same over TLS (RFC 5425): each message is prefixed with its length. Port 6514 by default. The collector’s certificate is checked against the system’s roots, or against AUDIT_SINK_CA_FILE (PEM) for a private CA.

The sink ships from the database, not from memory. A worker on one node at a time (a leader lock) reads each chain’s rows after the last one it delivered, sends them, and records how far it got only once the receiver accepted them. When a delivery fails, nothing moves: the worker backs off, doubling up to a minute, and tries the same rows again. A receiver that is down for an hour, or a rolling restart of rIDM, delays rows but never loses them.

  • At least once. A row can arrive twice, for example when a node dies between a delivery and recording it. Deduplicate on the row’s id, not on seq: after a database restore, a chain reuses the seq numbers written after the backup.
  • A new destination starts now. The first time rIDM sees an AUDIT_SINK_URL (compared without credentials or query), it starts every chain at its current head rather than replaying history. Backfill older rows with ridm audit export.
  • Rows the retention job removed before they were shipped are skipped.

Watch ridm_audit_sink_rows_total, ridm_audit_sink_failures_total and ridm_audit_sink_lag_rows (rows recorded but not yet shipped, over every chain). A lag that keeps growing means the receiver is refusing rows or can’t keep up.

Caveats

  • Events travel on an in-process bus, and the audit writer and the webhook dispatcher record them after the action has committed. Each has its own bounded queue, so a burst delays events rather than skipping them (watch ridm_audit_queue_depth and ridm_webhook_dispatch_queue_depth); a node whose queue passes 80% of EVENT_QUEUE_CAPACITY reports itself not ready until it catches up, and only an event published into a full queue is dropped (ridm_event_queue_dropped_total). A node that is stopped gives its queues 15 seconds to drain and loses what was still waiting after that.
  • An event is dispatched only on the node where it happened; webhooks and audit rows are not duplicated across nodes.
  • Webhook payloads contain identifiers, email addresses (in invitation.created, user.email_changed) and login identifiers (in login.failed). Treat the endpoint as holding personal data.

Rotating keys

rIDM has two kinds of key, and they rotate differently:

  • Signing keys belong to one tenant. They sign every token the tenant issues and are published in its JWKS so that relying parties can verify those tokens. Rotating one is routine and can be automatic.
  • The master key belongs to the deployment. It encrypts secrets at rest (the private halves of signing keys, MFA credentials, identity-provider client secrets, webhook secrets and provider settings such as SMTP passwords). Rotating it means rolling a new key out to every node and re-encrypting stored rows.

For how the two fit together see Signing keys and the master key.

Signing keys

Lifecycle

StatusSigns tokensIn the JWKS
pendingnoyes
activeyesyes
retiringnoyes, until expires_at
revokednono

A tenant has at most one active key per algorithm. Tokens are signed with the active key of the tenant’s default algorithm (settings.keys.default_alg), except access tokens for a resource server that sets its own signing_alg, which are signed with the active key of that algorithm. Saving such a resource server makes sure a key of its algorithm exists, so a tenant can have several active keys at once, one per algorithm in use. Every key’s kid is its RFC 7638 JWK thumbprint. When a tenant has no key at all, the first token request or JWKS fetch creates one; a lock makes sure only one node generates it.

Activating a key moves the previous active key of the same algorithm to retiring, with expires_at set to now plus the overlap. Tokens signed by the old key keep verifying until then; the key_rotation job revokes retiring keys whose overlap has passed. Revoking a key unpublishes it immediately, and every token it signed stops verifying: that is the emergency action for a key you believe is compromised, not part of a normal rotation.

Key policy

settings.keys, in the console under Settings → Keys, discovery & audit:

SettingDefaultMeaning
default_algRS256algorithm for new keys and for signing: RS256, RS384, RS512, ES256 or EdDSA
rsa_bitsB2048RSA modulus for new RSA keys: B2048, B3072 or B4096
rotation_interval_days90rotate the active key when it is this old; 0 turns automatic rotation off
retire_overlap_hours24how long a retired key stays published

retire_overlap_hours must comfortably exceed the longest lifetime of a token signed by the key: the access and ID token lifetimes of the tenant, its clients and its resource servers (five minutes by default), plus the time relying parties may cache the JWKS. Refresh tokens are not affected; they are not signed JWTs checked against the JWKS.

Automatic rotation

The key_rotation job runs every hour on one node at a time. For each active tenant it revokes retiring keys whose expires_at has passed, then, when rotation_interval_days is not 0, rotates every active key whose not_before (its creation time, unless one was given) is that many days in the past: a new key of the same algorithm is generated and made active at once, and the old one retires with the overlap. Keys of the default algorithm and of resource servers’ algorithms follow the same schedule. Rotations are audited as signing_key.created and signing_key.status_changed with the system actor.

Rotating by hand

Console: Signing keys (/console/keys/) shows every key on a timeline with buttons for “Rotate now”, “New pending key”, activate, retire and revoke.

CLI:

ridm --tenant acme key list                  # KID, ALG, STATUS, NOT BEFORE, EXPIRES
ridm --tenant acme key list --status retiring
ridm --tenant acme key rotate                # new key, active at once; the old one retires

Admin API, under ridm:keys:read / ridm:keys:write (owners and administrators):

RouteDoes
GET /admin/tenants/{slug}/keys?status=list keys
POST /admin/tenants/{slug}/keys/rotatenew key with the default algorithm, active at once (rotate another algorithm’s key by creating one with activate: true)
POST /admin/tenants/{slug}/keysnew key, pending unless activate is true
GET /admin/tenants/{slug}/keys/{key}one key with its public JWK
POST …/keys/{key}/activatestart signing with it; the previous active key of its algorithm retires
POST …/keys/{key}/retirestop signing; stays published for the overlap
POST …/keys/{key}/revokeunpublish now

POST …/keys takes an optional body: alg and rsa_bits (2048, 3072 or 4096) default to the key policy, activate defaults to false, and not_before is recorded as the key’s start (the age automatic rotation measures from) but does not delay activation.

Rotation without a verification gap

A rotation (manual or automatic) activates the new key in the same step that creates it, so a relying party holding a cached JWKS meets a kid it has never seen. The JWKS is served with Cache-Control: public, max-age=300, must-revalidate and a strong ETag, and well-behaved libraries (including ridm-auth) refetch the key set when a token names an unknown kid. For relying parties that do not, publish the key before using it:

# 1. Publish only
curl -X POST https://id.example.com/admin/tenants/acme/keys \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
# 2. Wait longer than the JWKS max-age (5 minutes) plus any caching of your own
# 3. Start signing with it
curl -X POST https://id.example.com/admin/tenants/acme/keys/<id>/activate \
  -H "Authorization: Bearer $TOKEN"

Changing the signing algorithm works the same way: create a pending key with the new alg, wait, activate it, then set settings.keys.default_alg to the new algorithm, and finally retire the old algorithm’s key. Changing default_alg alone makes the next token request generate and activate a key of the new algorithm on the spot, with no pre-publication.

JWKS publication

GET /t/{slug}/.well-known/jwks.json (or /.well-known/jwks.json on the tenant’s custom domain) lists the public halves of every pending, active and retiring key, as application/jwk-set+json, answerable from any origin. Each JWK carries kid, alg and use: "sig". The document is cached per tenant under a version that every key change replaces, so a change is visible on every node at once; clients honour ETag with If-None-Match for cheap revalidation.

The master key

How it works

Each encrypted value is sealed with XChaCha20-Poly1305 under the master key (AES-256-GCM under a per-value derived key in the FIPS build, and in both builds from the next release; every server reads both), bound to the row it belongs to, and stored with the generation (key version) that sealed it. A node knows its current key (MASTER_KEY or MASTER_KEY_FILE, generation MASTER_KEY_VERSION) and, optionally, older generations (MASTER_KEY_PREVIOUS). New writes always use the current generation; reads use whichever generation the row records. With a key custody backend (KEY_WRAPPER) the generations are data keys an HSM or KMS wrapped instead, made with new-generation (below); the rest of this section is the same for both.

VariableDefaultMeaning
MASTER_KEYrequired, unless MASTER_KEY_FILE or KEY_WRAPPER is set32 bytes, hex or base64. Generate with openssl rand -hex 32
MASTER_KEY_FILEunsetread the key from a file (a mounted secret) instead
MASTER_KEY_VERSION1the current key’s generation, at least 1
MASTER_KEY_PREVIOUSemptyolder keys as version=key pairs, comma-separated, e.g. 1=9f86…,2=4e07…; every version must be lower than MASTER_KEY_VERSION

Re-encryption covers six columns: signing_keys.private_key_enc, credentials.data_enc (TOTP secrets, passkeys, recovery codes), tenant_provider_settings.config_enc (SMTP, SMS and CAPTCHA settings), identity_providers.client_secret_enc (OIDC client secrets, LDAP bind passwords), webhooks.secret_enc and saml_signing_keys.private_key_enc. Client secrets, personal access tokens and provisioning tokens are stored as hashes and are not involved.

Procedure

  1. Generate the new key and keep the old one:

    openssl rand -hex 32
    
  2. Roll it out to every node: the new key as MASTER_KEY, MASTER_KEY_VERSION incremented, the old key in MASTER_KEY_PREVIOUS. For a deployment on generation 1:

    MASTER_KEY=<new key>
    MASTER_KEY_VERSION=2
    MASTER_KEY_PREVIOUS=1=<old key>
    

    Restart or redeploy every node. From here on new secrets are written under generation 2 and old rows still decrypt.

  3. Re-encrypt everything still on an older generation, once, from anywhere. On the FIPS build this also rewrites values still sealed with XChaCha20-Poly1305 onto AES-256-GCM, whatever their generation; the status lists them under legacy_cipher_rows.

    ridm master-key status          # current generation, rows still on older ones
    ridm master-key rotate          # asks first; --yes (-y) for scripts
    

    or on a host with the server’s configuration (the same environment as the nodes):

    ridm-api rotate-master-key --status
    ridm-api rotate-master-key
    

    The console’s Signing keys page shows the same status to global administrators and has a button to re-encrypt. Through the admin API it is GET /admin/master-key and POST /admin/master-key/rotate, which need a global administrator with ridm:keys:read / ridm:keys:write.

  4. Check that nothing is left: ridm master-key status must report 0 rows on an older generation, and the rotation report must show no failed rows.

  5. Remove the old key from MASTER_KEY_PREVIOUS on every node and restart.

With a key custody backend

There is no key to generate or roll out: the backend wraps a new data key.

ridm master-key new-generation     # POST /admin/master-key/generations
ridm master-key rotate

or ridm-api rotate-master-key --new-generation, which creates the generation and re-encrypts in one run, or New generation then Re-encrypt pending rows on the console. The creating node switches at once and the others within a minute, so the rollout window below does not apply; a pass run before every node has switched simply leaves a few rows for the next one. Creating a generation is audited as master_key.generation_created.

Re-encryption runs online, in batches of 200 rows per table. Each row is rewritten only if its generation has not changed since it was read, so it is safe alongside live traffic and alongside a second rotation run. The report lists rows rewritten and rows that failed per table, and a pass that rewrote anything is audited as master_key.rotated in the global chain. ridm-api rotate-master-key exits 0 on success, 1 when rows failed or the database or Valkey could not be reached, and 2 on a configuration error; ridm master-key rotate exits 1 when rows failed.

Caveats

  • The rollout window. Between the first node starting on the new key and the last one, a node still on the old configuration cannot read secrets that an updated node has just written (a newly enrolled authenticator, a new signing key, a changed SMTP password): it does not know generation 2 yet. Roll the configuration out to all nodes together, and avoid key-generating operations while the rollout is in progress.
  • Failed rows mean a row’s generation is not among the keys the node knows, or its ciphertext is damaged. The usual cause is an incomplete MASTER_KEY_PREVIOUS. Fix the configuration and run the rotation again; rows that succeeded are not touched twice.
  • Removing an old key early makes every row still on it unreadable: signing keys fail to load, users cannot pass MFA, messaging and webhooks stop. Always check the status first.
  • Losing the master key loses every secret it protects. Keep it in a secret store with its own backup, separate from database backups.
  • Decrypted provider settings are cached in each node’s memory for up to a minute; this does not affect rotation, which reads the stored rows.
  • To keep the master key in an HSM or a cloud KMS instead of the environment, see Key custody: HSM and KMS.

Custom domains

By default a tenant lives under the deployment’s own host: its issuer is {PUBLIC_URL}/t/{slug}, for example https://id.example.com/t/acme. A tenant can instead be served on a host of its own, such as login.acme.example, so that its issuer, discovery document and endpoints carry the customer’s name.

What changes

With settings.custom_domain set to login.acme.example:

Without a custom domainWith one
Issuer (iss in every token)https://id.example.com/t/acmehttps://login.acme.example
Discoveryhttps://id.example.com/t/acme/.well-known/openid-configurationhttps://login.acme.example/.well-known/openid-configuration
Endpoints in discoveryhttps://id.example.com/t/acme/token, …https://login.acme.example/token, …
JWKS…/t/acme/.well-known/jwks.jsonhttps://login.acme.example/.well-known/jwks.json
Identity-provider callback…/t/acme/broker/{alias}/callbackhttps://login.acme.example/broker/{alias}/callback
Passkey relying party idthe UI’s hostlogin.acme.example
Sign-in pages and account console (embedded UI)https://id.example.com/login/, /account/https://login.acme.example/login/, /account/

Every tenant endpoint answers on the custom host without the /t/{slug} prefix: discovery, JWKS, /authorize, /par, /token, /userinfo, /introspect, /revoke, /end_session, dynamic registration, the flow API, branding and brokering. The prefixed paths on the primary host keep working, and report the custom issuer too: the issuer is a property of the tenant, not of the URL a document was fetched from.

What the custom host serves

A custom host serves its tenant and nothing else. Every request on it is mapped onto the tenant’s routes by prefixing the path with /t/{slug}, except:

Path on the custom hostServed as
/healthz, /readyzThe deployment’s probes, unchanged, so a load balancer can probe any host
/.well-known/webfinger, /.well-known/security.txtThe host-wide documents, unchanged
/t/acme/… (the tenant’s own prefix)Unchanged, for pages or clients that name the tenant explicitly
/scim/v2/acme/… (the tenant’s own SCIM base)Unchanged
A file of the embedded UI (/login/, /consent/, /account/, /_next/static/…), GET or HEADThe page, when the server serves the embedded UI; not the admin console (/console/) or the root page

Everything else lands under the tenant’s prefix, so it reaches only the tenant’s routes or nothing: another tenant’s /t/{other}/… and /scim/v2/{other}/…, the admin API (/admin/…), /metrics, /docs and /openapi.json all answer 404 on the custom host. The primary host is unchanged and keeps serving everything.

Other effects:

  • CORS: https://login.acme.example is admitted as an origin under the tenant’s paths, alongside the origins registered on its clients.
  • DPoP: a proof’s htu may name either the primary URL or the custom one.
  • Registration management: registration_client_uri for dynamically registered clients uses the custom host.

Only exact files of the UI pass: a page named without its trailing slash is left to the tenant’s routes, which own /account/me, /register and the like, so /login (no slash) is a 404 on the custom host rather than a redirect.

The sign-in pages move with the tenant. When the server serves the embedded UI, every page it sends the tenant’s users to is on the custom host: /authorize (on either host) redirects to https://login.acme.example/login/, and logout, the device page, and the links in recovery, invitation, verification and magic-link emails all point there. The pages call the tenant’s flow API on the same host, so the session they set is the one /authorize on the custom host sees, and passkeys (whose relying party id is the custom domain) work there. The account console is served on the custom host too, and its built-in client accepts https://login.acme.example/account/callback/ alongside the primary host’s, updated whenever the domain changes. The admin console is not: administer the tenant through the primary host. With the UI hosted separately (UI_URL on another origin, or a binary without the embedded UI), the pages stay at UI_URL; see the caveat below.

Setting a domain

Console: Settings → General → Custom domain. Admin API, with ridm:tenants:write:

curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"custom_domain": "login.acme.example"}}'

Send null or an empty string to remove it. The value is also part of the tenant configuration document, so it can be set by import.

The value is validated and lower-cased: a hostname of DNS labels (letters, digits and hyphens, 253 characters at most), optionally with a port for development (login.acme.test:8443). It must not be one of the deployment’s own hosts (PUBLIC_URL or UI_URL) and must not be claimed by another tenant (409). A change takes effect at once on every node: the host-to-tenant lookup is cached and evicted whenever the tenant changes.

rIDM does not verify that the tenant owns the name: there is no DNS challenge. A domain does nothing until DNS points it at your deployment and your proxy has a certificate for it, and only administrators holding ridm:tenants:write on the tenant can set it, so ownership is established by whoever operates DNS and TLS. If tenant administrators are not the same people as the operators, have operators set the domain.

DNS, TLS and the reverse proxy

The issuer is always https://<domain>; there is no plain-http form. So before setting the domain:

  1. Point the name at the deployment (a CNAME to the primary host, or the same addresses).

  2. Obtain a certificate for it and terminate TLS for it: at your reverse proxy or load balancer, or natively with TLS_CERT/TLS_KEY if one certificate can cover every host the node answers for.

  3. Make the proxy pass the requested host through. rIDM picks the tenant from:

    • X-Forwarded-Host, but only when the TCP peer is in TRUSTED_PROXIES;
    • otherwise the Host header;
    • otherwise the request’s authority (HTTP/2).

    Behind a proxy that rewrites Host, set X-Forwarded-Host and list the proxy in TRUSTED_PROXIES, or the request lands on the primary host’s routes and answers 404. The RFC 7239 Forwarded: host= parameter is not read.

  4. Check https://login.acme.example/.well-known/openid-configuration answers with "issuer": "https://login.acme.example" before moving any client over.

See TLS and reverse proxies for proxy configuration in general.

Moving an existing tenant

Setting, changing or removing a custom domain changes the tenant’s issuer for every client at the same moment. Plan it as a migration:

  • Relying parties that validate iss against a configured issuer, or that discovered endpoints once and stored them, reject new tokens until they are reconfigured. Tokens issued before the change carry the old issuer. Coordinate the switch with every client owner, or move clients whose issuer you control first.
  • Resource servers validating access tokens need the new issuer too (ridm-auth takes the issuer as configuration; see Protect a Rust API).
  • Upstream identity providers must have the new callback URL (https://<domain>/broker/{alias}/callback) added to their redirect URIs before the switch; the console shows the callback URL on each provider.
  • Passkeys are bound to their relying party id. Passkeys enrolled under the UI’s host stop working for this tenant once the relying party id becomes the custom domain, and the reverse holds when a domain is removed. Users fall back to their other factors and enrol again.
  • Redirect URIs of your clients are unaffected: they name the client’s own URLs.

Caveats

  • Browser sessions on the custom host. The session and trusted-device cookies are host-only with Path=/, and carry the tenant in their name (__Host-ridm_session_acme, __Host-ridm_device_acme; see Sign-in flows and sessions). A session set on the custom host is therefore sent back to every path there, /authorize included. A cookie is still bound to the host that set it, though. With the embedded UI the pages run on the custom host and this needs nothing. With the UI hosted separately, the sign-in pages at UI_URL call the flow API on the host they were built against, so a session they create belongs to that host, an authorization on the custom host does not see it, and passkeys (bound to the custom domain) cannot be used from pages on another host. Test the browser flows your clients use end to end on the custom host before moving production traffic; machine-to-machine traffic (client_credentials, token exchange, introspection) is unaffected.
  • The admin API and the admin console do not answer on a custom host, and the built-in admin console client is not configured for one; administer the tenant through the primary host.

Rate limits, IP rules and CAPTCHA

rIDM’s sign-in and OAuth endpoints are exposed to the internet by design, so they carry several layers of abuse protection. This page covers each control, what it counts or matches, its defaults and where to change it, and the deployment settings that decide which address a request comes from.

ControlScopeConfigured in
Request ceilingsper address, per client, per tenant, per deploymentsettings.rate_limits, RATE_LIMITS, RATE_LIMIT_IP_PER_MINUTE
IP rulestenant-wide and per client/admin/tenants/{slug}/ip-rules
Account lockoutper user, per addresssettings.lockout
CAPTCHAper sign-in flowsettings.captcha and /admin/tenants/{slug}/captcha
Breached passwordsevery password setsettings.password.check_breached, BREACH_CHECK_URL
Cross-origin policyevery responseclient cors_origins
Security headersevery responseHSTS_MAX_AGE
Outbound request policyevery URL a tenant or client choosesfixed

All of them depend on knowing the client’s address correctly, so start with the client address.

The client address

rIDM uses the TCP peer’s address unless the peer is a trusted proxy. TRUSTED_PROXIES lists the proxies and load balancers in front of rIDM, as CIDRs or single addresses, comma-separated:

TRUSTED_PROXIES=10.0.0.0/8,192.0.2.10

When the peer is in that list, rIDM reads X-Forwarded-For (and, when that is absent, the RFC 7239 Forwarded header) from the right: it skips entries that are themselves trusted proxies and takes the first one that is not. Proxies such as AWS load balancers and nginx with $proxy_add_x_forwarded_for append to whatever the caller sent, so the leftmost entry is attacker-controlled; the rightmost untrusted entry is the address your outermost proxy actually saw. An unparsable entry stops the walk.

That one address is what request ceilings count, what IP rules match, what the lockout throttle and CAPTCHA verification see, and what the audit log records. Get it wrong and every control misbehaves:

  • TRUSTED_PROXIES empty behind a proxy: every client appears as the proxy, so one busy office exhausts the per-address limits for everyone and IP rules match the proxy.
  • A proxy listed that does not overwrite or append the header: callers choose their own address.

TRUSTED_PROXIES also decides whether X-Forwarded-Host is believed, which matters for custom domains. See TLS and reverse proxies.

Request ceilings

What is limited

Three endpoint families have ceilings; each has its own per-address limit.

FamilyEndpoints (under /t/{slug})Refusal format
token/token, /introspect, /revoke, /userinfo, /device_authorizationOAuth JSON: 429, {"error": "slow_down"}
authorize/authorize (HTML page), /par and dynamic registration (OAuth JSON)as noted
flowsthe flow API, recovery, email verification, invitations, device verification (application/problem+json); brokering (HTML page)as noted

Discovery, JWKS, WebFinger, branding, /end_session, the account API, the admin API and SCIM are outside these families and not limited by them.

Each request is counted in up to four fixed-window buckets in Valkey, shared by every node:

BucketKeyLimit
deployment, per addressridm:rl:ip:{addr}RATE_LIMIT_IP_PER_MINUTE per 60 s, across every tenant and family
tenant, per address and familyridm:rl:t:{tenant}:{family}:ip:{addr}token_per_ip, authorize_per_ip or flows_per_ip per window
tenant, per clientridm:rl:t:{tenant}:client:{client}token_per_client per window, token family only
tenant totalridm:rl:t:{tenant}:alltenant_total per window, every limited request of the tenant

The per-client bucket is charged once the client is identified and before its secret or assertion is checked, so guessing a client secret is bounded by it too.

Defaults

Per tenant, settings.rate_limits (console: Settings → Rate limits):

SettingDefaultMeaning
enabledtrueswitch the tenant’s own buckets off (the deployment bucket still applies)
window_secs60window length, 1–3600 seconds
token_per_ip600token family, per address
token_per_client1200token family, per client
authorize_per_ip300authorize family, per address
flows_per_ip600flows family, per address
tenant_total0everything together; 0 = no ceiling

Any individual limit set to 0 is off. The defaults are generous on purpose: they are meant to hold for a busy office behind one NAT address. Tighten authorize_per_ip and flows_per_ip for consumer-facing tenants.

For the deployment:

VariableDefaultMeaning
RATE_LIMITStruemaster switch; false disables every ceiling, for tests and local experiments only
RATE_LIMIT_IP_PER_MINUTE6000per-address ceiling across all tenants; 0 = off
curl -X PATCH https://id.example.com/admin/tenants/acme \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"settings": {"rate_limits": {"authorize_per_ip": 60, "tenant_total": 20000}}}'

What a client sees

Every limited response carries the fields of the tightest bucket:

RateLimit-Limit: 600
RateLimit-Remaining: 587
RateLimit-Reset: 42

A refused request is 429 with Retry-After (seconds until the bucket’s window ends), in the family’s format from the table above. Browser navigations (/authorize, brokering) get a plain HTML page rather than a redirect to the client. These headers are exposed to browsers through CORS.

Caveats

  • Valkey being unreachable fails open: requests are allowed and a warning is logged. The ceilings protect against abuse; they are not an authorization control.
  • Windows are fixed, not sliding: a client can send up to twice a limit across a window boundary.
  • A request whose address cannot be determined counts only against the tenant total.

IP rules

IP rules allow or deny networks, either for the whole tenant or for one client.

How they decide

Rules form two scopes: tenant-wide (no client) and per client. Within a scope:

  1. the most specific rule whose network contains the address decides (a /32 beats a /24), whether it says allow or deny;
  2. an address that matches no rule passes, unless the scope has any allow rule, in which case the scope is an allow list and the address is refused.

A request must pass both scopes. The tenant scope is checked on every endpoint of the three families, before rate limiting and before anything else runs. The client scope is checked once the client is known: at /authorize (a refusal is an HTML page, never a redirect to the client) and at every client-authenticated endpoint (access_denied, before the secret is examined).

WhereRefusal
flow API403, application/problem+json
OAuth JSON endpoints403 with an OAuth error body
/authorize, brokering403 HTML page

If the rules cannot be read (the database and cache both unavailable), requests are refused with 503, not waved through.

Managing rules

Console: IP rules (/console/ip-rules/). Admin API, with ridm:tenants:read / ridm:tenants:write:

# Only the office and the VPN may sign in to this tenant
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "allow", "cidr": "203.0.113.0/24", "description": "Office"}'

# Except one address in it
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "deny", "cidr": "203.0.113.77"}'

# A machine client that may only be used from its build network
curl -X POST https://id.example.com/admin/tenants/acme/ip-rules \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"client_id": "<client uuid>", "action": "allow", "cidr": "2001:db8:42::/48"}'

action defaults to deny. cidr takes a network or a single IPv4 or IPv6 address and is stored in canonical form (203.0.113.9/24 becomes 203.0.113.0/24). One network may appear once per scope (409 otherwise). client_id is the client’s internal id. GET …/ip-rules?client_id=… or ?tenant_wide=true filters the list; PATCH changes action, cidr or description; DELETE removes a rule. Changes take effect at once on every node.

Caveats

  • An allow rule turns its scope into an allow list. Adding your office network as allow locks out everyone else, including remote administrators signing in to the console through this tenant. Keep a way in (another tenant, such as master, holds global administrators) before creating the first tenant-wide allow rule.
  • Rules apply to the endpoint families above, not to the admin API, the account API, SCIM, discovery or JWKS.
  • The address is the one described in The client address; with TRUSTED_PROXIES wrong, every request appears to come from the proxy.

Account lockout and address throttling

settings.lockout (console: Settings → Passwords & lockout) protects password sign-in against guessing:

SettingDefaultMeaning
max_failures10consecutive wrong passwords before the account is locked; 0 = off
lock_minutes15how long the lock lasts
ip_max_failures100failed password attempts from one address within the window before that address is throttled; 0 = off
ip_window_minutes15the window, and the Retry-After of a throttled address

A successful sign-in resets the account’s failure count. Reaching max_failures raises user.locked; while locked, even the right password is refused. An administrator can lift the lock early with POST /admin/tenants/{slug}/users/{user}/unlock or the Unlock button on the user’s page. The address throttle answers 429 before the password is even checked. Attempts are recorded as login attempts (and the login.failed event) with a reason: invalid_credentials, locked or disabled.

A lockout policy is a trade-off: max_failures lets anyone who knows a username lock that user out. Rely on the address throttle, the request ceilings and CAPTCHA for volume, and keep max_failures high enough not to become a denial-of-service tool.

CAPTCHA

rIDM supports Cloudflare Turnstile and hCaptcha. A tenant has at most one provider:

curl -X PUT https://id.example.com/admin/tenants/acme/captcha \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"provider": "turnstile", "site_key": "0x4AAAAAAA…", "secret": "0x4AAAAAAA…"}'

provider is turnstile or h_captcha; site_key and secret are required; verify_url optionally replaces the vendor’s siteverify endpoint (for a proxy or tests). The configuration is stored encrypted; GET …/captcha returns it with the secret replaced by secret_set: true (or 204 when none is configured) and DELETE removes it. In the console it sits under Settings → Passwords & lockout, beside the two settings below, and saves once both keys are present. The permissions are ridm:tenants:read / ridm:tenants:write.

When a challenge is demanded is settings.captcha:

SettingDefaultMeaning
after_failures3demand a challenge once a sign-in flow has this many failed attempts; 0 = never
on_registrationtruedemand a challenge on every self-registration

The flow state tells the sign-in page when a challenge is due (provider and site key), and the page renders the widget. A missing token is refused as a validation error on captcha_token (captcha_required), a rejected one as captcha_failed. If the vendor cannot be reached, the attempt fails with 503 rather than skipping the check.

Without a configured provider there is no challenge at all, whatever settings.captcha says. settings.captcha.on_registration is the only registration switch: an earlier settings.registration.captcha flag was removed (a migration carried any true value over into captcha.on_registration, and a PATCH that names it is refused with 400).

The verify_url, like every URL a tenant administrator chooses, is reached under the outbound request policy.

Breached-password check

With settings.password.check_breached on (default off; console: Settings → Passwords & lockout), every password a user or administrator sets (registration, recovery, forced change, the account console, an administrator’s reset, and imports carrying a plaintext password) is looked up in a Have I Been Pwned compatible range API. Only the first five hex digits of the password’s SHA-1 leave the server; the request asks for a padded answer and the match is made locally. A password that appears in the corpus is refused as a validation error on password.

VariableDefaultMeaning
BREACH_CHECK_URLhttps://api.pwnedpasswords.com/range/the range endpoint; off, none, false or empty disables the check for the whole deployment (air-gapped installs), and the tenant toggle is then inert

A lookup that fails (timeout after 5 seconds, an error status) is logged and lets the password through, so an outage of the corpus never blocks sign-ups or resets. To keep the check without reaching the internet, host a mirror of the range API and point BREACH_CHECK_URL at it.

Cross-origin requests

One rule set decides which browser origins may call the API, with credentials:

OriginAdmitted on
the UI’s and the API’s own origins (UI_URL, PUBLIC_URL)everything
any origindiscovery, JWKS and branding of every tenant, WebFinger, /.well-known/security.txt
the tenant’s custom domain (https://<domain>)that tenant’s paths
any origin in the cors_origins of one of the tenant’s active clientsthat tenant’s paths
anything elsenothing: no CORS headers are sent, so the browser refuses the response

The union of client origins is cached per tenant and evicted on every client change. Because a preflight cannot say which client a /token call is for, client-authenticated endpoints check again once the client is known: a browser Origin that is neither the UI’s, the API’s, the custom domain’s nor registered on that very client is refused with invalid_request, even though the tenant as a whole admits it. Server-side clients send no Origin and are never checked. A single-page app therefore needs its origin in its own client’s cors_origins; see Registering clients.

Preflights are cached by browsers for ten minutes. Allowed methods are GET, POST, PUT, PATCH, DELETE and OPTIONS; allowed request headers are Authorization, Content-Type, Accept, Accept-Language, If-None-Match, DPoP and X-Requested-With; Retry-After, WWW-Authenticate, Location, ETag, DPoP-Nonce and the three RateLimit-* headers are exposed.

Security headers

Every API response carries, unless the handler set its own:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policyno-referrer
Content-Security-Policydefault-src 'none'; frame-ancestors 'none' (under /docs, only frame-ancestors 'none', since Swagger UI needs its scripts)
Strict-Transport-Securitymax-age=<HSTS_MAX_AGE>; includeSubDomains, only when PUBLIC_URL is https

The HTML pages the API renders itself (authorization errors, refusals) use default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'. HSTS_MAX_AGE defaults to 63072000 (two years); 0 turns the header off. Because of includeSubDomains, only serve PUBLIC_URL over https on a domain whose subdomains are all https too.

The UI is a static export with its own Content-Security-Policy meta tag on every page: scripts only from the page’s origin, the CAPTCHA vendors and the page’s own inline scripts by SHA-256 hash. A meta tag cannot restrict framing, so the server adds that when it serves the embedded UI: X-Frame-Options: DENY and frame-ancestors 'none' on every page except /login/, which gets SAMEORIGIN and frame-ancestors 'self' because the admin console shows it in an iframe as the live branding preview (the console pages’ own policy is the only one that allows framing this origin). A static host serving ui/out itself must send the same headers, and Strict-Transport-Security.

Cookies set by the API are HttpOnly, SameSite=Lax, host-only with Path=/, named per tenant (__Host-ridm_session_acme, __Host-ridm_device_acme), and Secure unless COOKIE_SECURE=false, which is only for plain-http development and drops the __Host- prefix. See TLS and reverse proxies.

Outbound request policy

Several URLs rIDM connects to are chosen by tenant administrators or by whoever registers a client, not by the operator. Left unchecked, any of them could point the server at its own network (server-side request forgery). These are covered:

TargetChosen by
Webhook receiverstenant administrator
Back-channel logout URIsclient registration
Client jwks_uri (for private_key_jwt)client registration
Identity-provider discovery, token, userinfo and JWKS endpointstenant administrator
A tenant’s HTTP email gateway and SMS webhooktenant administrator
A tenant’s CAPTCHA verify_urltenant administrator
A tenant’s SMTP servertenant administrator

For all of them:

  • Names resolve to public addresses only. The check is made when the connection is made, not only when the URL is saved, so a name that later resolves to a private address (DNS rebinding) is refused too. Refused ranges: private (RFC 1918), loopback, link-local (including 169.254.169.254), unspecified, broadcast, multicast, carrier-grade NAT (100.64.0.0/10), unique-local IPv6 (fc00::/7), documentation, benchmarking and reserved ranges, and IPv6 forms that embed one of them (IPv4-mapped, IPv4-compatible, NAT64, 6to4). A name with no public address left fails.
  • IP literals in a private range are refused before any request is sent; webhook URLs and tenant SMTP hosts that are private literals are refused when saved (400).
  • Redirects are not followed, since a redirect would be a second, unchecked target.
  • HTTP_PROXY and HTTPS_PROXY are ignored for these requests, because a proxy would resolve the name out of the check’s sight.
  • Development allowance: loopback literals (127.0.0.0/8, ::1) and the host name localhost may reach loopback. Any other name resolving to loopback is refused.
  • Networks the operator opens: OUTBOUND_ALLOW_NETWORKS lists private networks these requests may reach anyway, for applications that live on an internal network (a back-channel logout endpoint at https://orders.internal, a webhook receiver in the same VPC). An address inside one counts as public, literal or resolved. Open the narrowest networks that hold those applications, not the whole private range: a tenant administrator can point a webhook at anything inside them.
OUTBOUND_ALLOW_NETWORKS=10.20.0.0/16,192.168.40.12

A tenant’s SMTP server is resolved just before each connection; rIDM connects to the first allowed address it found (only that one is tried) while TLS still verifies the certificate against the configured host name.

The URLs the operator sets in the environment are trusted and not filtered: the deployment’s SMTP_HOST (a private relay is the usual case), AUDIT_SINK_URL, BREACH_CHECK_URL and OTEL_EXPORTER_OTLP_ENDPOINT. An egress firewall remains the place to restrict where nodes may connect at all; see Deployment overview.

HTTP endpoints

Every HTTP route the server mounts, apart from the admin API (/admin/...), which has its own reference. Paths are relative to PUBLIC_URL; examples use https://id.example.com and the tenant acme, whose issuer is https://id.example.com/t/acme.

Tenant endpoints live under /t/{slug}. A tenant with a custom domain also answers every one of them on its own host without the prefix (https://login.acme.example/token as well as https://id.example.com/t/acme/token); both forms report the same issuer. A custom host serves that tenant only: besides the tenant’s routes it answers /healthz, /readyz, /.well-known/webfinger, /.well-known/security.txt and the tenant’s own /t/{slug}/… and /scim/v2/{slug}/… paths, and 404 for everything else (the admin API, /metrics, /docs, /openapi.json, other tenants). The router lives in api/src/lib.rs.

The Limit column names the rate-limit family a route counts against (see Rate limits, IP rules and CAPTCHA). Tenant IP rules are checked by the same guard, so routes marked — are subject to neither.

FamilyTenant settingRefusal format
tokenrate_limits.token_per_ip, token_per_clientOAuth JSON ({"error": "slow_down"})
authorizerate_limits.authorize_per_ipHTML page for /authorize, OAuth JSON for /par and /register
flowsrate_limits.flows_per_ipRFC 9457 problem document; HTML page for brokering

Global

MethodPathAuthLimitPurpose
GET/healthznone—Liveness: {"status": "ok", "version": "..."}. Never touches a dependency.
GET/readyznone—Readiness: pings Postgres and Valkey and checks the in-process event queues; 200 with {"status": "ok", "checks": {"database": "ok", "cache": "ok", "events": "ok"}}, or 503 with "status": "degraded" and the failing check as "fail" ("events": "saturated" when an event queue is 80% full).
GET/metricsBearer <METRICS_TOKEN> when that variable is set, otherwise none—Prometheus exposition (text/plain; version=0.0.4). See Observability.
GET/openapi.jsonnone—The admin and account API’s OpenAPI 3 document. Always served.
GET/docsnone—Swagger UI over /openapi.json; mounted only when DOCS_ENABLED=true.
GET/.well-known/webfingernone—Issuer discovery (OIDC Discovery 1.0 §2, RFC 7033). resource is acct:user@domain (the domain must be in a tenant’s settings.discovery.email_domains) or an issuer URL or any URL beneath it.
GET/.well-known/security.txtnone—Where to report vulnerabilities in this deployment (RFC 9116), as SECURITY_CONTACT or SECURITY_TXT configures it; 404 when neither is set. See Server configuration.

The container image’s HEALTHCHECK runs ridm-api --healthcheck inside the container, which probes /healthz at BIND_ADDR (loopback for a wildcard address), over HTTPS when TLS_CERT is set.

Tenant OpenID Connect and OAuth 2.0

MethodPathAuthLimitPurposeSpecification
GET/t/{slug}/.well-known/openid-configurationnone—Provider metadata (below). ETag, Cache-Control: public, max-age=300, must-revalidate, 304 on If-None-Match, Access-Control-Allow-Origin: *.OIDC Discovery 1.0 §3, RFC 8414
GET/t/{slug}/.well-known/jwks.jsonnone—The tenant’s published signing keys (pending, active and retiring; revoked keys are removed at once). Same caching as discovery.RFC 7517
GET, POST/t/{slug}/authorizeclient identified by client_idauthorizeAuthorization endpoint. POST takes application/x-www-form-urlencoded. Client and redirect_uri problems render an HTML error page; everything else is returned to the client’s redirect URI with state and iss.RFC 6749 §4.1, OIDC Core §3.1.2, RFC 7636, RFC 9207, RFC 9101
GET/t/{slug}/authorize/denied/{id}none (a one-time id)authorizeDelivers access_denied (with state and iss) after a cancelled, declined or refused sign-in to a client whose response mode is not plain query: a fragment, a form_post page or a signed JARM response. A query client is sent to its redirect URI directly.RFC 6749 §4.1.2.1, RFC 9207
POST/t/{slug}/parclient authenticationauthorizePushed authorization request; answers 201 {"request_uri": "urn:ietf:params:oauth:request_uri:...", "expires_in": ...} for a single use at /authorize.RFC 9126
POST/t/{slug}/tokenclient authenticationtokenToken endpoint, form-encoded only. Grants below. Optional DPoP header.RFC 6749 §3.2, RFC 8693, RFC 8628, RFC 9449, RFC 8707
POST/t/{slug}/device_authorizationclient authenticationtokenStarts the device authorization grant; the client must be allowed the device-code grant.RFC 8628 §3.1
POST/t/{slug}/bc-authorizeclient authentication (confidential clients only)tokenBackchannel authentication: names the user (login_hint or id_token_hint) and answers {"auth_req_id", "expires_in", "interval"}; the user approves in the account console. See Backchannel sign-in and FAPI 2.0.OpenID CIBA Core 1.0 §7
GET, POST/t/{slug}/userinfoaccess token, JWT or opaque (Authorization: Bearer or DPoP, or an access_token form field on POST)tokenClaims about the token’s subject; the token must carry openid. See Token claims.OIDC Core §5.3, RFC 6750
GET/t/{slug}/featuresaccess token of the tenant (Bearer or DPoP)tokenThe feature flags that are on for the token’s organization (org_id), or tenant-wide: {"features": [...], "org_id": ...}.—
POST/t/{slug}/introspectclient authentication (confidential clients only)tokenToken introspection for refresh tokens (rt_...), personal access tokens (rpat_...) and access tokens, JWT or opaque (at_...); the only way a resource server learns what an opaque token stands for. Unknown, expired, foreign or inactive tokens, and ID tokens, answer {"active": false}.RFC 7662
POST/t/{slug}/revokeclient authenticationtokenRevokes a refresh token with its whole family, or an access token: its jti is denylisted until it expires, and an opaque token’s entry is dropped. Unknown tokens still answer 200.RFC 7009
GET, POST/t/{slug}/end_sessionnone (id_token_hint recommended)—RP-initiated logout. With an id_token_hint matching the browser session the logout is immediate; otherwise the UI asks the user first.OIDC RP-Initiated Logout 1.0
GET/t/{slug}/end_session/{flow}none—State of a pending logout for the UI’s logout page (who is being signed out of, and the CSRF token).
POST/t/{slug}/end_session/confirmCSRF token of the logout flow—The user’s confirmation from the logout page. Back-channel logout tokens are sent and front-channel logout URLs (with iss and sid) are returned for the page to load.OIDC Back-Channel Logout 1.0, Front-Channel Logout 1.0
POST/t/{slug}/registernone, or an initial access token (Bearer iat_..., issued by an administrator; see Registering clients), per settings.dcr.modeauthorizeDynamic client registration. 403 access_denied when dcr.mode is disabled; 401 invalid_token when an initial access token is required and missing, unknown, revoked, expired or used up. Answers 201 with the metadata, registration_access_token and registration_client_uri.RFC 7591
GET, PUT, DELETE/t/{slug}/register/{client_id}registration access token (Bearer)authorizeRead, replace or delete a dynamically registered client.RFC 7592
GET/t/{slug}/brandingnone—Public description of the tenant for the sign-in pages: name, theme, links, locales and which sign-in options exist. Cache-Control: public, max-age=60.

Client authentication

/token, /par, /device_authorization, /bc-authorize, /introspect and /revoke authenticate the client with the method registered for it; any other method is invalid_client. A client under the FAPI 2.0 profile must use private_key_jwt with the issuer as the assertion’s aud.

MethodHow
nonepublic client: client_id in the body only (PKCE protects the code)
client_secret_basicAuthorization: Basic base64(client_id:secret)
client_secret_postclient_id and client_secret in the form body
private_key_jwtclient_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and a client_assertion JWT signed with a key from the client’s jwks or jwks_uri

| tls_client_auth | client_id in the body, over a mutual-TLS connection with a certificate from one of the tenant’s trusted authorities that carries the client’s registered subject (Mutual TLS) | | self_signed_tls_client_auth | client_id in the body, over a mutual-TLS connection with a certificate registered in the client’s jwks or jwks_uri (x5c) |

The two mutual-TLS methods are offered when the deployment can receive client certificates (MTLS_BIND or CLIENT_CERT_HEADER); discovery then lists them, tls_client_certificate_bound_access_tokens: true and, with MTLS_PUBLIC_URL, the mtls_endpoint_aliases where they are used. client_secret_jwt is not implemented.

Grant types at /token

grant_typeNotes
authorization_codePKCE S256 only; plain is refused. Replaying a code revokes everything the first exchange produced. A code whose browser session was signed out before the exchange is refused (invalid_grant). resource may only pick among the resources named at /authorize (invalid_target otherwise).
refresh_tokenRotation on every use with reuse detection (not for a FAPI 2.0 client, which keeps its refresh token). scope and resource may narrow the original grant, never widen it (invalid_scope, invalid_target, checked before the token is spent). Without offline_access the token ends with its browser session; each refresh extends the session’s idle window.
client_credentialsTokens for the client itself, or for its service-account user when one exists. openid and offline_access are refused. With no scope, the tenant’s default scopes the client may hold.
urn:ietf:params:oauth:grant-type:device_codeRFC 8628. /device_authorization answers a device_code, a user_code (XXXX-XXXX from letters that are hard to confuse), verification_uri (the /device/ page), verification_uri_complete, expires_in (600) and interval (5). The user’s code becomes a login flow for the device’s client, so sign-in, second step, profile completion, terms and consent all apply. Polling answers authorization_pending, slow_down (polled faster than the interval, which then grows by five seconds), access_denied or expired_token until the user decides, then the tokens exactly once. Wrong user codes are limited to ten per address per ten minutes; every code leaves a device_codes audit row.
urn:openid:params:grant-type:cibaauth_req_id from /bc-authorize. The same answers as the device grant while the user decides; slow_down applies only to a request still undecided, so a pinged client collects at once. The tokens are handed over once.
urn:ietf:params:oauth:grant-type:token-exchangesubject_token (type urn:ietf:params:oauth:token-type:access_token or ...:jwt; an opaque at_... token must be sent as access_token), optional actor_token (same rule), audience, resource, scope. No refresh token; the result reports issued_token_type. A client registered for opaque tokens may not request ...:jwt.

A client may only use the grants in its allowed_grants; a known grant it is not allowed is unauthorized_client, an unknown one unsupported_grant_type. Audiences come from resource parameters (RFC 8707; token exchange also takes audience), or from the client’s allowed_audiences when none is requested, plus the resource server of any requested scope bound to one; see Resource servers, scopes and permissions and Token claims.

The response is {"access_token", "token_type", "expires_in", "refresh_token"?, "id_token"?, "scope"?, "issued_token_type"?} with Cache-Control: no-store. token_type is DPoP when a DPoP proof bound the tokens, otherwise Bearer (a certificate-bound token is a Bearer token, checked against the connection’s certificate).

Discovery document

The discovery document advertises exactly what the build implements (api/src/oidc/discovery.rs). Endpoints are {issuer}/authorize, /token, /.well-known/jwks.json, /userinfo, /introspect, /revoke, /end_session, /par, /device_authorization and /bc-authorize (backchannel_authentication_endpoint); registration_endpoint ({issuer}/register) appears only when the tenant’s dcr.mode is not disabled.

MemberValue
scopes_supportedthe tenant’s scopes (standard plus custom)
response_types_supportedcode
response_modes_supportedquery, fragment, jwt, query.jwt, fragment.jwt, form_post.jwt
grant_types_supportedauthorization_code, refresh_token, client_credentials, urn:ietf:params:oauth:grant-type:device_code, urn:ietf:params:oauth:grant-type:token-exchange, urn:openid:params:grant-type:ciba
backchannel_token_delivery_modes_supportedpoll, ping
backchannel_user_code_parameter_supportedfalse
subject_types_supportedpublic, pairwise
id_token_signing_alg_values_supportedRS256, RS384, RS512, ES256, EdDSA
id_token_encryption_alg_values_supportedRSA-OAEP-256, RSA-OAEP
id_token_encryption_enc_values_supportedA256GCM, A128GCM
token_endpoint_auth_methods_supported (also introspection_... and revocation_...)none, client_secret_basic, client_secret_post, private_key_jwt, and tls_client_auth, self_signed_tls_client_auth when mutual TLS is configured
tls_client_certificate_bound_access_tokenstrue when mutual TLS is configured, absent otherwise (RFC 8705 §3.3)
mtls_endpoint_aliaseswith MTLS_PUBLIC_URL: token_endpoint, userinfo_endpoint, introspection_endpoint, revocation_endpoint, pushed_authorization_request_endpoint, device_authorization_endpoint, backchannel_authentication_endpoint under {MTLS_PUBLIC_URL}/t/{slug} (RFC 8705 §5)
token_endpoint_auth_signing_alg_values_supportedRS256, RS384, RS512, ES256, EdDSA
request_object_signing_alg_values_supportedthe same five
authorization_signing_alg_values_supported (JARM)the same five
dpop_signing_alg_values_supportedES256, ES384, RS256, RS384, RS512, PS256, PS384, PS512, EdDSA: the algorithms the DPoP verifier accepts
code_challenge_methods_supportedS256
acr_values_supportedurn:ridm:acr:single, urn:ridm:acr:mfa
claims_supportedsub, iss, aud, exp, iat, auth_time, nonce, acr, amr, azp, sid, name, given_name, family_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, updated_at, email, email_verified, phone_number, phone_number_verified, address, locale
claim_types_supportednormal
prompt_values_supportednone, login, consent, select_account, create
ui_locales_supportedthe tenant’s settings.locale.supported
claims_parameter_supportedfalse
request_parameter_supportedtrue
request_uri_parameter_supportedtrue (PAR request_uri values only)
require_request_uri_registrationfalse
require_pushed_authorization_requestsfalse (tenant-wide; a client can be registered to require PAR)
authorization_response_iss_parameter_supportedtrue
backchannel_logout_supported, backchannel_logout_session_supportedtrue
frontchannel_logout_supported, frontchannel_logout_session_supportedtrue
op_policy_uri, op_tos_urithe tenant’s registration.privacy_url and registration.terms_url, when set
service_documentationthe tenant’s branding.support_url, when set

form_post is not offered as a plain response mode, only as form_post.jwt. Userinfo responses are plain JSON, so userinfo_signing_alg_values_supported is absent. Server-provided DPoP nonces and dpop_jkt at /authorize are not implemented.

Browser flow API

The sign-in pages drive a login flow through these routes. A flow is created by /authorize (or by device verification) and identified by {id}. Every mutating step carries the flow’s csrf token in its JSON body; a wrong one is 403. Responses carry the public flow state; when its stage is done the page navigates to finish_url. All are in the flows limit family and answer errors as RFC 9457 problems (see Errors). The flow is described in Sign-in flows and sessions.

MethodPathPurpose
GET/t/{slug}/flows/{id}Current flow state.
POST/t/{slug}/flows/{id}/passwordIdentifier and password.
POST/t/{slug}/flows/{id}/password-changeNew password when one is required (expired or flagged).
POST/t/{slug}/flows/{id}/registerSelf-registration (when settings.registration.enabled).
POST/t/{slug}/flows/{id}/magic-linkSend a sign-in link by email.
POST/t/{slug}/flows/{id}/magic-link/verifyRedeem the link’s token.
POST/t/{slug}/flows/{id}/email-otpSend a one-time sign-in code by email.
POST/t/{slug}/flows/{id}/email-otp/verifyCheck the emailed code.
POST/t/{slug}/flows/{id}/sms-otpSend a one-time sign-in code by SMS.
POST/t/{slug}/flows/{id}/sms-otp/verifyCheck the texted code.
POST/t/{slug}/flows/{id}/passkey/startPasskey sign-in: discoverable-credential challenge.
POST/t/{slug}/flows/{id}/passkey/finishPasskey sign-in: verify the assertion.
POST/t/{slug}/flows/{id}/kerberosKerberos desktop sign-in (HTTP Negotiate): 401 challenge, 204 when an automatic attempt is not for this client, the flow’s state with a mutual-authentication token once a ticket is accepted. See Kerberos desktop sign-in.
POST/t/{slug}/flows/{id}/mfa/totp/enrollSecond step: start authenticator-app enrolment.
POST/t/{slug}/flows/{id}/mfa/totp/confirmSecond step: confirm the first code.
POST/t/{slug}/flows/{id}/mfa/verifySecond step: TOTP or recovery code.
POST/t/{slug}/flows/{id}/mfa/passkey/registerSecond step: passkey creation options.
POST/t/{slug}/flows/{id}/mfa/passkey/register/finishSecond step: store the new passkey.
POST/t/{slug}/flows/{id}/mfa/passkey/startSecond step: passkey challenge.
POST/t/{slug}/flows/{id}/mfa/passkey/finishSecond step: verify the passkey.
POST/t/{slug}/flows/{id}/mfa/email/enroll, .../confirmSecond step: enrol email codes.
POST/t/{slug}/flows/{id}/mfa/email/send, .../verifySecond step: send and check an email code.
POST/t/{slug}/flows/{id}/mfa/sms/enroll, .../confirmSecond step: enrol SMS codes (phone in E.164).
POST/t/{slug}/flows/{id}/mfa/sms/send, .../verifySecond step: send and check an SMS code.
POST/t/{slug}/flows/{id}/profileComplete required profile attributes.
POST/t/{slug}/flows/{id}/termsAccept the terms (when settings.registration.require_terms).
POST/t/{slug}/flows/{id}/consentGrant or deny the requested scopes.
POST/t/{slug}/flows/{id}/cancelAbandon the flow: answers {"stage": "cancelled", "redirect_to"}, the client’s redirect URI with error=access_denied.
GET/t/{slug}/flows/{id}/finishBrowser navigation at the end: issues the authorization code (or approves the device code) and redirects to the client.

Registration, invitations, recovery, verification and devices

All in the flows limit family; errors are problem documents.

MethodPathPurpose
GET/t/{slug}/invitations/{token}Look up an invitation by the token from its email.
POST/t/{slug}/invitations/{token}Accept it: set up the account and sign in.
POST/t/{slug}/recovery/passwordRequest a password-reset message for an identifier. The answer does not reveal whether the account exists.
POST/t/{slug}/recovery/password/confirmSet a new password with the reset token.
POST/t/{slug}/verification/email/resendSend the email-verification message again.
POST/t/{slug}/verification/email/confirmConfirm an address with the token from the link; when the link belongs to a flow waiting for verification the user is signed in and the flow state returned.
POST/t/{slug}/device/verifyThe user’s side of the device grant: {user_code} becomes a login flow for the device’s client.

SAML identity provider

Every tenant is a SAML 2.0 IdP whose entity ID is its issuer. See SAML identity provider.

MethodPathAuthLimitPurposeSpec
GET/t/{slug}/saml/metadatanoneauthorizeThe IdP’s metadata: SSO and SLO endpoints (both bindings), NameID formats, and every SAML signing certificate, the active one first.SAML Metadata §2
GET, POST/t/{slug}/saml/ssothe SP’s signature when it has certificates registeredauthorizeAuthnRequest by HTTP-Redirect (query) or HTTP-POST (form). A POST is checked, kept for ten minutes and resumed at ?continue= by a same-site GET. Answers with a signed Response posted to the SP’s consumer URL.SAML Bindings §3.4, §3.5; Profiles §4.1
GET/t/{slug}/saml/initthe user’s sessionauthorizeIdP-initiated sign-in: ?sp= entity ID or client id, optional RelayState; only for SPs with allow_idp_initiated.Profiles §4.1.5
GET, POST/t/{slug}/saml/slothe SP’s signature when it has certificates registeredauthorizeLogoutRequest from an SP (ends the session, walks the other SPs, answers), or an SP’s LogoutResponse during such a walk (RelayState names it).Profiles §4.4
GET/t/{slug}/saml/slo/chain/{id}none (a one-time id)authorizeContinues a logout that started at rIDM through the session’s SAML SPs.
GET/t/{slug}/saml/respond/{ticket}none (a one-time id)authorizePosts a failure Response (RequestDenied, AuthnFailed) to the SP after a cancelled, refused or blocked sign-in.

Identity brokering

MethodPathAuthLimitPurpose
GET/t/{slug}/broker/{alias}/startnone (?flow= a login flow, or ?ticket= a link ticket from the account API)flowsSends the browser to the upstream provider named by alias.
GET, POST/t/{slug}/broker/{alias}/callbackstate of the upstream requestflowsReceives the upstream response (POST for providers that post it back), then continues the login flow or finishes linking.
GET/t/{slug}/broker/{alias}/saml/metadatanoneflowsrIDM’s SP metadata for a SAML provider (its URL is the SP entity ID).
POST/t/{slug}/broker/{alias}/saml/acsthe IdP’s signature, RelayStateflowsAssertion consumer service (HTTP-POST): checks the Response, then redirects to a same-site ?continue= GET, bound to the browser that started the sign-in, which continues the flow or the link.
GET, POST/t/{slug}/broker/{alias}/saml/slothe IdP’s signatureflowsThe IdP’s LogoutRequest (must be signed; ends the sessions it brokered) or its LogoutResponse to rIDM’s.
GET/t/{slug}/broker/{alias}/saml/slo/out/{id}, …/slo/done/{id}none (one-time ids)flowsSend rIDM’s LogoutRequest at the end of a sign-out that started here; answer the IdP’s once the downstream SAML SPs had their turn.

Register the callback URL with the upstream provider; the admin API reports it as callback_url on every identity provider (for SAML, the assertion consumer service, with the rest under saml_sp). See Identity brokering.

Account API

The self-service API for a signed-in user, under /t/{slug}/account. It takes a bearer (or DPoP-bound) access token, JWT or opaque, issued by that tenant with the urn:ridm:account audience (the built-in ridm-account-console client obtains one), or a personal access token with the account scope. It acts only on the token’s own subject. Errors are RFC 9457 problems; security changes may answer 403 with type urn:ridm:error:reauthentication-required (see Errors). These routes are in the OpenAPI document under the account tag and are not rate limited by the guard.

MethodPathPurpose
GET, DELETE/t/{slug}/account/meIdentity with the session’s auth_time, acr and amr; DELETE deletes the account (when settings.account.self_deletion).
GET, PATCH/t/{slug}/account/profileProfile attributes the schema lets the user see and edit.
GET, PUT/t/{slug}/account/passwordPassword status; change it.
POST, DELETE/t/{slug}/account/email/changeStart (or cancel) an email change.
POST/t/{slug}/account/email/confirmConfirm the new address with its code.
POST, DELETE/t/{slug}/account/phone/changeStart (or cancel) a phone change.
POST/t/{slug}/account/phone/confirmConfirm the new number.
DELETE/t/{slug}/account/phoneRemove the phone number.
GET/t/{slug}/account/mfaEnrolled second factors and remaining recovery codes.
POST/t/{slug}/account/mfa/totp/enroll, .../confirmEnrol an authenticator app.
POST/t/{slug}/account/mfa/email/enroll, .../confirmEnrol email codes.
POST/t/{slug}/account/mfa/sms/enroll, .../confirmEnrol SMS codes.
POST/t/{slug}/account/mfa/passkey/register, .../register/finishRegister a passkey.
POST/t/{slug}/account/mfa/recovery-codesReplace the recovery codes (shown once).
DELETE/t/{slug}/account/mfa/credentials/{credential_id}Remove a factor; removing the last second factor removes the recovery codes too.
GET, DELETE/t/{slug}/account/devicesTrusted devices; forget all.
DELETE/t/{slug}/account/devices/{device_id}Forget one trusted device.
GET, DELETE/t/{slug}/account/sessionsLive sessions; end all of them (?keep_current=true spares the calling session), with back-channel logout.
DELETE/t/{slug}/account/sessions/{session_id}End one session, with back-channel logout.
GET/t/{slug}/account/appsApplications the user consented to.
DELETE/t/{slug}/account/apps/{client_id}Withdraw consent and the application’s refresh tokens.
GET/t/{slug}/account/backchannel-requestsBackchannel (CIBA) sign-in requests waiting on the user.
POST/t/{slug}/account/backchannel-requests/{id}/approve, .../denyAnswer one; an approval is remembered as consent. Refused (403) in an impersonated session; approving also needs a sign-in session, so a personal access token cannot.
GET/t/{slug}/account/identitiesLinked upstream identities.
POST/t/{slug}/account/identities/linkStart linking an upstream identity: answers {"url"}, a /broker/{alias}/start?ticket=... address to send the browser to.
DELETE/t/{slug}/account/identities/{idp_id}Unlink one.
GET, POST/t/{slug}/account/tokensPersonal access tokens; mint one (shown once).
DELETE/t/{slug}/account/tokens/{token_id}Revoke a personal access token.
GET/t/{slug}/account/exportEverything held about the user as one JSON document.

A few behaviours the table leaves out:

  • Password change. PUT password needs the current password while one is set; sign_out_others: true ends every other session, with back-channel logout.
  • Contact changes. A six-digit code goes to the new address (or number); the right code moves the account over, verified, and the previous address is told. An address another account uses is a 409. Codes are hashed and single-use, good for ten minutes and five attempts, with three sends per ten minutes; a repeat inside twenty seconds reuses the pending code. The phone number cannot be removed while it backs an SMS second step (400).
  • Linking. The browser returns from the broker to the account console with ?linked=1 or ?link_error=<code>.
  • Personal access tokens. GET tokens also lists the scopes a new token may carry (account, plus the admin permissions the user holds) and the tenant’s maximum lifetime.
  • Export and deletion. The export holds the record, credential metadata, devices, sessions, consents, roles, groups and the audit trail, never secret material. DELETE me needs the username typed again as confirmation; see deletion for what follows.

Request and response bodies are in the rendered reference.

SCIM 2.0

A SCIM service provider per tenant at /scim/v2/{slug} (RFC 7643, RFC 7644). Every request needs Authorization: Bearer rscim_..., a provisioning token minted for that tenant (see SCIM provisioning). Bodies are application/scim+json; errors are SCIM error documents (Errors). Not rate limited by the guard.

MethodPathPurpose
GET/scim/v2/{slug}/ServiceProviderConfigSupported features.
GET/scim/v2/{slug}/ResourceTypesUser and Group.
GET/scim/v2/{slug}/SchemasCore schemas.
GET, POST/scim/v2/{slug}/UsersList (filter, startIndex, count up to 200) or create.
GET, PUT, PATCH, DELETE/scim/v2/{slug}/Users/{id}Read, replace, patch, soft-delete.
GET, POST/scim/v2/{slug}/GroupsList or create.
GET, PUT, PATCH, DELETE/scim/v2/{slug}/Groups/{id}Read, replace, patch, delete.

Headers on every response

A security-headers layer adds the browser hardening headers (and Strict-Transport-Security when PUBLIC_URL is https and HSTS_MAX_AGE is not 0); CORS is answered for the UI’s and the API’s own origins and for each client’s registered cors_origins. Rate-limited families add RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.

Admin API (OpenAPI)

Everything the admin console does, it does through the admin API under /admin, and the same API is there for your own automation. This page describes how the API is organised and the conventions every operation shares. The operation-by-operation reference is rendered from the OpenAPI document:

  • Rendered reference: every path, parameter, request body, response schema and required permission.
  • openapi.json: the raw OpenAPI 3.1 document, the same one the server serves at GET /openapi.json.

The document also covers the self-service account API (/t/{slug}/account/..., tag account), which follows the same conventions but authenticates differently; see HTTP endpoints.

Where the document comes from

The document is derived from the admin routers themselves with utoipa (api/src/openapi.rs), so an undocumented route fails the build, and a test keeps the committed api/openapi.json equal to what the binary produces. Three ways to get it:

curl -s https://id.example.com/openapi.json          # from a running server, no auth
cargo run -p ridm-api -- openapi > openapi.json      # from source, no database needed
docker run --rm <image> openapi > openapi.json       # from the container image

info.version is the server’s crate version (0.4.1 today). Swagger UI is served at /docs when DOCS_ENABLED=true.

Organisation

Operations are grouped by tag. Operation ids are the handler names prefixed with their tag (users_list, clients_create), unique across the document as client generators require.

TagCovers
auththe caller’s identity (GET /admin/me) and the permission catalogue with the built-in roles (GET /admin/permissions)
tenantstenants and their settings, CAPTCHA provider, profile schema, statistics
tenant_configconfiguration as code: GET .../export, POST .../import
clientsOAuth / OIDC clients, secrets, service accounts, registration access tokens, and the initial access tokens of dynamic registration (/admin/tenants/{slug}/dcr/initial-access-tokens)
usersusers and everything attached to them: password, sessions, credentials, trusted devices, roles, groups, consents, linked identities, personal access tokens, bulk import and export
groupsgroups, membership and group roles
organizationsorganizations, their members, domains, invitations and the roles granted inside them
rolesroles, composites, permission grants and holders
resource_serversresource servers (audiences) and their permissions
scopesOAuth scopes
mappersclaim mappers
keyssigning keys, and master-key rotation
invitationsinvitations
messagingemail and SMS delivery settings, templates, the delivery log
auditthe audit log, export and chain verification
webhookswebhooks and their deliveries
scimSCIM provisioning tokens
ip_rulesIP allow and deny rules
identity_providersupstream OpenID Connect and OAuth 2.0 providers
accountthe self-service account API

Almost everything is tenant-scoped under /admin/tenants/{slug}/.... The exceptions are /admin/me, /admin/permissions, /admin/tenants (list and create), /admin/master-key and the global audit chain at /admin/audit.

Authentication

Every admin operation takes a token in the Authorization header only (never a query or form parameter), with one exception, the download tickets below:

  • An access token whose aud includes urn:ridm:admin: a JWT (typ: at+jwt), or an opaque at_... token of a client registered with access_token_format: opaque. The token endpoint grants that audience only to clients that list it in allowed_audiences explicitly; it is never implied, even for clients whose audiences are otherwise unrestricted. The subject must be an active user, or the service-account user of a machine client. When the token was issued in a browser session, the session must still be alive, so signing out ends admin access before the token expires. A DPoP-bound token must be presented as Authorization: DPoP with a valid proof.
  • A personal access token (rpat_...) minted by the user in the account console, carrying admin permissions as its scopes. Its reach is the intersection of those scopes and what the user still holds.

The token may come from any tenant. A token issued by master is global: it may act on every tenant. A token from any other tenant reaches only that tenant; a request for another tenant is 403. Tenant lifecycle operations (creating and deleting tenants, master-key rotation, the global audit chain) need a global token.

Permissions are resolved from the user’s effective roles on every request (cached, and evicted whenever a role, group or grant changes), so revoking a role takes effect at once regardless of the permissions claim in the token. See Administrator access.

A token that names an organization (the org_id claim, put there by the sign-in) also carries the roles granted inside it. Those count only on the routes of that organization, under /admin/tenants/{slug}/organizations/{org}/...; everywhere else the caller is judged by their tenant-wide grants alone. See Organization administrators.

Download tickets

The exports (GET /admin/tenants/{slug}/export, .../users/export, .../audit/export and /admin/audit/export) can be larger than a browser should hold in memory, and a download made with fetch (the only way to send the header) must hold it. POST /admin/download-tickets with {"path": "<export path and query>"} answers 201 with {"url", "expires_in": 60}: the export’s URL with a download_ticket parameter that authenticates exactly one GET of exactly that path and query, within 60 seconds, as the caller. The browser’s download manager then fetches it and streams it to disk. The console downloads every export this way.

  • Only the export routes take a ticket; anything else, a different query, a second use or a method other than GET is 401, and a ticket presented anywhere is spent.
  • The caller must hold what the export requires (403 otherwise, when asking); redeeming runs the whole authentication again with the caller’s token, so a session signed out or a role revoked meanwhile ends it.
  • Tickets are 256 random bits, kept in Valkey only by their hash, and the answer carrying one is no-store. A DPoP-bound token’s ticket is redeemed without a proof (a navigation cannot make one); the request for the ticket was checked for it.

Permissions

Each operation requires one permission named ridm:<resource>:<action>; the rendered reference and GET /admin/permissions list which. The catalogue:

ResourcePermissions
tenantsridm:tenants:read, write, create, delete, export, import
usersridm:users:read, write
invitationsridm:invitations:read, write (also bulk user import)
groupsridm:groups:read, write
rolesridm:roles:read, write
clientsridm:clients:read, write
scopesridm:scopes:read, write
claim mappersridm:mappers:read, write
resource serversridm:resource-servers:read, write
identity providersridm:idps:read, write
keysridm:keys:read, write
messagingridm:messaging:read, write
auditridm:audit:read
webhooksridm:webhooks:read, write
SCIM tokensridm:scim:read, write

IP rules and the CAPTCHA provider fall under ridm:tenants:read / write. Custom roles may be granted wildcards (ridm:users:*, ridm:*). Every tenant is seeded with five immutable built-in roles:

RoleGrants
ridm:ownerevery permission
ridm:admineverything except ridm:tenants:create, ridm:tenants:delete, ridm:tenants:import
ridm:user-managerusers, invitations, groups and SCIM tokens; read tenant settings, roles and audit
ridm:client-managerclients, scopes, claim mappers, resource servers; read tenant settings, roles and audit
ridm:viewerevery *:read permission

An administrator can never grant a permission they do not hold themselves, whether through a role assignment, a composite, a group membership or an invitation.

Request and response conventions

ConventionDetail
Bodiesapplication/json, except user import, which also takes text/csv. Create and update bodies reject unknown fields, so a typo fails loudly instead of being ignored.
IdentifiersPath parameters take the resource’s UUID; clients also accept the public client_id, identity providers their alias.
CreatePOST to the collection, usually 201 with the created resource. A generated secret (client secret, webhook secret, SCIM token, DCR initial access token, temporary password) appears in that response only.
UpdatePATCH with the changed fields only. For tenant settings and client metadata the body is a JSON merge patch (RFC 7396): absent means unchanged, null clears a field or resets it to its default.
Replace, linkPUT on a sub-resource (.../roles/{role_id}, .../members/{user_id}) creates the link; DELETE removes it. Whole documents such as the profile schema and messaging settings are replaced with PUT.
DeleteDELETE, usually 204. Users are soft-deleted and purged after the tenant’s retention.
Secrets on readNever returned. Reads report secret_set, password_set or client_secret_set instead; omitting a secret on update keeps the stored one.
CachingResponses that carry secrets or exports are Cache-Control: no-store.

Pagination and filtering

Large collections (tenants, users, clients, invitations, the audit log) use keyset pagination:

GET /admin/tenants/acme/users?limit=100&cursor=eyJ0IjoiMjAyNi0wOS0xOFQwOTowMDowMFoiLCJpIjoi...
{
  "items": [ ... ],
  "next_cursor": "eyJ0IjoiMjAyNi0wOS0xOFQwOTowMToxMloiLCJpIjoi..."
}
ParameterMeaning
limitpage size, default 50, clamped to 1–500
cursorthe previous page’s next_cursor, passed back unchanged. It is opaque; a malformed one is 400

next_cursor is absent on the last page. Pages are stable while rows are inserted, because the cursor encodes the position (creation time and id) rather than an offset. Smaller collections (groups, roles, resource servers, scopes, claim mappers, keys, webhooks, IP rules, identity providers) return every item as one JSON array. The webhook delivery log and the message log take ?status= and ?limit= (default 50, at most 500) and return the most recent entries.

Filters are query parameters on the collection:

CollectionFilters
userssearch (username or email prefix), status, org_id, include_deleted
clientssearch (prefix of client_id or name)
invitationsopen_only
rolesclient_id, realm_only
claim-mappers, ip-rulesclient_id, tenant_wide
keysstatus (pending, active, retiring, revoked)
auditfrom, to, name (exact, or a prefix when it ends in . or *), actor_id, subject_id, user_id
statsdays (1–365, default 30)
users/export, audit/exportformat (json or csv)

Errors

Errors are RFC 9457 problem documents (application/problem+json) with a stable type URN such as urn:ridm:error:forbidden; see Errors. A missing or invalid token is 401 with WWW-Authenticate: Bearer realm="ridm-admin"; a valid token without the needed permission is 403 with detail naming it. The admin API is not rate limited by the request guard.

Example

A machine client for automation: create it with the client_credentials grant and urn:ridm:admin in allowed_audiences, give it a service account (PUT /admin/tenants/{slug}/clients/{client}/service-account), and assign that account a role such as ridm:user-manager. Then:

ISSUER=https://id.example.com/t/acme

TOKEN=$(curl -s "$ISSUER/token" \
  -u "provisioner:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=urn:ridm:admin | jq -r .access_token)

curl -s "https://id.example.com/admin/tenants/acme/users?search=alice&limit=20" \
  -H "Authorization: Bearer $TOKEN" | jq '.items[] | {id, username, email}'

For scripts run by a person, a personal access token is simpler: mint one in the account console with the admin permissions it needs and send it the same way (Authorization: Bearer rpat_...). The ridm command line wraps the common operations and accepts either kind of token.

Generating a client

Any OpenAPI 3.1 generator works against the document. The bundled UI generates its TypeScript types with openapi-typescript and calls the API through openapi-fetch:

cargo run -p ridm-api -- openapi > api/openapi.json
cd ui && npm run gen:api          # writes lib/api/openapi.d.ts

For another language, point your generator at the same file, for example:

npx @openapitools/openapi-generator-cli generate \
  -i api/openapi.json -g python -o ridm-admin-client

The document declares one security scheme, bearer (HTTP bearer, JWT format), and every operation requires it. A personal access token is sent under the same scheme.

Token claims

What each token rIDM issues carries, claim by claim, as built in api/src/services/tokens.rs, claims.rs and api/src/oidc/token.rs. For how tokens are used, see Tokens.

Every JWT is a JWS signed with one of the tenant’s active signing keys and names that key in the kid header. The algorithm is settings.keys.default_alg (RS256 by default), except for access tokens whose audience is a resource server with its own signing_alg (RS256, RS384, RS512, ES256 or EdDSA), which are signed with the tenant’s active key of that algorithm. Audiences that disagree on the algorithm cannot share one token (invalid_target; request them separately). The keys are published at {issuer}/.well-known/jwks.json.

JOSE typ headers

The typ header says what a JWT is, which is what stops one kind being replayed as another: rIDM’s own endpoints and ridm-auth refuse an access token whose typ is not at+jwt.

Tokentyp
Access tokenat+jwt (RFC 9068)
ID tokenJWT
Back-channel logout tokenlogout+jwt
JARM authorization response (response_mode=*.jwt)JWT

An ID token for a client with id_token_encryption configured is additionally encrypted as a JWE (RSA-OAEP-256 or RSA-OAEP, with A256GCM or A128GCM) to the client’s RSA key from its jwks (a key with use: enc is preferred).

Access token

Access tokens are JWTs unless the client is registered with access_token_format: opaque. Such a client receives at_<random> instead: the claims below are assembled the same way but kept in Valkey, keyed by the token’s SHA-256, until the token expires. Only /introspect reveals them to a resource server; /userinfo, /revoke, token exchange and the account and admin APIs accept the opaque form directly. ridm-auth validates JWTs only. See Registering clients.

ClaimPresentValue
issalwaysthe tenant’s issuer: {PUBLIC_URL}/t/{slug}, or https://{custom_domain}
subalwaysthe user’s subject (below); for client_credentials without a service account, the client_id
audalwaysthe resource servers the token is for (a string when one, an array when several), plus audiences from audience claim mappers; the client_id when there is none
client_idalwaysthe client the token was issued to
azpalwaysthe same client_id
iat, nbfalwaysissue time (seconds since the epoch)
expalwaysexpiry; see lifetimes
jtialwaysa UUIDv7; revocation denylists it until exp
tidalwaysthe tenant’s UUID. A relying party serving several tenants of one deployment keys on it
org_idthe sign-in acts in an organizationthe organization’s UUID (see Organizations). It comes from the session, so the same user signing in to another organization gets another value; no mapper can write it
featuresthe features scope was grantedthe tenant’s feature flags that are on for the sign-in’s organization, sorted ([] when none); no mapper can write it
scopealwaysthe granted scopes, space-separated (see Scopes in the token)
rolesa user subjectnames of the user’s effective roles: direct, inherited through groups and their ancestors, and expanded composites; a roles claim mapper replaces it
groupsa user subjectnames (not paths) of the user’s groups, including ancestor groups; a groups claim mapper replaces it
permissionsa user subject whose roles hold permissions on a requested resource serverpermission names, e.g. orders:read
sidthe token came from a browser sessionthe session’s UUID
auth_timethe token came from an authenticationwhen the user authenticated
amrthe token came from an authenticationauthentication methods (below)
acrthe session has a classurn:ridm:acr:single, urn:ridm:acr:mfa, or the *:mfa class the client asked for
cnfa DPoP proof bound the token, or the client is registered for certificate-bound tokens{"jkt": "<RFC 7638 thumbprint of the proof key>"} (RFC 9449 §6.1) and/or {"x5t#S256": "<base64url SHA-256 of the client certificate>"} (RFC 8705 §3.1)
acttoken exchange with an actor_token, or a sign-in an administrator opened as the usertoken exchange: {"sub", "client_id"} of the acting party, nesting any previous act (RFC 8693 §4.1). Impersonation: {"sub", "iss"} of the administrator. The admin API refuses any token carrying it
profile attributesthe attribute lists access_token in its visible_in and the user has a valueone claim per attribute, named after it (see Profile attributes)

A client_credentials token for a client with a service account is issued for that account’s user, so it carries roles, groups and permissions like any user token. A token exchanged under RFC 8693 keeps the subject token’s sub, sid, amr and acr, and never outlives it.

Scopes in the token

The scope claim holds what the grant carries, which can be less than what was asked:

  • Default scopes. A request that names no scope (at /authorize, the device endpoint or client_credentials) gets the tenant’s scopes marked is_default that the client may hold; client_credentials never gets openid or offline_access this way.
  • Bound scopes. A scope bound to a resource server (resource_server_id) adds that server to the audiences, and is refused with invalid_scope when the client may not target it. A bound scope is left out of any token whose audience lacks its server, so a refreshed or narrowed token does not keep it.
  • offline_access is kept only when every resource server in the audience has allow_offline_access on; otherwise it is dropped silently (OIDC Core §11 lets the provider decline it) and no refresh token outliving the session is issued.
  • Refresh and code exchange may narrow the scopes and audiences of the original grant, never widen them (invalid_scope, invalid_target).

See Resource servers, scopes and permissions.

Permissions

permissions is computed per request: for each resource server in aud, the permissions the user’s effective roles hold on it, de-duplicated. A token for the admin API (aud urn:ridm:admin) therefore lists the caller’s ridm:* permissions. The admin and account APIs do not trust this claim; they resolve permissions again on every request. See Resource servers, scopes and permissions.

Subject identifiers

With the client’s subject_type public (the default), sub is the user’s UUID. With pairwise it is base64url(SHA-256(sector | "|" | user id | "|" | tenant salt)), where the sector is the host of the client’s sector_identifier_uri, else the host of its first redirect URI, else its client_id. The same user therefore has different, stable subjects at clients of different sectors (OIDC Core §8). sub is computed the same way in the ID token, the userinfo response and the logout token.

ID token

Issued from the token endpoint when the scope includes openid and there is a user.

ClaimPresentValue
iss, subalwaysas in the access token
audalwaysthe client_id
azpalwaysthe client_id
iat, expalwaysissue time and expiry
auth_timealwayswhen the user authenticated; a token minted from a refresh token repeats the original value (OIDC Core §12.2)
tidalwaysthe tenant’s UUID
org_idthe sign-in acts in an organizationas in the access token
featuresthe features scope was grantedas in the access token
noncethe authorization request had oneechoed
sida browser session existsthe session’s UUID, the value back- and front-channel logout name
amr, acras in the access tokenrefresh-derived ID tokens repeat the original values
actan administrator opened the session as the useras in the access token (impersonation)
at_hashalways (an access token is always issued alongside)left half of the hash of the access token, using the hash of the signing algorithm (SHA-256 for RS256/ES256, SHA-384 for RS384, SHA-512 for RS512 and EdDSA)

The claims released by the granted scopes (below) are not in the ID token by default: an access token is always issued with it, so they are read from /userinfo (OIDC Core §5.4). A client that wants them in the ID token as well sets id_token_scope_claims. Profile attributes listing id_token in visible_in, and claim mappers with id in include_in, add claims to the ID token either way.

Userinfo response

GET or POST {issuer}/userinfo with an access token carrying the openid scope returns plain JSON (never signed or encrypted):

  • sub (as above);
  • the claims released by the token’s scopes (below), read from the user record now, not from the time of sign-in;
  • profile attributes listing userinfo in visible_in;
  • the claims of mappers with userinfo in include_in.

Each scope releases the claims named in its claims list, which administrators edit on the scope (console: Scopes; admin API: /admin/tenants/{slug}/scopes). The standard scopes are seeded with the OIDC Core §5.4 sets:

ScopeSeeded claims
profilename, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at
emailemail, email_verified
phonephone_number, phone_number_verified
addressaddress

features is seeded with no claims: it adds the features claim to the access and ID tokens (above), not to userinfo.

A claim name is resolved against the user as follows: preferred_username is the username, email/email_verified and phone_number/phone_number_verified come from the user’s contact fields (only when the user has that address or number), locale and updated_at from the record, address from the profile attribute address when it is a JSON object. Any other name is a top-level user field of that name (username, email, phone, locale, …) or else the profile attribute of that name; attributes.<name> names the profile attribute explicitly. A claim with no value for the user is left out. Protected claims and roles, groups and permissions are never released this way. A custom scope with a claims list releases those claims the same way.

roles, groups and permissions are not part of userinfo unless a mapper adds them. The claims request parameter (OIDC Core §5.5) is not implemented; discovery reports claims_parameter_supported: false.

Profile attributes

An attribute of the tenant’s profile schema whose visible_in lists id_token, userinfo or access_token appears there as a claim named after the attribute, when the user has a non-null value. It is applied after the scope claims and before the claim mappers: it never overwrites a claim a scope already released, never writes a protected claim or roles, groups or permissions, and a mapper writing the same claim wins.

Logout token

Back-channel logout (OIDC Back-Channel Logout 1.0 §2.4) posts logout_token=<jwt> as a form to the client’s backchannel_logout_uri, with a five-second timeout and no redirects followed.

ClaimValue
issthe tenant’s issuer
subthe user’s subject as that client sees it
audthe client_id
iatissue time
expiat + 120 seconds
jtia UUIDv7
sidthe ended session’s UUID
events{"http://schemas.openid.net/event/backchannel-logout": {}}

Front-channel logout loads each client’s frontchannel_logout_uri with iss and sid query parameters appended.

Introspection response

POST {issuer}/introspect (RFC 7662) answers {"active": false} for anything unknown, expired, revoked, issued to another client, or belonging to an inactive user, and for ID tokens, which are not access tokens. For an active access token, JWT or opaque, it returns active: true, token_type (DPoP when the token carries cnf.jkt, else Bearer, certificate-bound tokens included), typ: "at+jwt" (JWTs only), whichever of scope, client_id, sub, aud, iss, exp, iat, nbf, jti, sid, tid, roles, permissions, cnf and act the token holds, and username. Refresh tokens report token_type: "refresh_token", personal access tokens token_type: "personal_access_token".

amr values

amr lists how the session was established (RFC 8176 names where one exists), with mfa added once a second factor passed.

Methodamr
Passwordpwd
Magic link, email one-time code, invitation acceptanceotp
SMS one-time codeotp, sms
Passkey sign-in with user verificationhwk, user, mfa
Passkey sign-in without user verificationhwk
Upstream identity provider (brokering)fed
Kerberos desktop sign-inkerberos (RFC 8176 registers no value for it)
Second step: authenticator app, email codeadds otp, mfa
Second step: recovery codeadds mfa only
Second step: SMS codeadds otp, sms, mfa
Second step: passkeyadds hwk (and user when verified), mfa

Tokens issued by client_credentials carry no amr. A device-code token repeats the approving session’s values; a refresh-derived token repeats the original ones.

acr values

ValueMeaning
urn:ridm:acr:singleone factor. Every session carries a class, so a request with acr_values always gets an acr back (OIDC Core §3.1.2.1)
urn:ridm:acr:mfaa second factor passed (or a passkey with user verification signed in)
any class ending in :mfathe class the client asked for in acr_values, asserted once a second factor passed

acr_values is a preference list: rIDM honours the first class it recognises. A *:mfa class first is a step-up request and demands a second factor even on a trusted device; urn:ridm:acr:single ahead of it means the client will settle for one factor. Both built-in classes are listed in acr_values_supported. See MFA and passkeys.

Claim mappers

Claim mappers add claims to the access token, the ID token and the userinfo response. A mapper is tenant-wide or belongs to one client, and is stored as {name, client_id?, config}, where config is {"type": ..., <type fields>, "include_in": [...]}. include_in names at least one of access, id, userinfo. Manage them with /admin/tenants/{slug}/claim-mappers (ridm:mappers:read / write); changes reach tokens at once.

typeFieldsProduces
user_attributeattribute, claim, json_type (string default, number, boolean, json)the user value attribute names, coerced to json_type (a value that cannot be coerced is skipped): attributes.<name> is that profile attribute; any other name is a top-level user field (id, username, email, email_verified, phone, phone_verified, locale, created_at, updated_at) or else the profile attribute of that name
groupsclaim, full_path (default false)the user’s group names, or parent/child paths with full_path
rolesclaim, client_id (optional)without client_id, the user’s tenant-wide roles; with it, only the roles scoped to that client (named by its public client_id)
hardcodedclaim, value (any JSON)the fixed value
templateclaim, templatea Handlebars template rendered over user, tenant (slug, id, display_name), client (client_id), roles, groups and scopes; an empty result adds nothing. Templates must compile when saved
audienceaudienceadds an audience to access tokens only (no claim)
{
  "name": "department",
  "config": {
    "type": "user_attribute",
    "attribute": "attributes.department",
    "claim": "department",
    "json_type": "string",
    "include_in": ["access", "id"]
  }
}

Behaviours worth knowing:

  • Mappers run after the scope claims and profile attributes, so a mapper may override either.
  • Only a roles mapper may write a claim named roles, and only a groups mapper one named groups. In an access token their output replaces the built-in claim (to emit one client’s roles, or group paths). No mapper may write permissions, which is computed from the audience’s grants. A mapper breaking these rules is refused when saved.
  • A roles mapper’s client_id is resolved to the client when the tenant’s mappers are loaded into the cache, which lasts up to five minutes; a mapper naming a client created after that takes effect once the cache refreshes.

Protected claims

Mappers can never set these claims, and neither can scope claims or profile attributes. A mapper targeting one is refused when saved; one stored before that rule is skipped with a warning in the log:

iss, sub, aud, exp, iat, nbf, jti, azp, at_hash, c_hash, nonce, auth_time, amr, acr, sid, tid, client_id, scope, typ, cnf, act.

Lifetimes

TokenDefaultOverridden by
Access token300 s (settings.session.access_token_ttl_secs)the client’s access_token_ttl_secs; then the shortest token_ttl_secs of the requested resource servers; token exchange caps it at the subject token’s remaining life
ID token300 s (settings.session.id_token_ttl_secs)the client’s id_token_ttl_secs
Refresh token30 days (settings.session.refresh_token_ttl_secs)the client’s refresh_token_ttl_secs. Without offline_access it also ends with the browser session it was issued in (sign-out, idle or absolute timeout)
Logout token120 sfixed

Refresh tokens are opaque (rt_...), not JWTs; personal access tokens are opaque (rpat_...), and so are access tokens of clients registered as opaque (at_...). None has claims a holder can read; introspection describes them.

Server configuration

The server is configured entirely through environment variables, so the same binary and image run everywhere. Every variable is read once at start-up by api/src/config.rs; .env.example is an annotated template. A .env file in the working directory is loaded first when present, and real environment variables win over it.

Everything that is per tenant (password policy, token lifetimes, MFA, branding, rate-limit ceilings and so on) is not here but in the tenant’s settings; see Tenants and tenant settings and the tenant configuration document.

Conventions used below:

  • An empty or whitespace-only value counts as unset.
  • Booleans accept 1, true, yes, on and 0, false, no, off (any case).
  • Integers are unsigned 32-bit.
  • An invalid or missing required value stops the server with configuration error: ... on standard error and exit code 2.
  • Secrets can come from files. DATABASE_URL, DATABASE_READ_URL, REDIS_URL (and each region’s DATABASE_URL_<NAME>, DATABASE_READ_URL_<NAME>, REDIS_URL_<NAME>), MASTER_KEY, SMTP_PASSWORD, METRICS_TOKEN, AUDIT_SINK_TOKEN, AUDIT_SINK_SECRET and BOOTSTRAP_ADMIN_PASSWORD each also have a *_FILE form (DATABASE_URL_FILE, …) naming a file that holds the value, the convention for Docker and Kubernetes secret mounts. One trailing line ending is dropped and nothing else; an empty file counts as unset, like an empty variable; the variable itself wins when both are set. A file that cannot be read stops the server like an invalid value. The production compose stack passes every secret this way.

Required

VariableTypeMeaning
DATABASE_URLPostgres URLPostgres 16+ connection string. Use a non-superuser role with DML privileges only: superusers bypass row level security, which backs tenant isolation, and a table owner can switch it off. See Postgres and Valkey.
REDIS_URLURLValkey (or Redis) connection: redis://host:6379, rediss:// for TLS, redis+cluster://h1:7000,h2:7001, or redis+sentinel://s1:26379,s2:26379/<master name>. Credentials go before an @ and apply to every host.
PUBLIC_URLhttp/https URLThe externally visible base URL, e.g. https://id.example.com. Every tenant’s issuer is {PUBLIC_URL}/t/{slug} (a tenant with a custom domain uses https://{domain} instead). Changing it changes every issuer.
MASTER_KEY or MASTER_KEY_FILE32 bytesThe master key that encrypts secrets at rest (signing keys, identity providers’ client secrets, SMTP and SMS credentials, webhook secrets, second-factor secrets). MASTER_KEY is hex or base64 (standard or URL-safe); MASTER_KEY_FILE names a file holding the raw 32 bytes or the same text encodings. MASTER_KEY wins when both are set. Generate with openssl rand -hex 32. See Signing keys and the master key. Optional when KEY_WRAPPER is set.

Server

VariableTypeDefaultMeaning
BIND_ADDRsocket address0.0.0.0:8080Address the HTTP (or HTTPS) listener binds to. ridm-api --healthcheck probes /healthz at this address (a wildcard 0.0.0.0 or :: becomes the loopback address of the same family).
UI_URLURLPUBLIC_URLBase URL of the sign-in, consent and console pages. The server redirects browsers to pages under it (/login/, /consent/, /console/, …) and derives the built-in console clients’ redirect URIs from it, updating them at start-up when it changes. Leave it unset when the server serves its embedded UI; set it to wherever the UI is hosted otherwise.
EMBEDDED_UIbooleantrueServe the UI compiled into the binary (the container image’s build, --features embedded-ui) on PUBLIC_URL’s origin. Has no effect on a binary built without the feature, or when UI_URL is another origin, and false turns the pages off on a node that should answer the API alone. See Deployment overview.
DOCS_ENABLEDbooleanfalseServe Swagger UI at /docs. /openapi.json is served either way. Keep it off in production.
MIGRATE_ON_STARTbooleanfalseAt start-up, apply migrations if any are pending, as DATABASE_URL’s role; with none pending nothing is applied, so the DML-only app role can run with it on once ridm-api migrate has brought the schema up to date. A pending migration then fails start-up unless the role owns the schema. Off, the server only logs a warning when migrations are pending. ridm-api bootstrap follows the same setting. See Container image.

Database and cache

VariableTypeDefaultMeaning
DATABASE_READ_URLPostgres URLunsetA read replica for listings and statistics, run in read-only transactions. Anything that feeds a decision stays on the primary. Unset, the primary serves both.
DB_POOL_MINinteger2Minimum Postgres connections per node. Must not exceed DB_POOL_MAX.
DB_POOL_MAXinteger20Maximum Postgres connections per node. Roughly twice the database server’s CPU count divided by the number of API nodes; every node’s pools together must stay under the server’s max_connections.
DB_ACQUIRE_TIMEOUT_MSinteger ≥ 12000How long a request waits for a free pooled connection before failing with 503. An overloaded pool fails fast instead of queueing requests for seconds; raise DB_POOL_MAX (or add nodes) if it happens under normal load.
REDIS_POOL_MAXinteger32Valkey connections per node (at least 1).
REDIS_TLS_CA_FILEpathunsetPEM roots to verify a rediss:// server against, instead of the system’s. Single-server form only.
REDIS_TLS_CERT_FILE, REDIS_TLS_KEY_FILEpathsunsetA client certificate (chain) and its key, PEM, presented to a rediss:// server; both or neither. With Valkey’s tls-auth-clients yes and tls-auth-clients-user CN the connection is authenticated as the user the certificate’s CN names, with no password. The three files are re-read whenever they change, so a renewal needs no restart. Per region: REDIS_TLS_CA_FILE_<NAME>, REDIS_TLS_CERT_FILE_<NAME>, REDIS_TLS_KEY_FILE_<NAME>. See Postgres and Valkey.
DATA_REGIONSregion listunsetRegional databases tenants can be placed in, comma-separated names (1-32 lowercase letters, digits or hyphens, starting with a letter; home is the DATABASE_URL database). Each region <name> needs DATABASE_URL_<NAME> (upper case, - as _: eu-west → DATABASE_URL_EU_WEST) and may set DATABASE_READ_URL_<NAME> and REDIS_URL_<NAME>; all three also have a *_FILE form. Every node must list the same regions. See Data residency.

Security and keys

VariableTypeDefaultMeaning
MASTER_KEY_VERSIONinteger ≥ 11Generation number of the current master key, stored with every ciphertext. Bump it when rotating the master key.
MASTER_KEY_PREVIOUSversion=key listunsetOlder generations still needed to decrypt rows not yet re-encrypted: 1=<hex>,2=<hex>. Every version must be lower than MASTER_KEY_VERSION. Remove an entry once ridm-api rotate-master-key --status (or GET /admin/master-key) shows nothing left under it.
KEY_WRAPPERpkcs11, aws-kms, vault, gcp-kms or azure-key-vaultunsetAn HSM or KMS holds the master-key generations instead of the environment; each backend has settings of its own. See Key custody: HSM and KMS.
KEY_WRAPPER_PREVIOUSbackend listunsetFurther backends still holding older generations, comma-separated, while rows move off them.
COOKIE_SECUREbooleantrueSet the Secure attribute on the session and trusted-device cookies and give them the __Host- prefix (__Host-ridm_session_{slug}, __Host-ridm_device_{slug}; without it ridm_session_{slug}, ridm_device_{slug}). Only turn off for plain-HTTP local development. See TLS and reverse proxies.
TRUSTED_PROXIESCIDR/IP listemptyComma-separated networks whose X-Forwarded-For, Forwarded and X-Forwarded-Host headers are believed. The client address is the TCP peer unless the peer is in this list. Empty means headers are never trusted. See TLS and reverse proxies.
OUTBOUND_ALLOW_NETWORKSCIDR/IP listemptyComma-separated private networks that requests to tenant-chosen URLs (webhooks, back-channel logout, client jwks_uri, identity provider endpoints, tenant email/SMS gateways and SMTP hosts, CAPTCHA) may reach, for applications on an internal network. Addresses inside them count as public, whether given literally or resolved. Empty means public addresses only (plus localhost for development). See the outbound request policy.
HSTS_MAX_AGEinteger (seconds)63072000 (two years)Strict-Transport-Security max-age, sent only when PUBLIC_URL is https. 0 disables the header.
SECURITY_CONTACTURI listunsetComma-separated mailto:, tel: or https: URIs, written as the Contact fields of /.well-known/security.txt (RFC 9116), with an Expires 30 days ahead, rolling. Unset (with SECURITY_TXT unset too), that path answers 404: the document names whoever handles vulnerability reports for this deployment, which only its operator can say.
SECURITY_POLICY_URLhttps URLunsetWritten as the document’s Policy field. Needs SECURITY_CONTACT.
SECURITY_TXT or SECURITY_TXT_FILEtextunsetThe whole document, served byte for byte, for an operator who writes (or OpenPGP-signs) their own. It must hold at least one Contact and exactly one RFC 3339 Expires; the server warns at start-up once that date has passed, and keeps serving it. Cannot be combined with the two above.
ARGON2_M_COST_KIBinteger19456 (19 MiB)argon2id memory cost for password hashes.
ARGON2_T_COSTinteger2argon2id iterations.
ARGON2_P_COSTinteger1argon2id lanes.
BREACH_CHECK_URLURL, or off/none/falsehttps://api.pwnedpasswords.com/range/Have I Been Pwned compatible range endpoint used by tenants with password.check_breached on (k-anonymity: only a five-character hash prefix leaves the server). A trailing / is added when missing. off, none or false disables the check for the whole deployment (air-gapped installs).

Geo-IP

Where the country and coordinates behind adaptive authentication’s location signals come from. Both sources are optional and consulted in this order; with neither, a tenant’s risk policy still scores the device and velocity signals and simply never raises a location one.

VariableTypeDefaultMeaning
GEOIP_COUNTRY_HEADERSheader name listcloudfront-viewer-country,cf-ipcountry,x-geo-countryComma-separated headers a proxy or CDN sets, first one present wins. An ISO 3166-1 alpha-2 code; XX and T1 mean “unknown” and are ignored. Only read when the request came through a TRUSTED_PROXIES peer, exactly like X-Forwarded-For.
GEOIP_LATITUDE_HEADERSheader name listcloudfront-viewer-latitude,x-geo-latitudeCoordinates from the same proxy, needed for impossible travel. A country without them still works.
GEOIP_LONGITUDE_HEADERSheader name listcloudfront-viewer-longitude,x-geo-longitude
GEOIP_DBfile pathunsetA MaxMind DB file (GeoLite2 or GeoIP2, City or Country), read into memory at start-up and used when no trusted header answered. rIDM bundles no data: supply and refresh the file yourself, under its own licence. A file that cannot be read is logged and treated as absent.

The argon2 parameters may not go below 8 MiB, 1 iteration and 1 lane. Hashes made with weaker parameters are upgraded on the user’s next successful sign-in, so raising them is safe.

Native TLS

VariableTypeDefaultMeaning
TLS_CERTfile pathunsetPEM certificate chain.
TLS_KEYfile pathunsetPEM private key.

Both or neither: setting one alone is a configuration error. With both, the listener speaks HTTPS (rustls); leave them unset when a reverse proxy or load balancer terminates TLS. ridm-api --healthcheck then probes over HTTPS, trusting exactly the first certificate in TLS_CERT.

Mutual TLS

Client certificates for mutual-TLS client authentication and certificate-bound tokens (RFC 8705). Setting MTLS_BIND or CLIENT_CERT_HEADER turns the feature on: discovery then offers tls_client_auth and self_signed_tls_client_auth and tls_client_certificate_bound_access_tokens. Without either, a client registered for one of them cannot authenticate.

VariableTypeDefaultMeaning
MTLS_BINDsocket addressunsetA second listener (0.0.0.0:8443) that serves the same routes and asks every connection for a client certificate without requiring one. The handshake proves the client holds the certificate’s key; which certificates are accepted is decided per client.
MTLS_CERT / MTLS_KEYfile pathTLS_CERT / TLS_KEYThat listener’s server certificate and key (PEM), both or neither. MTLS_BIND without them or TLS_CERT/TLS_KEY is a configuration error.
MTLS_PUBLIC_URLURLunsetWhere clients reach the mTLS endpoints (https://mtls.id.example.com). Discovery publishes {MTLS_PUBLIC_URL}/t/{slug}/token and the other client-authenticated endpoints as mtls_endpoint_aliases, so browsers never meet a certificate prompt on the sign-in host. Needs MTLS_BIND or CLIENT_CERT_HEADER.
CLIENT_CERT_HEADERheader nameunsetThe header a TLS-terminating proxy puts the client certificate in (X-Client-Cert): PEM, URL-encoded PEM (nginx $ssl_client_escaped_cert) or base64 DER, leaf first, up to five certificates. Only read from a TRUSTED_PROXIES peer; the proxy must overwrite it on every request.

FIPS build

Read only by the FIPS build (--features fips, the -fips image). The standard build ignores them.

VariableTypeDefaultMeaning
FIPS_TRANSITIONbooleanfalseAllows the one-time reads FIPS doesn’t approve while a deployment moves onto the FIPS build: secrets at rest still sealed with XChaCha20-Poly1305, and password hashes other than PBKDF2 (argon2, bcrypt, MD5, salted digests), at sign-in and in bulk imports. Each read is logged as a warning and counted in ridm_fips_non_approved_total. Turn it off once rotate-master-key has moved everything (see FIPS 140-3).
PBKDF2_ITERATIONSinteger210000PBKDF2-HMAC-SHA512 iterations for new password hashes, 210,000 (OWASP’s floor for SHA-512) to 1,000,000 (the most a stored hash may ask for). Raising it re-hashes each user at their next sign-in. The standard build hashes with argon2id (ARGON2_*) instead.
FIPS_ALLOW_NON_FIPS_HOSTbooleanfalseLets the FIPS build start on a host that isn’t in FIPS mode (/proc/sys/crypto/fips_enabled is not 1). It prints and logs a warning. The AWS-LC module self-test must still pass. For development and CI only: such a host isn’t a FIPS-validated configuration.

Email

Deployment-wide SMTP defaults, used by tenants that have not configured their own email backend (PUT /admin/tenants/{slug}/messaging/email). There is no deployment-wide SMS gateway; SMS is configured per tenant. See Email, SMS and templates.

VariableTypeDefaultMeaning
SMTP_HOSThost nameunsetSetting it turns the defaults on. Chosen by the operator, so it may be a private relay; tenant SMTP hosts are held to the outbound request policy instead.
SMTP_PORTinteger587
SMTP_USERNAMEstringunset
SMTP_PASSWORDstringunset
SMTP_FROMmailboxrequired when SMTP_HOST is setFrom header, e.g. "rIDM <no-reply@example.com>".
SMTP_SECURITYstarttls, tls, nonestarttlstls is implicit TLS (usually port 465); none is plain SMTP, for a local catcher such as Mailpit.

Rate limits

Per-tenant ceilings live in settings.rate_limits (see Rate limits, IP rules and CAPTCHA). These two belong to the deployment:

VariableTypeDefaultMeaning
RATE_LIMITSbooleantrueMaster switch for every request ceiling. Off only for tests and local experiments. Tenant IP rules are enforced either way.
RATE_LIMIT_IP_PER_MINUTEinteger6000Requests per minute one client address may make to every limited endpoint of every tenant together. 0 turns this bucket off.

Logging, metrics, tracing and audit export

VariableTypeDefaultMeaning
LOG_FORMATjson, pretty (or text)jsonLog line format. JSON lines carry the current span’s fields.
RUST_LOGtracing filterinfoLog filter, e.g. info,ridm_api=debug,sqlx=warn.
METRICS_TOKENstringunsetWhen set, GET /metrics demands Authorization: Bearer <token>; unset, it is open.
OTEL_EXPORTER_OTLP_ENDPOINTURLunsetOTLP/HTTP collector base URL (e.g. http://otel-collector:4318); traces are posted to <url>/v1/traces as protobuf: one info span per request, named METHOD /route/{template}, plus whatever else RUST_LOG enables. Unset, no exporter runs.
OTEL_SERVICE_NAMEstringridmservice.name on exported traces.
AUDIT_SINK_URLURLunsetAlso ship every audit row, from the database, so nothing is lost while the receiver is down: https://... (JSON arrays of up to 100 rows), syslog://host:514 / syslog+udp://host:514 (UDP), syslog+tcp://host:514 (RFC 5424, one message per line) or syslog+tls://host:6514 (RFC 5425). See Shipping to an external system.
AUDIT_SINK_TOKENstringunsetSent as Authorization: Bearer <token> to an HTTP(S) audit sink.
AUDIT_SINK_SECRETstringunsetSigns each HTTP(S) batch: X-RIDM-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">.
AUDIT_SINK_CA_FILEpathunsetPEM certificates to trust for the sink (HTTPS or syslog+tls) instead of the system’s roots.
HOSTNAMEstring-The host field of syslog audit lines. Usually set by the container runtime.

See Observability for the metric names and the audit sink’s delivery behaviour.

Background jobs

VariableTypeDefaultMeaning
RETENTION_DAYSinteger ≥ 130Days the hourly cleanup job keeps spent rows: expired, revoked or consumed refresh tokens, ended sessions (expired, idle past their idle timeout, or revoked; at most a week), login attempts, sent or dead messages, delivered or dead webhook deliveries, expired or used invitations, expired or revoked trusted devices, personal access tokens and provisioning tokens.
EVENT_QUEUE_CAPACITYinteger ≥ 1000100000Events each in-process consumer (the audit writer, the webhook dispatcher) may have waiting on a node. /readyz reports events: saturated once a queue is 80% full, so a load balancer stops sending the node traffic while it catches up; an event published into a full queue is dropped, logged and counted (ridm_event_queue_dropped_total). Each queued event holds roughly a kilobyte.

Audit retention is per tenant (settings.audit.retention_days), not governed by this variable.

First-run bootstrap

A development convenience: at start-up, create the master tenant’s first global owner from the environment. It is a no-op once a user in master holds the owner role. Production installs usually run ridm bootstrap or ridm-api bootstrap once instead; both fall back to these variables for values not given as flags.

VariableTypeDefaultMeaning
BOOTSTRAP_ADMIN_EMAILemailunsetEmail of the first owner.
BOOTSTRAP_ADMIN_PASSWORDstringunsetInitial password; the account must change it at first sign-in.
BOOTSTRAP_ADMIN_USERNAMEstringadminUsername of the first owner.
BOOTSTRAP_SAMPLE_CLIENTbooleanfalseAlso make sure master has the public single-page-app client sample-spa: PKCE, redirect URI http://localhost:3000/callback, post-logout redirect http://localhost:3000/, CORS origin http://localhost:3000. Created once; an existing one is left alone. Read only when the two variables above are set, at start-up and by ridm-api bootstrap.

BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD must be set together or not at all.

Server subcommands

The ridm-api binary runs the server by default and has these subcommands. All except openapi and --healthcheck load the full configuration above; migrate leaves out the master key and key custody settings, which migrations never need.

CommandPurpose
ridm-api migrateApply pending migrations, then exit. Run it as the schema-owner role.
ridm-api bootstrap [--email E] [--username U] [--password-stdin] [--no-must-change]Create the first global administrator. Applies pending migrations only with MIGRATE_ON_START=true; otherwise pending migrations make it exit 1 and name ridm-api migrate.
ridm-api rotate-master-key [--status | --new-generation]Re-encrypt secrets at rest under the current generation, or report what is left. --new-generation first has the key custody backend wrap a new generation and makes it current.
ridm-api openapiPrint the admin API’s OpenAPI document; needs no database.
ridm-api --healthcheckExit 0 when /healthz answers at BIND_ADDR (over HTTPS when TLS_CERT is set), 1 otherwise; the image’s HEALTHCHECK runs it.

Command-line tool (ridm)

The ridm CLI reads these variables; each overrides the stored profile, and the matching flag overrides the variable. It also loads a .env file from the working directory.

VariableFlagMeaning
RIDM_URL--urlServer origin, e.g. https://id.example.com.
RIDM_TOKEN--tokenBearer token to present (an access token or a personal access token). Never written to disk.
RIDM_TENANT--tenantTenant slug to act on (master by default).
RIDM_PROFILE--profileStored profile to use.
RIDM_CONFIGPath of the profile file. Default $XDG_CONFIG_HOME/ridm/config.json, else ~/.config/ridm/config.json (created with mode 0600).

ridm bootstrap talks to the database directly and so reads the server’s own DATABASE_URL, REDIS_URL and MASTER_KEY or key custody settings (and the BOOTSTRAP_* variables).

UI build and development

The Next.js UI in ui/ is a static export; these are read when it is built or run with next dev, not by the server.

VariableWhenMeaning
NEXT_PUBLIC_API_URLbuild timeBase URL of the API when the UI is hosted on another origin. Empty (the default) means same origin.
API_PROXYnext dev onlyProxy /t, /admin, /healthz, /readyz, /.well-known and /openapi.json to this API origin, so the pages call it same-origin, e.g. API_PROXY=http://localhost:8080 npm run dev.

Tenant configuration document

A tenant’s configuration can be exported as one JSON document and applied back, to the same tenant or another one. The document is deterministic (sorted keys and sorted collections), keyed by natural identifiers rather than database ids, and free of secrets, so it can live in version control and be reviewed like code. The idea and the workflow are in Configuration as code; this page is the format reference. The implementation is api/src/services/tenant_config.rs.

OperationAPICLIPermission
ExportGET /admin/tenants/{slug}/exportridm tenant export [slug] [-o FILE]ridm:tenants:export
Plan (dry run)POST /admin/tenants/{slug}/import?dry_run=true[&prune=true]ridm tenant diff [slug] -f FILE [--prune] [--exit-code]ridm:tenants:import
ApplyPOST /admin/tenants/{slug}/import[?prune=true]ridm tenant import [slug] -f FILE [--prune] [--yes]ridm:tenants:import

Of the built-in roles only ridm:owner holds ridm:tenants:import; ridm:admin may export but not import.

Top level

{
  "format": "ridm.tenant/1",
  "tenant": { "slug": "acme", "display_name": "Acme", "settings": { } },
  "profile_schema": { "attributes": [], "allow_undeclared": false },
  "resource_servers": [],
  "scopes": [],
  "clients": [],
  "roles": [],
  "groups": [],
  "claim_mappers": [],
  "message_templates": [],
  "webhooks": [],
  "ip_rules": [],
  "mtls_trust_anchors": [],
  "identity_providers": [],
  "saml_service_providers": []
}
FieldRequiredMeaning
formatyesmust be ridm.tenant/1; anything else is refused
tenantyesname and settings (below)
profile_schemanothe user profile schema; defaults to an empty schema
resource_servers … saml_service_providersnocollections, each defaulting to empty

Unknown fields are refused at the top level and in every section, so a misspelt key fails the import rather than being dropped. The one exception is inside tenant.settings, which, like settings everywhere, ignores keys it does not know (they are not stored).

What the document leaves out

The document is configuration, not data or credentials. It never contains:

  • Secrets: client secrets, webhook signing secrets, identity providers’ client secrets, registration access tokens.
  • Users and everything attached to them: credentials, sessions, trusted devices, consents, personal access tokens, linked identities, role assignments to users and group memberships. Move users with the bulk user import and export.
  • Provider credentials: the email and SMS delivery settings and the CAPTCHA provider, which hold secrets.
  • Signing keys (each tenant has its own), SCIM provisioning tokens, dynamic registration initial access tokens, invitations and the audit log.
  • Built-in objects: the ridm-admin-console and ridm-account-console clients, the built-in ridm:* roles, and the built-in resource servers urn:ridm:admin and urn:ridm:account. Exports leave them out, an import naming a built-in client is refused, and pruning never deletes them. The standard scopes (openid, profile, email, phone, address, offline_access, features) are exported so their descriptions can be tuned, but pruning never deletes them either.

Sections

tenant

FieldMeaning
sluginformational only: an import applies to the tenant named in the URL, so one document can configure several tenants
display_namethe tenant’s name
settingsthe complete tenant settings document

The tenant’s status (active or disabled) is not part of the document.

settings is applied as a whole, not merged: a section or field the document omits takes its default, not the tenant’s current value. An export always writes every field, so round-tripping is safe; a hand-written document that omits settings resets the tenant’s settings to defaults, and the plan shows it. custom_domain is part of the settings and must be unique across tenants, so clear it before applying a production export to another tenant.

Settings sectionFields (default)
passwordmin_length (12), max_length (128), require_uppercase, require_lowercase, require_digit, require_symbol (all false), history (5), max_age_days (null, never), check_breached (false)
sessionidle_timeout_secs (1800), absolute_timeout_secs (43200), max_concurrent (0, unlimited), remember_device_days (30), access_token_ttl_secs (300), refresh_token_ttl_secs (2592000), id_token_ttl_secs (300)
mfa{"mode": "off"}; also optional, required, required_for_admins, {"mode": "required_for_roles", "roles": [...]}
mfa_methodstotp (true), email_otp (false), sms_otp (false)
riskenabled (false), weights (new_device 20, new_country 50, impossible_travel 60, velocity 40), step_up_at (50), block_at (100), impossible_travel_kmh (900), velocity_window_minutes (15), velocity_max_failures (10)
impersonationenabled (false), max_minutes (60, 1–480)
authfirst-factor methods: password (true), magic_link, email_otp, sms_otp, passkey (all false)
registrationenabled (false), require_email_verification (true), require_terms (false), terms_url, privacy_url (null), allowed_email_domains ([], any). A leftover captcha key from older exports is ignored; the setting is captcha.on_registration
localedefault ("en"), supported (["en"])
brandinglogo_url, favicon_url, primary_color, contrast_color, background_color, support_url, headline, tagline, custom_css (all null), links ([] of {label, url}), skin ("signal-v2"; also signal, vault, registry), mode ("system"; also light, dark), allow_theme_switch (true)
keysdefault_alg ("RS256"; also RS384, RS512, ES256, EdDSA), rsa_bits ("B2048"; also B3072, B4096), rotation_interval_days (90, 0 = never), retire_overlap_hours (24)
discoveryemail_domains ([]): WebFinger acct: domains that resolve to this tenant
dcrmode ("disabled"; also open, initial_access_token), allowed_grants ([]), require_pkce (true)
lockoutmax_failures (10), lock_minutes (15), ip_max_failures (100), ip_window_minutes (15)
captchaafter_failures (3, 0 = never), on_registration (true)
notificationsnew_device, password_changed, mfa_changed, email_changed (all true)
auditretention_days (365, 0 = forever)
accountself_deletion (true), deletion_retention_days (30), personal_tokens (true), personal_token_max_days (365, 0 = no limit)
rate_limitsenabled (true), window_secs (60), token_per_ip (600), token_per_client (1200), authorize_per_ip (300), flows_per_ip (600), tenant_total (0, off)
custom_domainnull, or a host name
features{}: flag name to {enabled, description, organizations} (a bare boolean is accepted); see Feature flags

What each setting does is described in Tenants and tenant settings.

profile_schema

{"attributes": [...], "allow_undeclared": false}. Each attribute:

FieldMeaning
nameattribute name, stored under the user’s attributes
typestring, number, boolean, email, url, phone, date (YYYY-MM-DD), enum, json
label, descriptionshown in forms
required, multivaluedbooleans, default false
editable_byuser (default), admin, none
visible_inany of id_token, userinfo, access_token: the attribute appears there as a claim of its name (see Token claims)
validationmin_length, max_length, pattern (anchored regular expression), min, max, values (for enum)
orderposition in forms

The schema is replaced as a whole when it differs.

resource_servers

Keyed by identifier.

FieldDefaultMeaning
identifierthe audience URI; immutable
namedisplay name
token_ttl_secsnullcaps the lifetime of access tokens for this audience
signing_algnullRS256, RS384, RS512, ES256 or EdDSA: access tokens for this audience are signed with the tenant’s key of that algorithm (created on save if missing); null uses the tenant’s keys.default_alg
allow_offline_accesstruefalse drops offline_access from any grant whose audience includes this server
permissions[][{name, description?}]

permissions is authoritative: when a resource server is created or updated, permissions missing from its list are deleted, with or without prune.

scopes

Keyed by name: name, description, claims ([]: the claims the scope releases at /userinfo, and in ID tokens of clients with id_token_scope_claims), is_default (false: granted when a request names no scope), and resource_server (the identifier of the resource server the scope is bound to, or null; requesting a bound scope targets that server). See Token claims.

clients

Keyed by client_id. Besides client_id, a client document carries:

FieldDefaultMeaning
statusactiveor disabled
service_accountfalsewhether the client has a service-account user for client_credentials (created or removed to match)

and every metadata field POST /admin/tenants/{slug}/clients accepts: name (required), client_type (spa, web, native, machine, device), description, logo_uri, client_uri, tos_uri, policy_uri, token_endpoint_auth_method, jwks, jwks_uri, redirect_uris, post_logout_redirect_uris, allowed_grants, allowed_scopes, allowed_audiences, access_token_ttl_secs, refresh_token_ttl_secs, id_token_ttl_secs, access_token_format, id_token_encryption ({alg, enc}), subject_type, sector_identifier_uri, require_pkce, require_consent, id_token_scope_claims, cors_origins, initiate_login_uri, backchannel_logout_uri, frontchannel_logout_uri, dpop_bound_access_tokens, require_pushed_authorization_requests, security_profile (none, fapi2), backchannel_token_delivery_mode (poll, ping), backchannel_client_notification_endpoint. See Registering clients.

Before comparing, rIDM fills omitted fields with the defaults the client type implies, exactly as creating the client would, so a minimal document does not read as a change against the export of the client it created.

roles

Keyed by name, or client_id/name for a client role.

FieldMeaning
namerole name
clientclient_id of the client the role belongs to; absent for a realm role
description
compositesrole references: name for a realm role, client_id/name for a client role
permissionsresource-server-identifier#permission-name, e.g. https://orders.example#orders:read

composites and permissions are authoritative for each role in the document: links not listed are removed.

groups

Keyed by path.

FieldMeaning
pathnames from the root down, e.g. ["staff", "engineering"]; the last is the group’s own name
description
attributesa JSON object ({} by default)
rolesrole references the group grants its members; authoritative

Parents are applied before children, and a group whose path changed is a delete (with prune) plus a create. Members are not part of the document.

claim_mappers

Keyed by name, or client_id/name for a client’s mapper: name, client (client_id, or absent for a tenant-wide mapper) and config, the mapper document ({"type": ..., "include_in": [...]}) described in Token claims.

message_templates

Keyed by channel/event/locale. Only tenant overrides are exported; the built-in templates are not.

FieldMeaning
channelemail or sms
eventthe message event, e.g. password_reset
localee.g. en
subjectemail subject (email only)
body_textrequired
body_htmloptional HTML body (email only)

The events and their variables are listed in Email, SMS and templates.

webhooks

Keyed by name: name, url, events (exact names, prefixes such as user.*, or *), enabled (true), headers (an object of extra request headers, {}), max_attempts (8). The signing secret is not in the document; a webhook created by an import gets a fresh one, reported once. See Events, audit and webhooks.

ip_rules

Keyed by cidr, or client_id/cidr for a client’s rule: cidr (normalised, so 10.0.0.1/8 compares equal to 10.0.0.0/8), action (allow or deny, default deny), client (a client_id, or absent for a tenant-wide rule), description.

mtls_trust_anchors

The certificate authorities tls_client_auth clients may present certificates from (Mutual TLS), keyed by the certificate’s SHA-256 thumbprint: name and certificate_pem (one CA certificate). An import refuses a certificate that is not a CA or has expired; a changed name replaces the entry.

identity_providers

Keyed by alias (lower-cased).

FieldDefaultMeaning
aliasthe provider’s name in /broker/{alias}/...
kindoidcor oauth2, saml
display_namebutton label
presetnullgoogle, microsoft, github, apple, gitlab
enabled, hiddentrue, false
issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_urinullupstream endpoints
client_idrIDM’s client id at the provider
token_endpoint_auth_methodclient_secret_basicor client_secret_post, none
scopes[]scopes requested upstream
pkcetrue
link_policyverified_emailor explicit, always_new
trust_emailfalse
mappers{}subject, username, email, email_verified (claim names) and attributes (profile attribute → claim)
sort_order0
samlabsenta saml provider’s settings: entity_id, sso_url, sso_binding, slo_url, slo_binding, signing_certificates, name_id_format, sign_requests, want_assertions_signed, require_encrypted_assertions, force_authn, authn_context_class_refs, allow_unsolicited, unsolicited_client_id, metadata_url (see SAML identity providers); the metadata refresh status is not part of the document. Certificates may be PEM or base64 and compare as stored

A SAML provider has no client id, secret or OAuth endpoints (they export empty) and is never listed under secrets. The client secret is never part of the document. An update keeps the stored secret; a provider created by an import has none, and the report lists its alias under secrets.identity_providers so you can set it (PATCH .../identity-providers/{alias} with client_secret). See Identity brokering.

saml_service_providers

Keyed by client_id: the registration POST /admin/tenants/{slug}/saml/service-providers takes (name, entity_id, acs_urls, slo_url, name_id_format, attributes, certificates, signing and encryption options, …; see SAML identity provider), plus status (default active). Certificates may be PEM or base64 and compare equal either way. SAML clients appear here and never under clients; a clients entry with client_type: saml is refused. The IdP’s own SAML signing keys are not part of the document.

How an import works

  1. Parse and normalise. The document is checked (format, unknown fields, duplicate keys within a collection) and brought into the shape an export would have: client defaults filled in, CIDRs normalised, reference lists sorted and de-duplicated, empty attributes and headers made {}.
  2. Plan. The tenant is exported and compared with the document, collection by collection, on the natural keys above. An item only in the document is a create; an item in both that differs is an update, with the changed fields; an item only in the tenant is a delete, but only with prune. Without prune an import only adds and changes.
  3. Apply (skipped for a dry run). Changes are applied in dependency order: tenant settings, profile schema, resource servers, scopes, clients, roles (rows first, then composites and permissions), groups (parents first), claim mappers, message templates, webhooks, IP rules, identity providers. Deletions run afterwards in reverse order.

Each change is applied independently: a change that fails is reported in errors and the others still go ahead, so an import is not a single transaction. Fix the reported problems and apply again. Applying the same document twice gives an empty plan the second time.

An import cannot hand out admin permissions the importing administrator does not hold. A role or group whose new composites, permissions or roles would grant ridm:* permissions beyond the importer’s own is refused and left unchanged, reported in errors as cannot grant permissions you do not hold: …. A role the same document defines counts with the permissions it will have once imported. A dry run finds these refusals without changing anything and lists them in its errors too, so a plan shows what the apply would refuse; ridm tenant diff fails on them and ridm tenant import prints them before asking.

Report

A dry run and an apply both answer 200 with the same shape:

{
  "dry_run": false,
  "prune": false,
  "changes": [
    {
      "resource": "client",
      "key": "orders-web",
      "op": "create"
    },
    {
      "resource": "webhook",
      "key": "audit-feed",
      "op": "update",
      "fields": [
        { "field": "max_attempts", "from": 8, "to": 12 }
      ]
    }
  ],
  "summary": { "create": 1, "update": 1, "delete": 0, "unchanged": 11 },
  "applied": 2,
  "errors": [],
  "secrets": {
    "clients": { "orders-web": "k3Jd..." },
    "webhooks": {},
    "identity_providers": []
  }
}
FieldMeaning
changes[].resourcetenant, profile_schema, resource_server, scope, client, role, group, claim_mapper, message_template, webhook, ip_rule, mtls_trust_anchor, identity_provider
changes[].keythe natural key
changes[].opcreate, update, delete
changes[].fieldsfor updates: {field, from, to} for each top-level field that differs
summarycounts; unchanged counts items present on both sides and equal
appliedchanges applied successfully (0 for a dry run)
errors[{resource, key, error}] for changes that failed; in a dry run, the changes the apply would refuse under the rule above
secretspresent only when the import created something with a secret: clients (client_id → secret) and webhooks (name → secret), shown this once; identity_providers lists providers that still need their client secret

The tenant change is the exception to field-level diffs: it compares the name and the settings as one pair, so its single entry has an empty field and carries [display_name, settings] before and after in full; compare the two to see what moved.

ridm tenant diff --exit-code exits 3 when the plan is not empty, so a pipeline can detect drift; ridm tenant import shows the plan and asks before applying unless --yes is given (required when standard input is not a terminal).

Annotated example

examples/demo-tenant.json is the document behind the example relying parties. Abridged:

{
  "format": "ridm.tenant/1",
  "tenant": {
    "slug": "demo",
    "display_name": "Example Orders Co."
  },
  "resource_servers": [
    {
      "identifier": "https://orders.example",
      "name": "Orders API",
      "allow_offline_access": true,
      "permissions": [
        { "name": "orders:read", "description": "See orders" },
        { "name": "orders:write", "description": "Place orders" }
      ]
    }
  ],
  "scopes": [
    { "name": "orders:read", "description": "See your orders", "resource_server": "https://orders.example" },
    { "name": "orders:write", "description": "Place orders on your behalf", "resource_server": "https://orders.example" }
  ],
  "roles": [
    {
      "name": "orders-manager",
      "description": "May see and place orders",
      "permissions": ["https://orders.example#orders:read", "https://orders.example#orders:write"]
    }
  ],
  "clients": [
    {
      "client_id": "orders-web",
      "name": "Orders (server-side web app)",
      "client_type": "web",
      "token_endpoint_auth_method": "client_secret_basic",
      "redirect_uris": ["http://localhost:3200/callback"],
      "post_logout_redirect_uris": ["http://localhost:3200/"],
      "backchannel_logout_uri": "http://localhost:3200/backchannel-logout",
      "allowed_grants": ["authorization_code", "refresh_token"],
      "allowed_scopes": ["openid", "profile", "email", "offline_access", "orders:read", "orders:write"],
      "allowed_audiences": ["https://orders.example"],
      "require_pkce": true,
      "require_consent": false
    }
  ]
}

Reading it top to bottom:

  • tenant has no settings, so applying this document to an existing tenant plans to reset its settings to the defaults. It is meant for a fresh tenant, where that is no change.
  • The resource server https://orders.example is the audience of the Orders API and declares two permissions. Tokens requested for it (the client lists it in allowed_audiences, so it is the default audience) carry the permissions the user’s roles hold on it in the permissions claim.
  • The two custom scopes belong to that resource server. profile, email and the other standard scopes are not listed: they exist in every tenant, and without prune an import never deletes anything.
  • The role orders-manager grants both permissions, referenced as identifier#name. Users are not in the document; assign the role to users or groups afterwards.
  • orders-web is a confidential client (client_secret_basic). The document carries no secret: the first import creates the client and returns its secret once under secrets.clients. Omitted fields (token lifetimes, subject_type, and so on) take the web type’s defaults.

Applying it to a new tenant called demo:

ridm tenant create demo --name "Example Orders Co."
ridm tenant diff demo -f examples/demo-tenant.json      # review the plan
ridm tenant import demo -f examples/demo-tenant.json    # apply; prints the client secret once

or with the API:

curl -s -X POST "https://id.example.com/admin/tenants/demo/import?dry_run=true" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @examples/demo-tenant.json | jq .summary

Errors

rIDM answers errors in the format each protocol expects, so a client library written for that protocol understands them without special handling:

WhereFormat
OAuth 2.0 / OIDC endpoints (/token, /par, /device_authorization, /bc-authorize, /introspect, /revoke, /register)RFC 6749 §5.2 JSON: {"error", "error_description"?}
/authorizean HTML page, or a redirect to the client with error
/userinfo, and the resource side of ridm-authRFC 6750 WWW-Authenticate challenge plus a JSON body
Admin API, account API, flow API and the other browser endpointsRFC 9457 application/problem+json
SCIMRFC 7644 §3.12 SCIM error documents

The types are defined in api/src/error.rs.

OAuth and OIDC errors

Token, PAR, device-authorization, introspection and revocation errors are JSON with Cache-Control: no-store:

HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store

{"error": "invalid_grant", "error_description": "invalid authorization code"}

invalid_client answers 401 with WWW-Authenticate: Basic realm="ridm". The codes rIDM emits:

errorStatusTypical cause
invalid_request400missing or repeated parameter; /token body not application/x-www-form-urlencoded
invalid_client401unknown client, wrong secret or assertion, a method other than the registered one; a public client at /introspect; under FAPI 2.0, an assertion not signed with PS256/ES256/EdDSA or whose aud is not the issuer
invalid_grant400code or refresh token invalid, expired, used, or issued to another client; PKCE verifier mismatch; user no longer active; a code whose browser session was signed out before it was exchanged; a refresh token without offline_access whose session has ended (its family is revoked); bad token-exchange subject or actor token
unauthorized_client400the client is not allowed this grant type
unsupported_grant_type400a grant type rIDM does not know
invalid_scope400a scope the client may not request; a scope bound to a resource server the client may not target; a refresh scope naming a scope the original grant did not carry (checked before the refresh token is spent, so it stays usable); openid/offline_access with client_credentials
invalid_target400an unknown resource/audience, or one the client is not allowed (RFC 8707); at code exchange, a resource the authorization request did not name; on refresh, a resource outside the original grant’s audiences (checked before the token is spent); audiences whose resource servers need different signing algorithms in one request
invalid_dpop_proof400malformed, replayed, stale or mismatched DPoP proof, or none from a client that must present one (RFC 9449); under FAPI 2.0, a proof not signed with PS256/ES256/EdDSA
authorization_pending400device or CIBA grant: the user has not approved yet (RFC 8628, CIBA Core §11)
slow_down400device or CIBA grant: polling faster than the interval
expired_token400device or CIBA grant: the device code or auth_req_id expired
access_denied400device or CIBA grant: the user denied; /bc-authorize: five requests already wait on the user; dynamic registration disabled (403); a forbidden operation
unknown_user_id400/bc-authorize: the hint names no active, unlocked user (CIBA Core §13)
invalid_binding_message400/bc-authorize: binding_message longer than 64 characters or holding anything but letters, digits, spaces and -_.:#
temporarily_unavailable503a dependency (for example the IP-rule store) could not be read
server_error500an internal failure; details are logged, never returned

A rate-limited OAuth request answers 429 with {"error": "slow_down", "error_description": "too many requests; retry after N seconds"} and Retry-After (see Rate limits).

Dynamic client registration

/register and /register/{client_id} (RFC 7591, RFC 7592) use the same JSON shape with the registration codes: invalid_redirect_uri and invalid_client_metadata (400), access_denied (403, registration disabled for the tenant), and invalid_token with a Bearer challenge (401) when an initial access token (under dcr.mode: initial_access_token) or a registration access token is missing, unknown, revoked, expired or used up.

Authorization endpoint

/authorize follows the order RFC 6749 §4.1.2.1 requires. Problems found before the redirect URI can be trusted are shown to the user as an HTML page with the code and description, because redirecting would send the browser somewhere unverified:

Code on the pageCause
invalid_requestunknown or disabled client, unregistered redirect_uri, a POST that is not form-encoded (415)
invalid_request_objecta request JWT that does not verify against the client’s keys (RFC 9101)
invalid_request_urian unknown, expired or already used PAR request_uri
access_deniedthe client’s own IP rules refuse the address (tenant-wide rules are refused the same way by the guard, 403)

Every other error returns to the client’s redirect URI in the requested response mode, with error, error_description, state and iss (RFC 9207):

https://app.example.com/callback?error=login_required&error_description=...&state=af0ifjsldkj&iss=https%3A%2F%2Fid.example.com%2Ft%2Facme
errorCause
invalid_requestmissing or malformed parameter, missing PKCE where required
unsupported_response_typeanything other than code
invalid_scopea scope the client may not request
unauthorized_clientthe client may not use the authorization code grant
request_not_supporteda request parameter inside a request object
request_uri_not_supporteda request_uri that /par did not issue
login_required, consent_requiredprompt=none and the user would have to sign in or consent
access_deniedthe user denied consent or cancelled the flow

Bearer token errors (RFC 6750)

/userinfo refuses a bad access token with a challenge and a JSON body:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="access token is invalid or expired"
Cache-Control: no-store

{"error": "invalid_token", "error_description": "access token is invalid or expired"}
StatuserrorCause
401invalid_requestno token presented
401invalid_tokenthe token does not verify, is expired or revoked, is neither a typ: at+jwt JWT nor a live opaque at_… token, or its client or user no longer exists
403insufficient_scopethe token lacks the openid scope

A DPoP-bound token presented as a plain bearer token, without a proof or with another key’s proof answers 401 with a challenge naming both schemes and the accepted proof algorithms (RFC 9449 §7.1):

WWW-Authenticate: DPoP error="invalid_token", error_description="...", algs="ES256 ES384 RS256 RS384 RS512 PS256 PS384 PS512 EdDSA", Bearer error="invalid_token"

ridm-auth

The ridm-auth crate answers the same way on your own API. Its AuthError keeps “not acceptable” (401), “not enough” (403) and “could not check” (503) apart, because a caller that cannot tell them apart cannot retry correctly:

FailureStatusWWW-AuthenticateBody
No Authorization: Bearer header401Bearer realm="<realm>" (no error code){"error_description"}
Malformed token, unknown kid, bad signature, unacceptable alg, wrong typ, expired, not yet valid, wrong issuer or audience, missing or malformed claim, sender-constrained token the validator cannot verify401Bearer realm="<realm>", error="invalid_token", error_description="..."{"error": "invalid_token", "error_description"}
Missing scope, permission or role403Bearer realm="<realm>", error="insufficient_scope", error_description="..."{"error": "insufficient_scope", "error_description"}
Key set or discovery document unreachable503none{"error_description"}, with Retry-After: 5
Validator misconfigured500none{"error_description"}

The realm defaults to api. AuthError::is_transient() is true only for the 503 family. Every response carries Cache-Control: no-store.

ridm-auth validates JWT access tokens only. An opaque at_… token, issued to a client registered with access_token_format: opaque, is refused as a malformed token (401 invalid_token); an API that must accept those checks them at /introspect instead.

Problem documents (RFC 9457)

The admin API, the account API, the flow API, recovery, verification, invitations and device verification answer errors as application/problem+json:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "urn:ridm:error:forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "missing permission `ridm:users:write`"
}
FieldMeaning
typea stable URN identifying the kind of error (below); match on this, not on detail
titlethe HTTP reason phrase
statusthe HTTP status
detaila human-readable explanation; omitted for 5xx, whose details are logged instead
errorsfor validation only: [{"field", "message"}]
typeStatusMeaning
urn:ridm:error:bad-request400malformed input, a failed business rule, an invalid cursor, a JSON body that does not parse or has unknown fields, a reference to something that does not exist
urn:ridm:error:validation400field-level validation failure, listed in errors
urn:ridm:error:unauthorized401no token, or a token that is not acceptable
urn:ridm:error:forbidden403authenticated but not allowed: a missing permission, a token scoped to another tenant, an inactive account, a refused IP address, a wrong CSRF token
urn:ridm:error:reauthentication-required403a security change needs a recent sign-in (below)
urn:ridm:error:impersonation-forbidden403something only the user may do (a credential, consent, the account itself), asked by an impersonated session or a token naming an actor
urn:ridm:error:not-found404the resource (or tenant) does not exist or is not visible to the caller
urn:ridm:error:conflict409a uniqueness violation (already exists) or a conflicting state
urn:ridm:error:rate-limited429a rate limit refused the request; Retry-After is set
urn:ridm:error:unavailable503a dependency (CAPTCHA provider, IP-rule store, a mail or SMS backend) failed
urn:ridm:error:internal500a database, cache or internal error; no detail

Admin and account API 401 responses also carry WWW-Authenticate: Bearer realm="ridm-admin" (with error="invalid_token" when a token was presented but refused) and Cache-Control: no-store. The account API shares the admin API’s realm.

Reauthentication required

Security changes in the account API (second factors, the password, contact details, linked identities, trusted devices, sessions, personal access tokens, deleting the account) need a sign-in from the last 15 minutes, and with a second factor once the account has one. Otherwise:

{
  "type": "urn:ridm:error:reauthentication-required",
  "title": "Forbidden",
  "status": 403,
  "detail": "sign in again with your second step to change security settings"
}

The client re-authorizes with max_age=0 (and an MFA class in acr_values when the detail asks for the second step) and retries with the new token.

Flow API

Problems in the flow API use the same types; CAPTCHA enforcement reports validation with {"field": "captcha_token", "message": "captcha_required"} or "captcha_failed". A refused credential is not a problem document but a plain 401 the sign-in page shows inline, carrying the flow’s failed-attempt count:

{"error": "invalid_credentials", "error_description": "incorrect identifier or password", "attempts": 2}
errorStep
invalid_credentialspassword
account_lockedpassword, after settings.lockout.max_failures
invalid_codeone-time codes, magic links, second-step codes
invalid_passkeypasskey sign-in and second step

SCIM errors

The SCIM endpoints answer RFC 7644 §3.12 error documents as application/scim+json:

{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "400",
  "scimType": "invalidFilter",
  "detail": "unexpected token at position 14"
}

scimType is one of invalidFilter, invalidSyntax, invalidPath, invalidValue, noTarget, uniqueness (409) and tooMany (a non-indexed user filter over more than 2,000 users, or startIndex beyond 2,000). A missing or invalid provisioning token is 401 with WWW-Authenticate: Bearer realm="scim".

Adding members to a group whose roles grant any ridm:* admin permission is 403 with no scimType (RFC 7644 defines none for an operation the credentials do not permit), and nothing is changed; see SCIM provisioning.

Rate limits

The OAuth, authorization and flow endpoint families are rate limited (see Rate limits, IP rules and CAPTCHA). Every response of a limited family carries the tightest bucket’s state:

RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 42
Retry-After: 42

A refused request is 429 with Retry-After in seconds, rendered in the family’s own format:

FamilyBody
/token, /introspect, /revoke, /userinfo, /device_authorization, /par, /register{"error": "slow_down", "error_description": "too many requests; retry after 42 seconds"}
flow API, recovery, verification, invitations, device verificationproblem document of type urn:ridm:error:rate-limited
/authorize, brokeringan HTML error page with the code slow_down

A tenant IP rule that refuses the address is 403 in the same three formats (access_denied for OAuth and HTML, urn:ridm:error:forbidden for problems). If Valkey is unreachable the limiter fails open with a warning in the log; the IP-rule check fails closed (503).

Deployment overview

rIDM is one stateless server binary (ridm-api) in front of two stateful services it does not run itself: Postgres and Valkey. The browser pages (sign-in, consent, the admin and account consoles) are a static export that the container image compiles into the server and serves on its own origin; they can also be hosted on any static web server. This page describes the moving parts and what each one needs; the rest of this section covers each part in detail.

Each release publishes a signed image, the Helm chart and static Linux binaries (Releases and verification); every part can also be built from a checkout, and these pages show both where it matters.

The parts

                      browsers, relying parties, SCIM clients
                                     |
                                     | https
                                     v
                  +-------------------------------------+
                  |  reverse proxy / load balancer      |   TLS
                  |  (nginx, Caddy, a cloud LB, ...)    |
                  +-------------------------------------+
                                     |
                       API paths and the UI's pages
                                     |
          +-------------+-------------+
          |             |             |
     +---------+   +---------+   +---------+
     | ridm-api|   | ridm-api|   | ridm-api|   stateless nodes, any number,
     +---------+   +---------+   +---------+   each serving the embedded UI
          |  \          |          /  |
          |   +---------+---------+   |
          v             v             v
     +-----------+           +-----------------+
     | Postgres  |           | Valkey          |
     | primary   |           | standalone,     |
     | (+ read   |           | Sentinel or     |
     |  replica) |           | Cluster         |
     +-----------+           +-----------------+

   outbound from the nodes: SMTP or an HTTP mail API, an SMS webhook, webhook
   receivers, upstream identity providers, the breached-password range API,
   an audit sink, an OTLP collector
PartWhat it isRequired
ridm-apiThe HTTP server: OIDC and OAuth endpoints, the flow API the sign-in pages drive, the admin, account and SCIM APIs, background jobsyes
PostgresThe system of record: tenants, users, credentials, clients, keys, refresh tokens, audit log, queues. Version 16 or later; CI and the compose file run 18.6yes
ValkeyShared short-lived state and the cache: browser sessions, authorization codes, login flows, one-time codes, rate-limit counters, the access-token denylist, job leader locks, cache invalidation. CI and the compose file run Valkey 9.1yes
UIui/out, the Next.js static export of the sign-in pages and the consoles; compiled into the image’s binary and served by every node, or hosted separatelyyes, for any browser sign-in
MailThe deployment’s SMTP defaults (SMTP_*), which tenants may override with their own SMTP server or an HTTP mail APIfor verification, recovery, invitations, email one-time codes
SMSA per-tenant HTTP webhook configured in the admin console; there is no deployment-wide SMS defaultonly for SMS one-time codes

The details of what lives where are in Postgres and Valkey.

Stateless nodes

A node keeps nothing that another node needs. Everything a second request might depend on (a browser session, a login flow half-way through, an authorization code, a rate-limit count) is in Valkey or Postgres, so any node can serve any request and no sticky sessions are needed. Each node does keep an in-process cache of the hottest read-mostly objects (tenants, clients, signing keys) for about 15 seconds; writes evict it on every node through a Valkey pub/sub channel. See Scaling and performance.

Nodes do share three things, and they must match across the fleet:

  • the same Postgres database and the same Valkey deployment (one logical Valkey: a standalone server, a Sentinel group or a cluster);
  • the same configuration for the settings that shape what a client sees, above all PUBLIC_URL (every tenant’s issuer is {PUBLIC_URL}/t/{slug}), UI_URL, TRUSTED_PROXIES and COOKIE_SECURE;
  • the same master key (MASTER_KEY or MASTER_KEY_FILE, with MASTER_KEY_VERSION and MASTER_KEY_PREVIOUS during a rotation). It encrypts signing keys, MFA credentials, identity-provider secrets and messaging settings at rest; a node with a different key cannot read them. See Signing keys and the master key.

Background jobs (key rotation, cleanup, delivery retries) run in every node’s process and take a Valkey lock per pass, so each job runs on one node at a time without any node being special.

Where the UI is served from

Embedded (the default). The container image builds the UI’s static export and compiles it into the server (the embedded-ui cargo feature), so every node serves the sign-in pages and both consoles itself, on PUBLIC_URL’s origin: /login/, /consent/, /console/, /account/ and the rest. Leave UI_URL unset (it defaults to PUBLIC_URL) and there is nothing else to host; the proxy sends every path to rIDM. API routes always win over pages, and a miss under an API prefix (/t/, /admin/, /scim/, /.well-known/, the probes, /metrics, /docs, /openapi.json) stays the API’s 404 rather than a page; any other unknown path gets the export’s 404.html. Pages are served with trailingSlash semantics (/login redirects to /login/ with a 308, query kept), hashed build assets under /_next/static/ are cached for a year as immutable, pages revalidate against an ETag, and text is gzip-compressed for clients that accept it. EMBEDDED_UI=false turns the pages off on a node that should serve the API alone.

Hosted separately. A binary built without the feature (a plain cargo build -p ridm-api) serves the API only, and a node with UI_URL on another origin does not serve its embedded pages. Build the UI yourself:

cd ui
npm install
npm run build          # static export to ui/out

and serve ui/out from any static web server. Two layouts work:

  • Same origin. One host name; the proxy sends API paths (/t/, /admin/, /scim/, /.well-known/, /openapi.json, /healthz, /readyz) to rIDM and everything else to ui/out. Leave UI_URL unset and build the UI with NEXT_PUBLIC_API_URL empty. TLS and reverse proxies has nginx and Caddy starting points.
  • Separate host. Build the UI with NEXT_PUBLIC_API_URL=https://id.example.com and set UI_URL on the server to where the pages are hosted. rIDM sends browsers to {UI_URL}/login/, {UI_URL}/consent/ and the other pages, admits the UI’s origin for cross-origin calls, and registers the built-in console clients with redirect URIs under UI_URL.

Either way the static host must add the framing headers the pages cannot set themselves (see Security controls). In development the UI runs under next dev with the API proxied; see Run rIDM locally.

A tenant’s custom domain moves its issuer and endpoints to that host. With the embedded UI its sign-in pages and account console move there too; with the UI hosted separately they stay at UI_URL.

Outbound connections

Nodes make outbound requests, so egress rules need to allow whichever of these a deployment uses:

DestinationWhen
SMTP server or HTTP mail APIany tenant sends email
SMS webhooka tenant configured SMS
Webhook receiversa tenant registered webhooks
Upstream identity providers (discovery, token and JWKS endpoints)a tenant uses identity brokering
Client JWKS URLsa client authenticates with private_key_jwt and a jwks_uri
https://api.pwnedpasswords.com/range/ or BREACH_CHECK_URLa tenant turns on the breached-password check; BREACH_CHECK_URL=off disables it for the deployment
AUDIT_SINK_URLaudit export is configured
OTEL_EXPORTER_OTLP_ENDPOINTtrace export is configured

URLs a tenant administrator or a client registration chooses (webhook receivers, back-channel logout URIs, client jwks_uri, identity-provider endpoints, a tenant’s HTTP email or SMS gateway, a CAPTCHA verify_url, a tenant’s SMTP host) are only ever connected to at public addresses: private, loopback, link-local and similar ranges are refused unless OUTBOUND_ALLOW_NETWORKS opens that network for internal applications, redirects are not followed, and HTTP(S)_PROXY is ignored. The URLs the operator sets in the environment (AUDIT_SINK_URL, BREACH_CHECK_URL, the collector, the deployment’s SMTP_HOST) are not filtered. See Rate limits, IP rules and CAPTCHA.

Where to go next

docker-compose

deploy/docker-compose.yml runs the API with Postgres and Valkey on one machine. It is the fastest way to evaluate rIDM and the stack CI boots to test the container image. It is not a production deployment: it has no TLS and no reverse proxy, publishes Postgres and Valkey on the host, and defaults every password. For production on one host use deploy/production/ instead.

Services and profiles

ServiceProfileImageWhat it does
postgresalwayspostgres:18.6-alpineThe database. On first initialisation it runs deploy/postgres/init-app-role.sh, which creates the migrator and app roles
valkeyalwaysvalkey/valkey:9.1.2-alpine3.24Valkey with append-only persistence, maxmemory 256mb, allkeys-lru
migratealwaysthe rIDM imageOne-shot: runs ridm-api migrate as the migrator role, then exits
apiprodthe rIDM imageThe API with JSON logs, Secure cookies, Swagger UI off, no seeding
mailpitdevaxllent/mailpit:v1.31.1Catches every outgoing email; web UI on 8025, SMTP on 1025
api-devdevthe rIDM imageThe API with pretty logs, COOKIE_SECURE=false, Swagger UI at /docs, SMTP pointed at Mailpit, and first-run bootstrap

Choose one profile. api and api-dev both publish RIDM_HTTP_PORT, so the two cannot run together. Services without a profile start with either.

The rIDM image is ${RIDM_IMAGE:-ghcr.io/zerosandonesllc/ridm:latest}, and each service using it also has a build section pointing at api/Dockerfile. Releases publish that image (Releases and verification); until the first one is cut, build it from the checkout:

docker compose -f deploy/docker-compose.yml --profile dev build

or pass --build to up. To run an image you built elsewhere, set RIDM_IMAGE and use up --no-build, which is what CI does.

First run

MASTER_KEY has no default: compose refuses to start without it. It is 32 bytes, hex or base64:

export MASTER_KEY=$(openssl rand -hex 32)

Keep it. It encrypts signing keys, MFA secrets and provider credentials in the database; a database restored without the same key cannot decrypt them. Put it in the .env file compose reads, or your shell profile, rather than retyping it: starting the stack later with a different key leaves the stored secrets unreadable. See Signing keys and the master key.

The dev profile

docker compose -f deploy/docker-compose.yml --profile dev up -d --build
curl http://localhost:8080/readyz

Order of events: Postgres initialises its volume (and creates the two roles), migrate applies every migration (the first one seeds the master tenant) and exits, then api-dev starts. Because BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD are set, the first start creates the global administrator in master (admin@ridm.local / ChangeMe-Now-1234 unless overridden), who must change the password at first sign-in. Bootstrap does nothing once a global owner exists. The service also sets BOOTSTRAP_SAMPLE_CLIENT=true, which creates a public single-page-app client, sample-spa, in master: PKCE, redirect URI http://localhost:3000/callback, post-logout redirect http://localhost:3000/, CORS origin http://localhost:3000. It is created once and left alone afterwards. For your own applications, see Registering clients.

Mail sent by any tenant appears at http://localhost:8025.

The image embeds the UI, so the admin console is at http://localhost:8080/console/ and the sign-in pages are on the same origin; sign in through tenant master as admin. To work on the pages themselves, run them under next dev as described in Run rIDM locally and point UI_URL at it (api-dev does not pass UI_URL through; add it to the service’s environment or run the API outside compose as the quickstart does).

The prod profile

export PUBLIC_URL=https://id.example.com
docker compose -f deploy/docker-compose.yml --profile prod up -d --build

The api service does not bootstrap. Create the first administrator with the server’s bootstrap command, run once against the same database:

docker compose -f deploy/docker-compose.yml --profile prod run --rm \
  -e DATABASE_URL=postgres://ridm_migrator:ridm_migrator@postgres:5432/ridm \
  api bootstrap --email admin@example.com

It prompts for the password (or reads it from stdin with --password-stdin) and marks it for change at first sign-in unless --no-must-change is given. It runs as the service’s own DML-only role and does not migrate: the migrate service has already applied every migration, and if any were pending bootstrap would exit 1 and tell you to run ridm-api migrate first.

The prod profile sets COOKIE_SECURE=true, so browsers only keep the session cookie over https: put a TLS-terminating proxy in front of port 8080 and set PUBLIC_URL to the https URL clients use (see TLS and reverse proxies). Its default PUBLIC_URL is http://localhost:8080, which is only useful for API-level smoke tests.

Variables

Compose substitutes these from the environment or from a .env file. With -f deploy/docker-compose.yml the default .env is deploy/.env, next to the compose file, not one in the directory you run compose from; to use the repository root’s .env, pass --env-file .env.

VariableDefaultUsed for
MASTER_KEYnone, requiredThe master key
PUBLIC_URLhttp://localhost:${RIDM_HTTP_PORT}Externally visible base URL; issuers are {PUBLIC_URL}/t/{slug}
RIDM_HTTP_PORT8080Host port of api / api-dev
RIDM_PG_PORT5432Host port of Postgres
RIDM_VALKEY_PORT6379Host port of Valkey
RIDM_MAILPIT_UI_PORT8025Host port of the Mailpit web UI
RIDM_MAILPIT_SMTP_PORT1025Host port of Mailpit’s SMTP listener, for an API run outside compose
RIDM_IMAGEghcr.io/zerosandonesllc/ridm:latestImage for migrate, api, api-dev
POSTGRES_PASSWORDridmPassword of the ridm superuser
RIDM_MIGRATOR_USER, RIDM_MIGRATOR_PASSWORDridm_migrator / ridm_migratorThe schema-owning role migrate connects as
RIDM_APP_USER, RIDM_APP_PASSWORDridm_app / ridm_appThe DML-only role the API connects as
LOG_FORMATjson (api); pretty in api-devLog format
RUST_LOGinfo (api); info,ridm_api=debug in api-devLog filter
TRUSTED_PROXIESemptyProxies whose forwarding headers are honoured
COOKIE_SECUREtrue (api); forced false in api-devSecure cookies
DOCS_ENABLEDfalse (api); forced true in api-devSwagger UI at /docs
BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORDadmin@ridm.local / ChangeMe-Now-1234api-dev only: the first global administrator

The role passwords are read by init-app-role.sh only when the Postgres volume is first initialised. Changing them afterwards changes what the API and migrate connect with, not the roles themselves; alter the roles in Postgres to match.

Other server settings (SMTP_* for the api service, UI_URL, METRICS_TOKEN, OTEL_EXPORTER_OTLP_ENDPOINT, AUDIT_SINK_URL, pool sizes, rate limits) are not passed through by the compose file. Add them to the service’s environment block in a docker-compose.override.yml; the full list is in Server configuration.

Every port, including Postgres and Valkey, is published on all host interfaces. That suits a laptop. On a shared or internet-facing host, drop the ports entries of postgres and valkey in an override, because rIDM authenticates to them but cannot protect them from anyone else who can connect.

Volumes

VolumeMounted atHolds
pgdata/var/lib/postgresqlThe database
valkeydata/dataValkey’s append-only file

docker compose down keeps both; down -v deletes them, which is a full reset: the next up initialises a fresh database, recreates the roles and bootstraps again. Losing valkeydata alone signs every browser out and drops in-flight sign-ins; see Postgres and Valkey.

Tuning in the file

Postgres runs with shared_buffers=256MB, max_connections=200 and logs statements slower than 250 ms. Valkey is capped at 256 MB with allkeys-lru eviction. Both are sized for evaluation. Under memory pressure allkeys-lru may evict live browser sessions and access-token denylist entries along with cache entries; for anything beyond a trial, give Valkey enough memory that it never evicts (see Postgres and Valkey).

Upgrading the stack

Pull or rebuild the image and run up again. migrate runs on every up, applies whatever migrations the new image carries, and the API starts only after it succeeds. Take a database backup before upgrading; see Upgrading.

Production with docker-compose

deploy/production/ runs rIDM on one host for real use: the server behind a TLS-terminating reverse proxy, Postgres and Valkey on a network with no route out and no published port, and every secret in a file rather than in the environment. It is a separate file from deploy/docker-compose.yml, which is for evaluation and development.

Every CI run boots this stack behind each of the three proxies and checks it from the outside (see What CI checks).

The stack

ServiceImageNetworksWhat it does
postgrespostgres:18.6-alpinebackendThe database. First initialisation creates the migrator and app roles (deploy/postgres/init-app-role.sh) with passwords from secret files
valkeyvalkey/valkey:9.1.2-alpine3.24backendValkey with a password, append-only persistence and noeviction
migratethe rIDM imagebackendOne-shot: ridm-api migrate as the schema owner, then exits
ridmthe rIDM imageedge, backendThe server, with the embedded UI. Not published on the host
caddy, nginx or traefiksee belowedgeThe proxy, on ports 80 and 443. One of the three, by profile

backend is an internal network: nothing on it can reach the internet, and nothing on the host can reach Postgres or Valkey. edge carries the proxy and the server, and the server’s outbound traffic (SMTP, webhooks, upstream identity providers). Its subnet is fixed (RIDM_EDGE_SUBNET, default 172.30.53.0/24) so the proxy’s address (RIDM_PROXY_ADDRESS, default 172.30.53.10) is known in advance, and that one address is the server’s TRUSTED_PROXIES. Trusting the whole subnet would be wrong: with Docker’s userland proxy, a connection from the host arrives at the proxy from the subnet’s gateway, and behind a proxy that appends to X-Forwarded-For (nginx) rIDM would skip that trusted address and record whatever the caller had written further left. The smoke test below catches exactly that.

Hardening applied to every service: no-new-privileges, JSON-file logs capped at five files of 20 MB, restart: unless-stopped. The rIDM containers also run with a read-only root filesystem, every capability dropped, a 16 MB /tmp and a memory limit (RIDM_MEMORY_LIMIT, default 1g); Caddy and Traefik keep only NET_BIND_SERVICE.

Setting it up

cd deploy/production
cp .env.example .env     # edit: RIDM_DOMAIN, COMPOSE_PROFILES, RIDM_TLS_MODE, ...
./init.sh                # writes ./secrets once
docker compose up -d

init.sh writes random passwords for the Postgres superuser, the migrator and app roles and Valkey; the connection URLs built from them; the master key; a metrics token; an empty smtp_password; and, when BOOTSTRAP_ADMIN_EMAIL is set, a one-time password for the first administrator. It never overwrites a file that has content, so running it again is safe and rotates nothing. secrets/ is 0700; the files in it are 0644, because compose bind-mounts them unchanged and the containers read them as their own non-root users. secrets/, certs/ and .env are git-ignored.

Copy secrets/master_key off the host. It encrypts signing keys, MFA secrets and provider credentials in the database; a backup restored without it is unreadable. See Signing keys and the master key.

Releases publish the image (Releases and verification); until the first one is cut, build it from the checkout, docker compose build, or set RIDM_IMAGE to one you built and pushed. Pin a version, or better a digest, rather than latest.

The first administrator

With BOOTSTRAP_ADMIN_EMAIL set before init.sh runs, the first start creates the global administrator in master (username admin) with the password in secrets/bootstrap_admin_password, marked for change at first sign-in. Sign in at https://{RIDM_DOMAIN}/console/. Bootstrap does nothing once an owner exists, so the setting can stay. To retire it, empty BOOTSTRAP_ADMIN_EMAIL and the file together: the server refuses to start with only one of the two set.

Without it, create one with the server’s bootstrap command:

docker compose run --rm -T ridm bootstrap --email admin@example.com --password-stdin

Mail

Set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_FROM and SMTP_SECURITY in .env and put the password in secrets/smtp_password. This is the fallback for tenants without SMTP settings of their own; see Messaging.

Choosing a proxy

COMPOSE_PROFILES in .env picks one: caddy, nginx or traefik. All three are configured alike: one site for RIDM_DOMAIN and every name in RIDM_CUSTOM_DOMAINS (space-separated), everything proxied to rIDM with Host passed through, the client’s address in X-Forwarded-For, no request-body limit below the 32 MiB bulk import takes, /metrics refused, and no headers of their own: rIDM sends its security headers and HSTS. What differs is certificates.

ProxyImageCertificatesConfiguration
Caddycaddy:2.11.4-alpineACME (RIDM_TLS_MODE=acme, default) or filesdeploy/proxy/caddy/Caddyfile
nginxnginx:1.30.5-alpinefiles onlydeploy/proxy/nginx/templates/default.conf.template
Traefiktraefik:v3.7.13ACME (default) or filesthe traefik service’s command: and deploy/proxy/traefik/dynamic/ridm.yml

ACME (Caddy, Traefik): the proxy obtains and renews Let’s Encrypt certificates for every configured host through the HTTP challenge, so ports 80 and 443 must be reachable from the internet and each host’s DNS must point at this machine first. Certificates live in the caddydata or traefikacme volume.

Files (RIDM_TLS_MODE=files, and always for nginx): put fullchain.pem and privkey.pem in deploy/production/certs/, one certificate naming every host. Renewing them is your ACME client’s job; afterwards reload the proxy (docker compose exec nginx nginx -s reload, or restart Caddy or Traefik).

Plain http is redirected to https by all three. A host the proxy does not serve is refused (nginx rejects the TLS handshake and answers 444 on port 80; Caddy has no certificate for it; Traefik answers 404).

The same files work outside compose with small changes, described in each file’s header and in TLS and reverse proxies.

Custom domains

A tenant’s custom domain is set on the tenant as usual; this stack also needs the domain in RIDM_CUSTOM_DOMAINS, a DNS record pointing at the host, and (in files mode) the name in the certificate. Then docker compose up -d recreates the proxy with the new site, and an ACME proxy obtains its certificate as it starts.

Variables

Read from .env next to compose.yml (.env.example has all of them):

VariableDefaultUsed for
COMPOSE_PROFILESnoneThe proxy: caddy, nginx or traefik
RIDM_DOMAINrequiredThe public host; PUBLIC_URL is https://{RIDM_DOMAIN}
RIDM_CUSTOM_DOMAINSemptyTenants’ custom domains, space-separated
RIDM_TLS_MODEacmeacme or files (nginx always uses files)
BOOTSTRAP_ADMIN_EMAILemptyFirst administrator, created on first start
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_FROM, SMTP_SECURITYempty, 587, empty, empty, starttlsServer-wide SMTP fallback
RIDM_IMAGEghcr.io/zerosandonesllc/ridm:latestThe rIDM image
RIDM_MEMORY_LIMIT1gMemory limit of the server container
RIDM_PG_SHARED_BUFFERS512MBPostgres shared_buffers
RIDM_VALKEY_MAXMEMORY512mbValkey maxmemory; with noeviction, writes fail when it is reached, so size it with headroom
RIDM_HTTP_PORT, RIDM_HTTPS_PORT80, 443Host ports of the proxy
RIDM_EDGE_SUBNET, RIDM_PROXY_ADDRESS172.30.53.0/24, 172.30.53.10The proxy network and the proxy’s address in it; change both together, and only on a clash
RIDM_SECRETS_DIR, RIDM_CERTS_DIR./secrets, ./certsWhere the secret files and certificates are
RUST_LOGinfoLog filter

Other server settings are not passed through. Add them to the ridm service’s environment in a compose.override.yml; the full list is in Server configuration.

Operating it

  • Metrics. /metrics is refused at the proxy and needs the token in secrets/metrics_token. Scrape it from a Prometheus attached to the edge network: http://ridm:8080/metrics with that bearer token.
  • Logs. JSON lines on the server’s standard output: docker compose logs ridm.
  • Upgrading. Change RIDM_IMAGE and docker compose up -d. migrate runs first and the server starts only if it succeeds. Back up the database before upgrading; see Upgrading.
  • Backups. docker compose exec -T postgres pg_dump -U ridm -Fc ridm > ridm.dump (as the superuser ridm, which row level security does not stop). Keep a copy of secrets/master_key somewhere else: never beside the dumps. See Backup and restore.
  • Security contact. Set SECURITY_CONTACT in .env so that /.well-known/security.txt tells researchers where to report a problem.
  • More than one host. This stack is one node. For several, run the server on each host against a shared Postgres and Valkey, or use the Helm chart.

What CI checks

deploy/production/smoke/run.sh, in the packaging job, generates a private CA and a certificate, runs init.sh, starts the stack in files mode with the image CI built, and gives the master tenant a custom domain. Then, behind each proxy in turn, it checks over https that plain http is redirected; discovery names the https issuer and JWKS answers; /console/ and /login/ arrive with rIDM’s framing headers and exactly one HSTS header; /metrics is refused; a forged X-Forwarded-For is not the address rIDM records, and neither is the proxy’s own (read from rIDM’s per-address rate-limit counters in Valkey); a 20 MiB bulk-import body reaches rIDM whole (an administrator’s token makes rIDM read it all before refusing it as JSON, so a proxy limit or a cut-off upload fails the check); the custom domain serves the tenant with its own issuer; and an unknown host gets nothing from rIDM. Once per run it also checks that Postgres and Valkey publish no port, that no secret’s value appears in docker inspect, and that the server is not given the migrator’s database URL.

docker build -f api/Dockerfile -t ridm:smoke .
RIDM_IMAGE=ridm:smoke deploy/production/smoke/run.sh            # all three
RIDM_IMAGE=ridm:smoke deploy/production/smoke/run.sh nginx      # one

It binds ports 80 and 443 on the machine running it.

Container image

The server ships as one container image built from api/Dockerfile. The same image runs everywhere and is configured entirely through environment variables. Each release publishes it to ghcr.io/zerosandonesllc/ridm for linux/amd64 and linux/arm64, signed and with an SBOM attached; see Releases and verification. To build it yourself instead:

Building

docker build -f api/Dockerfile -t ridm .
docker buildx build --platform linux/amd64,linux/arm64 -f api/Dockerfile -t ridm .

Run either from the repository root; the build context is the whole workspace. The build has three stages:

  • ui (node:26.13.0-bookworm-slim, overridable with --build-arg NODE_VERSION=...) runs npm ci and npm run build in ui/ with NEXT_PUBLIC_API_URL empty, producing the same-origin static export ui/out. It runs once on the build platform, since the files are the same for every architecture.
  • builder (rust:1.98.1-bookworm, overridable with --build-arg RUST_VERSION=...) compiles the dependencies first against stub sources, so a source change does not rebuild them, then builds ridm-api in release mode with --locked and --features embedded-ui, which compiles ui/out into the binary, and strips it.
  • runtime (gcr.io/distroless/cc-debian12:nonroot) holds only the binary at /ridm-api. There is no shell and no package manager, and no dynamic OpenSSL: TLS uses rustls with the aws-lc-rs provider.

For organizations that must run FIPS 140-3 validated cryptography there is a second image, built from api/Dockerfile.fips on Red Hat UBI 9 and published as ridm:<version>-fips. It is configured the same way. See FIPS 140-3.

The image serves the sign-in pages and both consoles itself on PUBLIC_URL’s origin (/login/, /console/, /account/, …); nothing else needs hosting. Set EMBEDDED_UI=false for a node that should answer the API alone, or UI_URL to another origin when the pages are hosted elsewhere; see Deployment overview.

Runtime properties

PropertyValue
Entrypoint/ridm-api
Usernonroot:nonroot (distroless’s UID/GID 65532)
Port8080 (EXPOSE 8080, BIND_ADDR=0.0.0.0:8080 set in the image)
SignalsSIGTERM or SIGINT stops accepting connections and drains in-flight requests for up to 20 seconds
ConfigurationEnvironment variables only; a .env file in the working directory is read if present

Because the process runs as non-root it cannot bind ports below 1024 without extra capabilities; keep 8080 and map it, or let a proxy listen on 443.

The minimum environment is DATABASE_URL, REDIS_URL, PUBLIC_URL and the master key (MASTER_KEY, or MASTER_KEY_FILE pointing at a mounted secret file). Every variable is listed in Server configuration. A configuration error prints configuration error: ... and exits with status 2 before anything else happens.

docker run --rm -p 8080:8080 \
  -e DATABASE_URL=postgres://ridm_app:secret@db.internal:5432/ridm \
  -e REDIS_URL=redis://valkey.internal:6379 \
  -e PUBLIC_URL=https://id.example.com \
  -e MASTER_KEY_FILE=/run/secrets/ridm_master_key \
  -e TRUSTED_PROXIES=10.0.0.0/8 \
  -v /srv/ridm/master_key:/run/secrets/ridm_master_key:ro \
  ridm

MASTER_KEY_FILE accepts the raw 32 bytes, or the same hex or base64 text MASTER_KEY takes. When both are set, MASTER_KEY wins.

Commands

The entrypoint is the server; arguments select a one-shot command instead.

ArgumentsWhat it doesExit status
(none)Runs the server1 on a fatal error, 2 on a configuration error
migrateApplies pending migrations and exits0 applied (or nothing to do), 1 failure, 2 configuration error
bootstrap [--email E] [--username U] [--password-stdin] [--no-must-change]Creates the first global administrator in the master tenant (and the sample-spa client when BOOTSTRAP_SAMPLE_CLIENT=true is set with the BOOTSTRAP_ADMIN_* variables). Applies pending migrations only when MIGRATE_ON_START=true; otherwise pending migrations make it stop0 created or already bootstrapped, 1 failure (including pending migrations), 2 bad arguments or configuration
rotate-master-key [--status | --new-generation]Re-encrypts secrets at rest under the current generation; --status only reports; --new-generation first has the key custody backend wrap a new generation0 done, 1 failure
openapiPrints the OpenAPI document of the admin API0
--healthcheckProbes the local server’s /healthz (see below)0 healthy, 1 not

Every command loads the configuration, so migrate and bootstrap also need REDIS_URL and PUBLIC_URL set, even though migrate only talks to Postgres. bootstrap needs the master key (or the key custody settings) too; migrate does not read them.

The image is built with every optional feature: the embedded UI, Kerberos and all the key custody backends. For PKCS#11, mount the HSM vendor’s library and whatever it depends on into the container and point PKCS11_MODULE at it.

bootstrap runs as DATABASE_URL’s role, normally the DML-only app role, so run migrate as the migrator first. With migrations pending and MIGRATE_ON_START off it exits 1 with a message telling you to run ridm-api migrate.

Master key rotation is described in Rotating keys.

Running migrations

Migrations are embedded in the binary (from api/migrations) and applied with sqlx, which takes a Postgres advisory lock, so two runners at once are safe: one waits for the other. There are two ways to apply them.

A separate migrate step (recommended). Run the image with migrate as the role that owns the schema, then start the servers as the DML-only role:

docker run --rm \
  -e DATABASE_URL=postgres://ridm_migrator:secret@db.internal:5432/ridm \
  -e REDIS_URL=redis://valkey.internal:6379 \
  -e PUBLIC_URL=https://id.example.com \
  -e MASTER_KEY_FILE=/run/secrets/ridm_master_key \
  -v /srv/ridm/master_key:/run/secrets/ridm_master_key:ro \
  ridm migrate

This is what the compose file’s migrate service does, and what a Kubernetes Job or a pre-deploy task in your pipeline should do. The API never holds DDL rights, so it cannot disable the row level security that separates tenants.

At startup. MIGRATE_ON_START=true makes each server check for pending migrations before it listens and apply them, if there are any, as its own DATABASE_URL role. Nothing pending means nothing is applied, so a server running as the DML-only app role starts normally with the flag on once ridm-api migrate has run; a pending migration then fails startup, because that role cannot apply it. For the server to migrate itself, its role must own the schema, which gives the running server the power to alter tables and turn row level security off. Simpler, and weaker; keep that for single-node trials.

With MIGRATE_ON_START off (the default) the server does not touch the schema; if migrations are pending it logs a warning (database migrations are pending) and starts.

The two roles and why they differ are covered in Postgres and Valkey. Migrations are forward-only; there are no down migrations, so a rollback means restoring the database from before the upgrade.

Health checks

EndpointMeaningUse as
GET /healthzThe process is up and serving HTTP. Never touches Postgres or Valkey. 200 {"status":"ok","version":"..."}Liveness probe
GET /readyzPostgres and Valkey both answer and no event queue is 80% full. 200 with {"status":"ok","checks":{"database":"ok","cache":"ok","events":"ok"}}, or 503 with "status":"degraded" and the failing check as "fail" ("saturated" for events)Readiness probe, load-balancer health check

Use /healthz for liveness so a database outage does not make the orchestrator restart every node, and /readyz for readiness so traffic stops reaching a node that cannot serve it. More in Observability.

The image has no curl, so the binary probes itself: /ridm-api --healthcheck requests /healthz at the address in BIND_ADDR, with a 2-second timeout. A wildcard bind address (0.0.0.0 or ::) becomes the loopback address of the same family; any other address is dialled as it is. When TLS_CERT is set the probe speaks https and trusts exactly that certificate: the server must present it and prove it holds its key. The certificate’s names, chain and dates are not checked, because the probe dials an IP address and only asks whether the process answers.

The Dockerfile declares the probe:

HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=3 \
    CMD ["/ridm-api", "--healthcheck"]

The compose file overrides the timing for local use:

healthcheck:
  test: ["CMD", "/ridm-api", "--healthcheck"]
  interval: 10s
  timeout: 3s
  retries: 10
  start_period: 15s

Kubernetes ignores Docker’s HEALTHCHECK; use HTTP probes on /healthz and /readyz there, or an exec probe running /ridm-api --healthcheck.

The port opens only after startup work finishes: the server connects to Postgres, applies pending migrations if MIGRATE_ON_START is on, pings Valkey, runs bootstrap if configured, and makes sure every tenant has its console clients. Give the probes a start period (the image allows 60 seconds, the compose file 15 seconds and then ten retries) rather than expecting the port at once.

Kubernetes

Use the Helm chart; see Kubernetes (Helm). It is the same pieces a hand-written manifest would need: a Deployment of the image with the probes above, a Job running migrate as the migrator role before each rollout, the master key and database passwords from Secrets (the key mounted as a file for MASTER_KEY_FILE, out of the process environment), and a Service behind your Ingress with TRUSTED_PROXIES set to the Ingress controller’s pod network, with terminationGracePeriodSeconds above the 20-second drain.

Kubernetes (Helm)

The chart in deploy/helm/ridm runs rIDM on any Kubernetes 1.27 or later: a Deployment of the image (which serves the UI itself), a Service, and optionally an Ingress, a HorizontalPodAutoscaler, a PodDisruptionBudget and a Prometheus Operator ServiceMonitor. Migrations run in a Job before every install and upgrade. It does not run Postgres or Valkey: bring your own (a managed service, an operator such as CloudNativePG, or your own StatefulSets).

Each release publishes the chart to oci://ghcr.io/zerosandonesllc/charts/ridm, signed (Releases and verification); it can also be installed from a checkout.

Before installing

  1. Postgres 16+ with two roles, as described in Postgres and Valkey: a schema owner for migrations (ridm_migrator) and the DML-only role the pods connect as (ridm_app). deploy/postgres/init-app-role.sh creates both.

  2. Valkey 9+ or Redis 8+, standalone, Sentinel or Cluster.

  3. A master key, 32 random bytes, kept somewhere you will not lose it:

    kubectl create namespace ridm
    kubectl -n ridm create secret generic ridm-master-key --from-literal=key="$(openssl rand -hex 32)"
    
  4. The public URL browsers and relying parties will use, with TLS in front of it.

Installing

A minimal values file, with the connection strings in a Secret you manage:

kubectl -n ridm create secret generic ridm-db \
  --from-literal=app='postgres://ridm_app:...@postgres:5432/ridm' \
  --from-literal=owner='postgres://ridm_migrator:...@postgres:5432/ridm' \
  --from-literal=valkey='redis://valkey:6379'
# ridm-values.yaml
publicUrl: https://id.example.com
trustedProxies: 10.0.0.0/8          # the ingress controller's pod network
database:
  existingSecret: {name: ridm-db, key: app}
redis:
  existingSecret: {name: ridm-db, key: valkey}
masterKey:
  existingSecret: {name: ridm-master-key, key: key}
migrations:
  database:
    existingSecret: {name: ridm-db, key: owner}
ingress:
  enabled: true
  className: nginx
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 32m   # bulk user import
  hosts:
    - host: id.example.com
      paths: [{path: /, pathType: Prefix}]
  tls:
    - secretName: id-example-com-tls
      hosts: [id.example.com]
helm install ridm oci://ghcr.io/zerosandonesllc/charts/ridm --version 0.4.1 \
  -n ridm -f ridm-values.yaml --wait
# or, from a checkout:
helm install ridm deploy/helm/ridm -n ridm -f ridm-values.yaml --wait

Every secret value can instead be given inline (database.url, redis.url, masterKey.value, migrations.database.url, smtp.password, metrics.token, bootstrap.adminPassword); the chart then keeps it in a Secret of its own. A reference to your own Secret wins over an inline value. The chart refuses to render without publicUrl, a database, a cache and a master key, and a values.schema.json rejects misspelled or mistyped values.

Then create the first administrator, once:

kubectl -n ridm exec -i deploy/ridm -- /ridm-api bootstrap --email you@example.com --password-stdin < password.txt

or install with bootstrap.enabled=true, bootstrap.adminEmail and a password; the first pod to start creates the administrator and the rest find it. Either way the password must be changed at first sign-in, at https://id.example.com/console/.

What the chart does

PieceBehaviour
MigrationsA pre-install/pre-upgrade hook Job runs ridm-api migrate as the schema owner. Its credential lives in a hook Secret deleted with the Job on success, so the pods never hold it. A failed Job stays for its logs until the next attempt. migrations.enabled=false leaves migrations to you
PodsNon-root (UID 65532), read-only root filesystem, no capabilities, RuntimeDefault seccomp, no service-account token (rIDM never calls the Kubernetes API; keyCustody.mountServiceAccountToken mounts it for Vault’s Kubernetes auth method)
Master keyMounted read-only at /run/secrets/ridm/master-key and read through MASTER_KEY_FILE, out of the process environment (masterKey.asFile, default on). Or held by an HSM or KMS instead (keyCustody, below). The migration Job never receives it
ProbesStartup and liveness on /healthz, readiness on /readyz (database and cache). The startup probe allows five minutes: start-up brings every tenant’s built-in clients in line, which takes a while on a large database
Start-upReplicas starting together take turns at bootstrap and the built-in clients under a Postgres advisory lock
RolloutsRolling updates with no pod unavailable; pods restart when the chart’s configuration or secrets change (checksum annotations). terminationGracePeriodSeconds is 30, above the server’s 20-second drain
SpreadingSoft topology spread across nodes and zones unless you set your own constraints
AvailabilityA PodDisruptionBudget (maxUnavailable: 1) whenever more than one replica runs
Scalingautoscaling.enabled adds an HPA on CPU (and memory if set). Nodes are stateless; see Scaling and performance
Metricsmetrics.serviceMonitor.enabled scrapes /metrics, with the bearer token from metrics.token or its Secret

Configuration

The chart’s values.yaml documents every value. They map onto the server’s environment variables: publicUrl → PUBLIC_URL, uiUrl → UI_URL, embeddedUi → EMBEDDED_UI, trustedProxies → TRUSTED_PROXIES, cookieSecure, logFormat, logLevel (RUST_LOG), docsEnabled, migrateOnStart, the pool sizes, masterKey.version/previous for a master key rotation, keyCustody.* for an HSM or KMS holding the master key, dataRegions.* for regional databases, mtls.* for mutual TLS, smtp.* for the deployment’s mail defaults. Anything else goes in env (plain values), extraEnv (full EnvVar entries) or extraEnvFrom, and anything a variable points at (a CA bundle, a certificate) is mounted with extraVolumes and extraVolumeMounts (migrations.extraVolumes and migrations.extraVolumeMounts for the migration Job, which has its own pod):

env:
  RETENTION_DAYS: 14
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.observability:4318
  OUTBOUND_ALLOW_NETWORKS: 10.20.0.0/16

Keep trustedProxies accurate: behind an ingress controller the TCP peer is always the controller, so without it every client shares one address for rate limits and IP rules (see TLS and reverse proxies).

A tenant’s custom domain needs its own Ingress rule (and certificate) pointing at the same Service with the Host header preserved, which every common ingress controller does.

TLS to Postgres with client certificates

database.tls mounts certificates for the pods’ database connections and adds the matching parameters to an inline database.url:

database:
  url: postgres://ridm_app@postgres.db.svc:5432/ridm        # no password
  tls:
    clientCertSecret: ridm-db-app-cert    # kubernetes.io/tls: tls.crt, tls.key
    caSecret: ridm-db-ca                  # the server's CA, under caKey (ca.crt)
    sslMode: verify-full                  # or verify-ca, require
migrations:
  database:
    url: postgres://ridm_migrator@postgres.db.svc:5432/ridm
    tls:
      clientCertSecret: ridm-db-migrator-cert
      caSecret: ridm-db-ca

The pods connect as ridm_app and the migration Job as ridm_migrator, each with its own certificate (its CN is the role name, which pg_hba.conf’s cert method maps onto the role), and no password is stored anywhere. cert-manager issues exactly this kind of Secret. The client certificate is mounted at /run/secrets/ridm-db-tls/tls.crt and tls.key, the CA at /run/secrets/ridm-db-ca/<caKey>; a URL that comes from existingSecret must carry sslmode, sslcert, sslkey and sslrootcert with those paths itself, since the chart cannot edit it. A caSecret alone (with a password in the URL) turns on verify-full against that CA.

redis.tls does the same for Valkey in the single-server rediss:// form: redis.tls.clientCertSecret (a kubernetes.io/tls Secret) and redis.tls.caSecret are mounted into the pods at /run/secrets/ridm-redis-tls and /run/secrets/ridm-redis-ca and passed as REDIS_TLS_CERT_FILE, REDIS_TLS_KEY_FILE and REDIS_TLS_CA_FILE. With Valkey’s tls-auth-clients-user CN, the pods log in as the user the certificate names and the URL carries no password (Postgres and Valkey). The files are re-read when they change, so cert-manager’s renewals need no restart.

FIPS and OpenShift

The FIPS 140-3 build is the same chart with three changes, and ci/fips-openshift-values.yaml is a complete example that CI renders and validates:

  • image.variant: fips runs ghcr.io/zerosandonesllc/ridm:<version>-fips. To pin by digest, set image.digest to the FIPS image’s own and keep -fips in image.tag, so the variant stays visible in the release.
  • Client-certificate Postgres for the pods and the Job, and client-certificate Valkey for the pods, as above. The FIPS build wants both: neither the Postgres driver’s password authentication nor Valkey’s password hashing is FIPS-validated code.
  • On OpenShift, podSecurityContext.runAsUser, runAsGroup and fsGroup are set to null, since the restricted-v2 SCC assigns them from the namespace’s range and rejects fixed values. The image already runs as a non-root user and needs no particular UID. route.enabled adds a Route (edge TLS at the router, HTTP redirected) in place of the Ingress; the router forwards X-Forwarded-For, so put its pods’ network in trustedProxies.

A fresh FIPS deployment sets neither FIPS_TRANSITION nor FIPS_ALLOW_NON_FIPS_HOST. The first is for moving a standard deployment’s data across; the second lets the FIPS build start on a host that isn’t in FIPS mode, for development and CI only.

Upgrading

helm upgrade runs the migration Job first; the Deployment rolls only after it succeeds, and a failed migration leaves the running pods untouched. Read the release notes before upgrading across versions; see Upgrading.

Testing the chart

deploy/helm/smoke/run.sh installs the chart into a throwaway kind cluster with Postgres and Valkey beside it, checks readiness, discovery, the key set and the embedded pages through a port-forward, checks that no pod restarted and that the migration Job and its Secret are gone, upgrades (the hook runs again and the pods roll), and uninstalls. It then installs the chart again against a Postgres that accepts only client-certificate authentication over TLS, with database.tls and migrations.database.tls and no database password, checks through pg_stat_ssl that the pods’ sessions are authenticated by the application role’s certificate, uninstalls, and deletes the cluster. RIDM_FIPS_IMAGE names a FIPS image to run that second phase with (started with FIPS_ALLOW_NON_FIPS_HOST, as the host isn’t in FIPS mode). CI’s helm-smoke job runs it on every pull request against the standard image built there and the FIPS image the fips job built:

docker build -f api/Dockerfile -t ridm:smoke .
docker build -f api/Dockerfile.fips -t ridm:fips .      # optional
RIDM_IMAGE=ridm:smoke RIDM_FIPS_IMAGE=ridm:fips deploy/helm/smoke/run.sh

Releases and verification

Every release is built by the release workflow from a v* tag and published in three forms: a container image, a Helm chart and static Linux binaries. Each is signed with Sigstore keyless signing, so you can check that what you run was built by this repository’s release workflow from that tag and not altered since. The release notes on GitHub come from CHANGELOG.md.

The first published release is v0.2.0.

What a release publishes

ArtifactWherePlatforms
Container imageghcr.io/zerosandonesllc/ridm:{version}linux/amd64, linux/arm64
FIPS 140-3 imageghcr.io/zerosandonesllc/ridm:{version}-fips (FIPS 140-3)linux/amd64, linux/arm64
Helm chartoci://ghcr.io/zerosandonesllc/charts/ridm, and ridm-{version}.tgz on the GitHub release
Static binariesridm-{version}-linux-amd64.tar.gz, ridm-{version}-linux-arm64.tar.gz on the GitHub releaselinux x86_64, aarch64
SBOMattached to each image (BuildKit’s attestation, and an SPDX document attested with cosign), and ridm-{version}.spdx.json and ridm-{version}-fips.spdx.json on the GitHub release
ChecksumsSHA256SUMS and its Sigstore bundle SHA256SUMS.sigstore.json on the GitHub release

Image tags

A release 1.2.3 is tagged 1.2.3, 1.2 and latest, and the FIPS image 1.2.3-fips, 1.2-fips and latest-fips. A pre-release such as 0.2.0-rc.1 gets only its own tag and never moves latest or a minor tag. The image is one multi-arch index; Docker, containerd and Kubernetes pick the architecture.

Deploy by digest rather than by tag: tags move, a digest names exactly one image. The release notes give the digest, and so does

docker buildx imagetools inspect ghcr.io/zerosandonesllc/ridm:1.2.3

In the Helm chart, set image.digest to it; see Kubernetes (Helm).

The Helm chart

helm install ridm oci://ghcr.io/zerosandonesllc/charts/ridm --version 1.2.3 -f values.yaml

The chart’s version is the release’s version, and its image tag defaults to the same.

Static binaries

Each tarball holds ridm-api (the server, with the UI compiled in), ridm (the admin CLI), LICENSE, README.md and CHANGELOG.md. Both binaries are statically linked against musl and run on any Linux of their architecture, with no libc or OpenSSL requirement; the server uses mimalloc, since musl’s own allocator scales poorly across threads.

tar -xzf ridm-1.2.3-linux-amd64.tar.gz
sudo install -m 0755 ridm-1.2.3-linux-amd64/ridm-api ridm-1.2.3-linux-amd64/ridm /usr/local/bin/

The server is configured by environment variables exactly as in the image (Server configuration), and runs migrations with ridm-api migrate as the schema owner. Under systemd, a unit along these lines keeps it confined:

[Unit]
Description=rIDM
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/ridm-api
EnvironmentFile=/etc/ridm/ridm.env
# Secrets as files, read through the *_FILE variables:
LoadCredential=master_key:/etc/ridm/master_key
Environment=MASTER_KEY_FILE=%d/master_key
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
CapabilityBoundingSet=
Restart=on-failure
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

Verifying a release

Install cosign 3 or later (the release workflow signs with cosign 3, whose Sigstore bundle format older versions do not read). Every signature was made by the release workflow with its GitHub identity, so verification names that workflow and GitHub’s token issuer:

IDENTITY='^https://github.com/ZerosAndOnesLLC/rIDM/\.github/workflows/release\.yml@refs/tags/v'
ISSUER=https://token.actions.githubusercontent.com

The image:

cosign verify ghcr.io/zerosandonesllc/ridm:1.2.3 \
  --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER"

The FIPS image is signed the same way: verify ghcr.io/zerosandonesllc/ridm:1.2.3-fips with the same command.

Its SBOM (an SPDX document, attested by the same identity):

cosign verify-attestation --type spdxjson ghcr.io/zerosandonesllc/ridm:1.2.3 \
  --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER" \
  | jq -r '.payload' | base64 -d | jq '.predicate.packages | length'

The chart:

cosign verify ghcr.io/zerosandonesllc/charts/ridm:1.2.3 \
  --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER"

The binaries and everything else on the GitHub release: verify the checksum file’s signature, then the files against it:

cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \
  --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER"
sha256sum --check --ignore-missing SHA256SUMS

A check that fails means the artifact was not produced by this repository’s release workflow for a v* tag, or was changed afterwards. Do not run it.

How a release is cut

For maintainers, in CONTRIBUTING.md: bump the version everywhere it lives, move the changelog’s Unreleased notes under the new version, merge, then publish a GitHub release for the tag (gh release create). The workflow refuses a tag that disagrees with any version in the tree or has no changelog section, and runs the heavy test suites (end-to-end, packaging, Helm, FIPS image, conformance, fuzzing) as a gate before anything is built or published. Dispatched from a branch, it rehearses the release from that branch without publishing anything.

TLS and reverse proxies

An identity provider must be reached over https: browsers will not keep rIDM’s __Host- cookies otherwise, and relying parties validate the issuer URL, which carries the scheme. rIDM can terminate TLS itself or sit behind a proxy that does. Either way, four settings have to agree with how clients actually reach it: PUBLIC_URL, UI_URL, TRUSTED_PROXIES and COOKIE_SECURE.

PUBLIC_URL and UI_URL

PUBLIC_URL is the externally visible base URL, for example https://id.example.com. It is required, must be http or https, and rIDM derives everything client-facing from it:

  • every tenant’s issuer is {PUBLIC_URL}/t/{slug} (https://id.example.com/t/acme), and so are the endpoint URLs in its discovery document;
  • Strict-Transport-Security is sent only when it is https;
  • its origin is admitted for cross-origin calls from the consoles.

rIDM does not look at X-Forwarded-Proto or the request’s own scheme. If the proxy terminates TLS and forwards plain http, PUBLIC_URL still says https://, and that is what clients see. A PUBLIC_URL that does not match what clients type produces tokens whose iss no relying party accepts, so set it before registering any client and treat changing it as a migration for every relying party.

UI_URL is where the sign-in pages and consoles are served. It defaults to PUBLIC_URL, which is right when rIDM serves its embedded UI (the default, and the layout the snippets below use) or when the proxy serves ui/out on the same host. Set it only when the UI lives elsewhere; see Deployment overview.

Every node must have the same PUBLIC_URL and UI_URL.

Option 1: TLS at a proxy or load balancer

The usual layout. The proxy holds the certificate, speaks https to clients and plain http to rIDM on a private network. Leave TLS_CERT and TLS_KEY unset.

The proxy must:

  • pass the original Host header (rIDM matches custom domains by it, or by X-Forwarded-Host from a trusted proxy);
  • append the client address to X-Forwarded-For (or send Forwarded: for=...);
  • have its address, as rIDM sees it, in TRUSTED_PROXIES;
  • allow request bodies of 32 MiB on /admin/, which is what bulk user import accepts (nginx’s default is 1 MiB).

Option 2: native TLS

Set both TLS_CERT (a PEM certificate chain) and TLS_KEY (the PEM private key) and the listener on BIND_ADDR speaks https only; there is no plain-http listener beside it and no redirect from http. Setting one without the other is a configuration error. The files are read once at startup, so a renewed certificate takes a restart (a rolling restart across nodes is enough).

Native TLS suits a single node or a layer-4 load balancer that passes TCP through. With no proxy in front, TRUSTED_PROXIES stays empty and the TCP peer is the client. The image’s --healthcheck probe follows the listener: with TLS_CERT set it speaks https and trusts exactly that certificate (see Container image).

Client certificates (mutual TLS)

Clients that authenticate with a TLS certificate (RFC 8705) need whatever terminates TLS to ask for one. Either run rIDM’s own mTLS listener on a second port (MTLS_BIND), or have the proxy request the certificate on a separate host and forward it in a header (CLIENT_CERT_HEADER), which rIDM believes only from TRUSTED_PROXIES. Keep that host apart from the one users sign in on (MTLS_PUBLIC_URL), so browsers never show a certificate picker. nginx, Caddy and Traefik snippets are in Mutual TLS.

TRUSTED_PROXIES and the client address

The client address feeds IP rules, every per-address rate limit, login-attempt records, session metadata and the audit log. rIDM takes it from the TCP peer unless the peer is listed in TRUSTED_PROXIES, a comma-separated list of CIDRs or single addresses:

TRUSTED_PROXIES=10.0.0.0/8,192.168.10.5

When the peer is trusted, rIDM reads X-Forwarded-For (or, if that is absent, Forwarded’s for= values) from the right, skipping every address that is itself in TRUSTED_PROXIES, and takes the first one that is not. Proxies that append (nginx’s $proxy_add_x_forwarded_for, AWS load balancers) leave whatever the caller sent at the left; reading from the right means a caller cannot choose the address rIDM records. An entry that does not parse as an address ends the walk.

Getting it wrong has a direction:

SettingEffect
Empty behind a proxyEvery request appears to come from the proxy. Per-address rate limits then count all users together and IP rules match the proxy
Too wide (for example 0.0.0.0/0)Anyone who can reach rIDM directly can forge their address with a header, defeating IP rules and per-address limits
Exactly the proxy tierCorrect

List every hop between the internet and rIDM that you operate: a CDN in front of a load balancer in front of nginx means all three ranges. X-Forwarded-Host is honoured under the same rule, and only matters for custom domains.

Cookies

rIDM sets two cookies, both host-only (no Domain), Path=/, HttpOnly and SameSite=Lax. Each carries the tenant’s slug in its name, so tenants on one host never share them; for tenant acme:

COOKIE_SECURESession cookieTrusted-device cookieAttributes
true (default)__Host-ridm_session_acme__Host-ridm_device_acmeSecure
falseridm_session_acmeridm_device_acmenone extra

The name, not the path, separates tenants because a __Host- cookie must have Path=/, and because a custom domain serves the tenant without the /t/{slug} prefix. Deployments that ran an earlier build, whose cookies were named without the slug and scoped to /t/{slug}, sign every user out once on upgrade, and remembered devices are forgotten once.

Browsers accept a Secure cookie, and anything named __Host-, only from a secure origin. So with the default COOKIE_SECURE=true, sign-in works only when the browser reaches rIDM over https. COOKIE_SECURE=false is for plain-http local development and nothing else. The compose dev profile sets it; the prod profile does not.

SameSite=Lax also means the cookie is not sent on cross-site fetch calls, which is why the UI belongs on the same origin as the API (or at least the same site).

Proxy configurations

The repository ships configurations for three proxies in deploy/proxy/. They are what the production compose stack runs, and CI boots that stack behind each one and checks it from outside: the http redirect, the https issuer, rIDM’s headers arriving unchanged, /metrics refused, a forged X-Forwarded-For ignored, a 20 MiB bulk-import body arriving whole, and a custom domain served as its tenant.

All three assume the default layout: the UI embedded in the server, so every path goes to the rIDM nodes. They pass Host through, give rIDM the connecting address in X-Forwarded-For, allow the 32 MiB bulk import, refuse /metrics and add no security headers: rIDM sends its own on every response, pages included (framing is refused everywhere except the login page, which only its own origin may frame, for the console’s branding preview), and HSTS when PUBLIC_URL is https. /docs is served only if DOCS_ENABLED is on, which production should not do.

nginx

deploy/proxy/nginx/templates/default.conf.template is a template for the official image, which fills in RIDM_DOMAIN, RIDM_CUSTOM_DOMAINS and RIDM_UPSTREAM at start. Its core, with the values filled in for nodes outside Docker:

upstream ridm {
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
    keepalive 32;
}

server {
    listen 443 ssl;
    http2 on;
    server_name id.example.com;

    ssl_certificate     /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    location / {
        proxy_pass http://ridm;
    }
    # Bulk user import accepts up to 32 MiB; nginx's default is 1 MiB.
    location /admin/ {
        client_max_body_size 32m;
        proxy_pass http://ridm;
    }
    # Scrape /metrics on the private network instead.
    location = /metrics { return 404; }
}

The file also redirects http to https, refuses the TLS handshake for hosts it does not serve, and re-resolves the upstream through Docker’s DNS (resolve, nginx 1.27.3 or later), which outside Docker becomes your own resolver or a fixed list of addresses. nginx obtains no certificates: renew them with your ACME client and reload.

$proxy_add_x_forwarded_for appends to whatever the caller sent, which is safe because rIDM reads the list from the right (see above). Setting it to $remote_addr instead is equally correct at the edge but loses the earlier hops when nginx sits behind another proxy you operate.

Caddy

deploy/proxy/caddy/Caddyfile obtains and renews certificates itself (or reads files with RIDM_TLS_MODE=files). Caddy passes Host through and replaces X-Forwarded-For with the connecting address by default, and has no request-body limit, so the whole configuration is short:

id.example.com login.acme.com {
	respond /metrics 404
	reverse_proxy 10.0.1.11:8080 10.0.1.12:8080 {
		health_uri /readyz
		health_interval 10s
	}
	header -Server
}

Traefik

Traefik takes its static configuration (entry points, the http-to-https redirect, the Let’s Encrypt resolver) from one source only; the compose stack gives it as the traefik service’s command:. The routes are the file provider’s deploy/proxy/traefik/dynamic/ridm.yml, a Go template reading the same RIDM_ variables: one router for every host, a /metrics router behind an ipAllowList that admits nobody, and a load-balanced service with /readyz health checks. Traefik drops a caller’s X-Forwarded-For unless the caller is in the entry point’s forwardedHeaders.trustedIPs (none are configured) and sets it to the connecting address; it has no request-body limit.

With Traefik’s Docker provider instead of a file, the same router is a set of labels on the rIDM service:

labels:
  traefik.enable: "true"
  traefik.http.routers.ridm.rule: Host(`id.example.com`)
  traefik.http.routers.ridm.entrypoints: websecure
  traefik.http.routers.ridm.tls.certresolver: letsencrypt
  traefik.http.services.ridm.loadbalancer.server.port: "8080"
  traefik.http.services.ridm.loadbalancer.healthcheck.path: /readyz

Hosting ui/out yourself

A binary built without the embedded UI serves only the API, and the proxy serves the static export. Route the API paths to rIDM and serve the files, adding the headers a page’s <meta> policy cannot set. The console frames /login/ for its branding preview, so that one page must allow its own origin. This variant is not among the tested configurations; in nginx:

    location ~ ^/(t|admin|scim|\.well-known)/ { proxy_pass http://ridm; ... }
    location ~ ^/(openapi\.json|healthz|readyz)$ { proxy_pass http://ridm; ... }

    root /srv/ridm/ui/out;
    location / {
        try_files $uri $uri/ =404;
        add_header X-Frame-Options "DENY" always;
        add_header Content-Security-Policy "frame-ancestors 'none'" always;
        add_header Strict-Transport-Security "max-age=63072000" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
    location = /login/ {
        try_files /login/index.html =404;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header Content-Security-Policy "frame-ancestors 'self'" always;
        add_header Strict-Transport-Security "max-age=63072000" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
    error_page 404 /404.html;

The repository’s OpenID conformance setup puts Caddy in front of rIDM in this layout, API paths to the server and the rest to ui/out (conformance/Caddyfile).

Custom domains behind the proxy

A tenant with a custom domain (say login.acme.com) is served on that host without the /t/{slug} prefix, so the proxy must send that host to rIDM with Host preserved and hold a certificate for it: in the shipped configurations, add it to RIDM_CUSTOM_DOMAINS. rIDM compares the whole Host value, port included, with the domain, so serve custom domains on port 443. With the embedded UI the tenant’s sign-in pages and account console are served on that host as well, through the same route. See Custom domains.

Key custody: HSM and KMS

By default the master key that encrypts secrets at rest comes from the environment (MASTER_KEY), so anyone who can read the deployment’s configuration can read every signing key, second-factor secret and SMTP password in a database backup. With a key custody backend an HSM or a key-management service holds the master key instead, and neither the configuration nor a backup is enough to decrypt anything without it.

BackendKEY_WRAPPERCargo featureBinds the generationAuthentication
PKCS#11 HSM (Thales Luna, Entrust nShield, YubiHSM 2, AWS CloudHSM, SoftHSM…)pkcs11hsm-pkcs11yes (AES-GCM additional data)the token’s user PIN
AWS KMSaws-kmskms-awsyes (encryption context)the AWS SDK’s chain: IRSA or EKS Pod Identity, ECS task role, instance profile, SSO, environment
HashiCorp Vault or OpenBao, Transit enginevaultkms-vaultby key referencea token, or the Kubernetes auth method with the pod’s service-account token
Google Cloud KMSgcp-kmskms-gcpyes (additional authenticated data)a service-account key, workload identity federation, or the metadata server (GKE Workload Identity, Cloud Run, Compute Engine)
Azure Key Vault or Managed HSMazure-key-vaultkms-azureby key idworkload identity (AKS, or any cluster federated with Entra ID), a client secret, or a managed identity

Each backend is an optional cargo feature, so a plain cargo build stays provider-neutral. The container image is built with all of them; the static (musl) release binaries with all but PKCS#11, because a static binary cannot load the HSM vendor’s library. A build without the backend KEY_WRAPPER names refuses to start and names the feature.

How it works

This is envelope encryption. Every master-key generation is a random 256-bit data key. The backend encrypts (“wraps”) it once, when the generation is created, and only the wrapped form is stored, in the master_key_generations table. Each node asks the backend to unwrap every generation when it starts and keeps the data keys in memory; rows are then encrypted and decrypted locally, exactly as under MASTER_KEY (XChaCha20-Poly1305, or AES-256-GCM under a per-value derived key in the FIPS build; bound to the row, tagged with the generation).

What that means in practice:

  • No request waits on the backend. Signing a token, checking a TOTP code or sending a webhook never calls the HSM or KMS, pays for a KMS request or counts against its rate limit. The backend is asked once per generation per node start.
  • An outage of the backend does not stop running nodes. A node that cannot reach it at start-up refuses to start (it would otherwise run on the wrong key), so scaling out and restarts wait for the backend.
  • The data keys are in the nodes’ memory, as MASTER_KEY always was. Custody protects the key at rest, in backups, in configuration and in CI; it does not make a compromised node’s memory safe.
  • Revoking rIDM’s access to the backend key stops every new node from starting. Deleting the backend key destroys every secret still under a generation it wrapped: treat it like losing the master key.

Generations from the environment and from the backend can coexist: the environment’s MASTER_KEY / MASTER_KEY_PREVIOUS stay readable next to wrapped generations, which is how an existing deployment moves onto a backend without downtime. A generation number is never reused, and a wrapped generation is always numbered above every generation a stored row carries.

Settings

VariableMeaning
KEY_WRAPPERThe backend new generations are wrapped by. Setting it makes MASTER_KEY optional.
KEY_WRAPPER_PREVIOUSFurther backends, comma-separated, that still hold older generations while rows move off them (switching from one backend to another).

PKCS#11

VariableDefaultMeaning
PKCS11_MODULErequiredPath of the vendor’s PKCS#11 library (.so). In the image, mount it and its dependencies into the container.
PKCS11_TOKEN_LABELunsetThe token, by label; else PKCS11_SLOT; else the first slot with a token.
PKCS11_SLOTunsetThe token, by slot id.
PKCS11_PIN / PKCS11_PIN_FILErequiredThe user PIN.
PKCS11_KEY_LABELridm-master-keyThe AES-256 key (CKA_LABEL) that wraps the data keys, with AES-GCM.
PKCS11_GENERATE_KEYfalseCreate that key on the token (sensitive, non-extractable) when it is missing. Leave off in production, where the HSM’s administrators create keys under their own ceremony; handy with SoftHSM.

The HSM must allow AES-GCM with a caller-supplied IV for C_Encrypt/C_Decrypt on that key.

AWS KMS

VariableDefaultMeaning
AWS_KMS_KEY_IDrequiredA symmetric key: id, ARN or alias/.... Each generation records the key’s ARN, so re-pointing an alias later leaves older generations on their key.
AWS_KMS_ENDPOINTunsetAnother endpoint (a VPC endpoint’s DNS name). AWS_ENDPOINT_URL_KMS works too.

Region and credentials come from the SDK’s standard chain (AWS_REGION, IRSA, EKS Pod Identity, the ECS task role, an instance profile, AWS_PROFILE, SSO). The generation is sent as the encryption context ridm:master-key = ridm:master-key:v<N>, so a key policy can require it and CloudTrail shows which generation a node unwrapped. The least the role needs:

{
  "Effect": "Allow",
  "Action": ["kms:Encrypt", "kms:Decrypt"],
  "Resource": "arn:aws:kms:us-east-1:123456789012:key/…",
  "Condition": { "StringLike": { "kms:EncryptionContext:ridm:master-key": "ridm:master-key:v*" } }
}

KMS automatic key rotation is transparent: older generations keep decrypting.

Vault and OpenBao (Transit)

VariableDefaultMeaning
VAULT_ADDRrequiredThe server, e.g. https://vault.example.com:8200.
VAULT_TRANSIT_KEYrequiredThe Transit key’s name.
VAULT_TRANSIT_MOUNTtransitWhere the Transit engine is mounted.
VAULT_TOKEN / VAULT_TOKEN_FILEA token, unless VAULT_KUBERNETES_ROLE is set.
VAULT_KUBERNETES_ROLEunsetLog in through the Kubernetes auth method as this role, with the pod’s service-account token. Works the same on OpenShift.
VAULT_KUBERNETES_MOUNTkubernetesWhere that auth method is mounted.
VAULT_KUBERNETES_TOKEN_FILE/var/run/secrets/kubernetes.io/serviceaccount/tokenThe service-account token to present (a projected token with Vault’s audience, if you use one).
VAULT_NAMESPACEunsetVault Enterprise / HCP namespace.
VAULT_CACERTunsetPEM file of the CA(s) to trust for VAULT_ADDR instead of the system’s.

The policy rIDM needs:

path "transit/encrypt/ridm" { capabilities = ["update"] }
path "transit/decrypt/ridm" { capabilities = ["update"] }

Rotating the Transit key (vault write -f transit/keys/ridm/rotate) is transparent; keep min_decryption_version at or below the oldest version a generation used.

Google Cloud KMS

VariableDefaultMeaning
GCP_KMS_KEYrequiredprojects/P/locations/L/keyRings/R/cryptoKeys/K, a symmetric encrypt/decrypt key.
GOOGLE_APPLICATION_CREDENTIALSunsetA service-account key file, or a workload identity federation file (external_account with a credential_source.file, such as a Kubernetes projected token, optionally impersonating a service account). Unset: the metadata server.
GCE_METADATA_HOSTmetadata.google.internalThe metadata server’s host.
GCP_KMS_ENDPOINThttps://cloudkms.googleapis.comAnother endpoint (Private Service Connect).

The service account needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key. Key rotation in Cloud KMS is transparent.

Azure Key Vault and Managed HSM

VariableDefaultMeaning
AZURE_KEY_VAULT_URLrequiredhttps://<name>.vault.azure.net or https://<name>.managedhsm.azure.net.
AZURE_KEY_VAULT_KEYrequiredThe key’s name.
AZURE_KEY_VAULT_KEY_VERSIONthe current versionPin a version. Each generation records the version it used either way.
AZURE_KEY_VAULT_ALGORITHMRSA-OAEP-256RSA-OAEP-256 or RSA-OAEP for an RSA key; A256KW, A192KW or A128KW for an AES key in a Managed HSM.
AZURE_TENANT_ID, AZURE_CLIENT_IDunsetThe application (or a user-assigned managed identity’s client id).
AZURE_FEDERATED_TOKEN_FILEunsetWorkload identity: the federated token the AKS webhook (or your own projection) provides, re-read on every call.
AZURE_CLIENT_SECRET / AZURE_CLIENT_SECRET_FILEunsetA client secret, when there is no federated token.
AZURE_AUTHORITY_HOSThttps://login.microsoftonline.comEntra ID’s host (sovereign clouds).
IDENTITY_ENDPOINT, IDENTITY_HEADERunsetApp Service / Container Apps managed identity.

Without tenant and client ids and a federated token or secret, rIDM asks the managed identity endpoint, then the instance metadata service. The identity needs the wrapKey and unwrapKey key permissions (the Key Vault Crypto User role). Creating a new key version is transparent as long as the older versions stay enabled.

Starting a new deployment on a backend

Set KEY_WRAPPER and the backend’s settings, and leave MASTER_KEY unset. The first node to start creates generation 1: it has the backend wrap a random data key, checks that the backend gives the same key back, and stores the wrapped form. Every later node unwraps it. The global audit chain records master_key.generation_created.

Moving an existing deployment onto a backend

  1. Add the backend to every node, keeping MASTER_KEY (and MASTER_KEY_VERSION, MASTER_KEY_PREVIOUS) as they are, and restart. The first node creates the first wrapped generation, numbered after the environment’s; new secrets are written under it and the old ones still decrypt.
  2. Re-encrypt what is still under the environment’s generations: ridm master-key rotate, ridm-api rotate-master-key, or the console’s Re-encrypt pending rows (see Rotating keys).
  3. Check with ridm master-key status that no row is left under the environment’s generations.
  4. Remove MASTER_KEY (and MASTER_KEY_VERSION, MASTER_KEY_PREVIOUS) from every node and restart. Destroy the old key once you no longer need to read backups taken before step 2.

Going back works the same way the other way round: MASTER_KEY current, KEY_WRAPPER unset, the backend in KEY_WRAPPER_PREVIOUS until the rotation is done. Moving from one backend to another likewise: the new one as KEY_WRAPPER, the old one in KEY_WRAPPER_PREVIOUS.

New generations

A new data key is a new generation:

ridm master-key new-generation     # over the admin API
ridm-api rotate-master-key --new-generation   # with the server's configuration; also re-encrypts

or New generation on the console’s Signing keys page, or POST /admin/master-key/generations (a global administrator with ridm:keys:write). The node that creates it starts encrypting under it at once; the other nodes adopt it within a minute (they look for new generations every minute, and load one on first sight of a row under it). Then re-encrypt as above. Rotating the backend’s own key (KMS automatic rotation, a new Transit or Key Vault key version) needs nothing from rIDM.

With the master key in the environment there is no such command: a generation is rolled out by changing MASTER_KEY and MASTER_KEY_VERSION, and the endpoint answers 409.

Status

ridm master-key status, ridm-api rotate-master-key --status, GET /admin/master-key and the console show the backend, every generation (from the environment or which backend and key wrapped it, and whether this node could unwrap it) and how many rows each table has under each generation.

At start-up a node logs each generation it cannot unwrap. That is fatal only for the backend’s current generation, or when no signing key decrypts (the start-up check); rows under an unreadable older generation fail to decrypt until its backend is configured again.

Backups

A database backup holds only wrapped data keys. To restore one you need the backend key that wrapped them (and, for rows still under the environment’s generations, those master keys). Do not schedule deletion of a KMS key, destroy a Transit key or purge a Key Vault key while master_key_generations or any backup you may restore still names it.

Kubernetes and OpenShift

The Helm chart takes the backend under keyCustody: wrapper, previous, the non-secret settings in env, the secret ones (VAULT_TOKEN, PKCS11_PIN, AZURE_CLIENT_SECRET) from a Secret of yours named in existingSecret, and mountServiceAccountToken: true for Vault’s Kubernetes auth method. Workload identity needs no secret at all: annotate the service account (serviceAccount.annotations, e.g. eks.amazonaws.com/role-arn, iam.gke.io/gcp-service-account, azure.workload.identity/client-id) and add the pod label azure.workload.identity/use: "true" for Azure. With a backend, masterKey may be left empty. The migration Job never receives the master key or the backend’s settings: migrations do not read secrets.

Data residency

A customer under GDPR, a public-sector contract or a sector regulator may need its users’ data stored in one jurisdiction. rIDM can place each tenant’s data in a regional database: one deployment serves every tenant, and a tenant created in eu keeps its users, credentials, sessions, clients, keys and audit log in the eu database (and, optionally, the eu Valkey), never in the others.

                      ┌──────────────────────────────┐
  every node ────────►│ home  DATABASE_URL           │  tenant registry, master tenant,
                      │                              │  master-key generations, and the
                      │                              │  tenants without a region
                      └──────────────────────────────┘
             ────────►┌──────────────────────────────┐
                      │ eu    DATABASE_URL_EU        │  everything of the tenants in eu
                      │       REDIS_URL_EU (optional)│  (their sessions and cached rows)
                      └──────────────────────────────┘
             ────────►┌──────────────────────────────┐
                      │ us    DATABASE_URL_US        │  everything of the tenants in us
                      └──────────────────────────────┘

Configure regions

Every node lists the same regions and can reach every regional database:

DATA_REGIONS=eu,us
DATABASE_URL_EU=postgres://ridm_app:…@pg.eu.internal:5432/ridm
DATABASE_READ_URL_EU=postgres://ridm_app:…@pg-replica.eu.internal:5432/ridm   # optional
REDIS_URL_EU=rediss://valkey.eu.internal:6379                                  # optional
DATABASE_URL_US=postgres://ridm_app:…@pg.us.internal:5432/ridm

A region’s name is 1-32 lowercase letters, digits and hyphens starting with a letter; the variables use it in upper case with - as _ (eu-west → DATABASE_URL_EU_WEST). Each also has a _FILE form for secret mounts. See Server configuration.

A regional database runs the same schema as the home one. ridm-api migrate migrates every configured database (home first), and MIGRATE_ON_START does the same, so a region is set up like the home database: an empty database, the migrator role owning the schema and the DML-only application role (see Postgres and Valkey).

Kubernetes

The Helm chart takes the region names and two Secrets you manage: one with the application role’s DATABASE_URL_<NAME> (and any DATABASE_READ_URL_<NAME>, REDIS_URL_<NAME>) for the pods, one with the schema owner’s DATABASE_URL_<NAME> for the migration Job, which then migrates every region before each upgrade:

dataRegions:
  names: [eu, us]
  existingSecret: ridm-regions              # DATABASE_URL_EU, DATABASE_URL_US, REDIS_URL_EU…
  migrationsExistingSecret: ridm-regions-owner

Place a tenant

A tenant’s region is chosen when it is created, in the console (Tenants → New tenant → Data region, shown when the deployment has regions) or through the admin API:

curl -X POST "$RIDM/admin/tenants" -H "Authorization: Bearer $TOKEN" \
  -d '{"slug": "acme-eu", "display_name": "Acme EU", "data_region": "eu"}'

GET /admin/regions lists the regions, how many tenants each holds, and whether it has a Valkey of its own. An unknown region is refused. A tenant’s data_region (null: home) is part of its admin API representation; it cannot be changed with a PATCH, only by moving the tenant.

What lives where

DataWhere
Every tenant-scoped row (users, credentials, sessions, refresh tokens, consents, clients, roles, groups, organizations, identity providers, signing and SAML keys, webhooks and deliveries, outbound messages, login history, the audit chain and its checkpoints)the tenant’s database
Sessions, sign-in flows, codes, per-tenant rate-limit counters, cached rows, the claims of opaque access tokensthe region’s Valkey when it has one (REDIS_URL_<NAME>), else the shared Valkey
The tenant registry (tenants: slug, display name, settings, data_region)the home database; each regional database also holds a copy of its tenants’ rows, for its foreign keys
The master tenant and global administrators, the global audit chain, master-key generationsthe home database
Leader locks, cache-invalidation messages, the deployment-wide per-address rate limit, the mapping from an opaque token’s hash to its tenantthe shared Valkey

The routing happens below every query: a tenant’s transaction opens on the database the registry names, and a Valkey key of a tenant (ridm:t:{tenant}:…) goes to its region’s Valkey. Background jobs (cleanup, webhook and message delivery, the audit sink and verification, LDAP sync, SAML metadata refresh) and master-key rotation run over every database.

Processing is not pinned. Any node may serve any tenant, reading and writing the tenant’s regional database over the network. Data at rest stays in the region; requests are processed wherever the node that answers them runs. When the processing location matters too, run nodes in the region and route the tenant’s traffic to them (its custom domain pointed at a regional load balancer); every node still needs every database.

Move a tenant

ridm-api move-tenant acme --region eu          # home → eu
ridm-api move-tenant acme --region us          # eu → us
ridm-api move-tenant acme --region home        # back to the home database

The move is offline: the tenant answers 503 while it runs, and everything else keeps working. It:

  1. marks the tenant as moving in the registry and waits (--drain-seconds, 20 by default and at least 16: nodes trust a placement they read for 15 seconds) until no node still serves it from the old database;
  2. copies every tenant-scoped table in foreign-key order, inside one transaction on the target, from one consistent snapshot of the source, and checks the row count of every table;
  3. re-verifies the copied audit chain;
  4. copies the tenant’s Valkey keys with their remaining lifetimes, when the two sides use different Valkeys (sessions survive the move);
  5. points the registry at the target, which ends the outage;
  6. deletes the source’s copy, rows and keys, and records tenant.moved in the tenant’s audit log.

It prints a JSON report (rows per table, keys copied, audit rows verified). A failure before step 5 deletes whatever reached the target and leaves the tenant where it was. A failure after it leaves a stale copy in the source; running the same command again finishes the job, and running it for a tenant already in the named region removes stale copies from every other database. One move per tenant runs at a time.

Run it as a one-off job with the deployment’s configuration (the application role is enough), like ridm-api rotate-master-key. Both databases must be on the same schema: run ridm-api migrate first after an upgrade. The move takes about as long as copying the tenant’s rows; a tenant with millions of users is unavailable for minutes, so plan a window. Audit events of the tenant raised during the move are recorded once it ends.

The master tenant cannot move.

Operating regions

  • Backups: every regional database is a database of record. Back each one up, on the same schedule as the home database, and restore them together; see Backup and restore.
  • Readiness: /readyz reports each region under checks.regions but does not fail on one: a region’s outage is an outage of its tenants (their requests fail), and taking every node out of the load balancer for it would take all tenants down. Alert on checks.regions.* instead.
  • A region down at start-up: nodes still start (a region’s connections open lazily, and the migration, master-key and Valkey checks log the region and go on); the tenants of that region fail until it is back.
  • Upgrades: ridm-api migrate migrates every database and fails if one cannot be reached; run it again once the region is back. A node starting with MIGRATE_ON_START while a region is down migrates the others and logs the one it skipped.
  • Removing a region: move its tenants elsewhere first. A tenant whose region is not configured on a node answers 503 there.
  • Connections: each node opens DB_POOL_MAX connections to each database; size the regional servers for the number of nodes.
  • Writing migrations: a migration that seeds something for every tenant must skip the home database’s registry-only rows (… FROM tenants WHERE NOT registry_only), or it would write a regional tenant’s rows at home. A test enforces it.

FIPS 140-3

Available since 0.3.0 as ghcr.io/zerosandonesllc/ridm:<version>-fips, next to the standard image. The work was tracked in #7.

FIPS 140-3 is the US and Canadian government standard for cryptographic modules. Some organizations must run software whose cryptography comes only from a module that NIST has validated. Typical examples are US federal agencies, their contractors (FedRAMP, CMMC) and regulated industries. These organizations often run FIPS-mode clusters, such as OpenShift on RHEL CoreOS installed with fips: true.

Most deployments don’t need any of this. The standard build and image are the default and are unaffected. Nothing on this page changes how they behave or how you configure them. You only need to read further if your organization requires FIPS.

Two builds, one codebase

rIDM has a single build switch, the fips cargo feature, which is off by default. Every feature works in both builds:

Standard build (default)FIPS build
How to get itcargo build, or the ghcr.io/zerosandonesllc/ridm:<version> image--features fips, or the ghcr.io/zerosandonesllc/ridm:<version>-fips image
Cryptographic moduleaws-lc-rs (AWS-LC)aws-lc-rs on the AWS-LC FIPS module, for everything: neither build links OpenSSL
New password hashesargon2idPBKDF2-HMAC-SHA512
Secrets at restXChaCha20-Poly1305, AES-256-GCM from the next release (reads both)AES-256-GCM
TLSTLS 1.2 and 1.3 with every rustls suite and group, including ChaCha20 and X25519TLS 1.2 and 1.3 with AES-GCM suites only, and the P-256, P-384 and X25519MLKEM768 groups
Starts on a host without FIPS modeyesno, unless FIPS_ALLOW_NON_FIPS_HOST=true (development and CI only)
Platformsimage (amd64, arm64) and static musl binariesimage only (see What isn’t covered)

The two builds use the same database schema, the same tenant documents and the same configuration. You can move a deployment from one build to the other in either direction (see Moving an existing deployment).

Wherever the FIPS-approved way of doing something is just as good for everyone, both builds share it, so there is only one path to test. This applies to hashing, HMAC, key generation, random numbers, TOTP and Kerberos crypto, all of which go through aws-lc-rs in both builds. Both builds also read both ciphers for secrets at rest; the standard build keeps writing XChaCha20-Poly1305 for one more release, so a rolling update or a rollback to the release before never meets a value it can’t read. The builds only differ where the standard build has a better choice for the average deployment, such as argon2id for passwords.

Building the FIPS image

Each release publishes the FIPS image as ghcr.io/zerosandonesllc/ridm:<version>-fips (also <major>.<minor>-fips and latest-fips), for linux/amd64 and linux/arm64. It’s signed and carries an SBOM like the standard image (see Releases and verification). Pin it by digest.

To build it yourself, use api/Dockerfile.fips from the repository root:

docker build -f api/Dockerfile.fips -t ridm:fips .

It’s built on Red Hat Universal Base Image 9 (ubi9/ubi for the build, ubi9/ubi-minimal at run time). Every package it installs comes from the freely available UBI repositories, so no RHEL subscription is needed to build it. It builds the same way on an entitled OpenShift cluster, a RHEL host or a laptop. The build stage:

  • installs golang, cmake, clang and perl. The AWS-LC FIPS module is compiled from source by aws-lc-fips-sys, and its build needs Go;
  • builds with --no-default-features --features fips,embedded-ui,kerberos,hsm-pkcs11,kms-aws,kms-vault,kms-gcp,kms-azure. Every feature of the standard image is included;
  • fails if the binary links anything but the FIPS module’s AWS-LC, links ring, or links any OpenSSL (scripts/fips/check-binary.sh).

The runtime image runs as the same non-root user (65532) as the standard one, with the same healthcheck and environment. Outside a container, the build command is:

cargo build --release --locked -p ridm-api \
  --no-default-features \
  --features fips,embedded-ui,kerberos,hsm-pkcs11,kms-aws,kms-vault,kms-gcp,kms-azure

Running it

  • The host must be in FIPS mode. For OpenShift, that means installing the cluster with fips: true. For RHEL, run fips-mode-setup --enable and reboot.

  • At start-up the server runs the FIPS module’s self-test, checks that the host is in FIPS mode (/proc/sys/crypto/fips_enabled is 1), and logs the module version:

    INFO FIPS build: the AWS-LC FIPS module passed its self-test module_version="4.2.0" fips_version=Some(4) host_fips=true
    

    Every TLS configuration it builds (the HTTPS and mutual-TLS listeners, LDAP, the audit syslog sink, the healthcheck) must pass rustls’ FIPS check. If any check fails, the server refuses to start and says which. The ridm command line and every ridm-api subcommand, the container healthcheck included, run the same checks.

  • Developing or running CI on a machine that isn’t in FIPS mode: set FIPS_ALLOW_NON_FIPS_HOST=true. The module self-test still has to pass, and the server prints a warning at start-up and logs it again. Never set this in production. A host that isn’t in FIPS mode isn’t a FIPS-validated configuration.

  • Postgres: connect with client-certificate authentication over TLS (sslmode=verify-full with sslcert/sslkey) rather than a password. The Postgres driver sqlx still implements password authentication (SCRAM) with non-validated code. That’s tracked upstream in transact-rs/sqlx#4416. On Kubernetes the chart’s database.tls and migrations.database.tls mount the certificates and build the URLs (Kubernetes).

  • Valkey: likewise, authenticate by client certificate rather than a password: rediss:// with REDIS_TLS_CA_FILE, REDIS_TLS_CERT_FILE and REDIS_TLS_KEY_FILE, and Valkey’s tls-auth-clients-user CN mapping the certificate onto a user with no password, so Valkey’s own password hashing never runs (Postgres and Valkey). SMTP, LDAP, webhooks and the KMS backends all use the FIPS TLS provider.

Settings only the FIPS build reads

VariableDefaultMeaning
PBKDF2_ITERATIONS210000PBKDF2-HMAC-SHA512 iterations for new password hashes, 210,000 to 1,000,000. Raising it re-hashes each user at their next sign-in.
FIPS_TRANSITIONfalseAllows the one-time legacy operations described below. Turn it off when the move is done.
FIPS_ALLOW_NON_FIPS_HOSTfalseLets the FIPS build start on a host that isn’t in FIPS mode, with a warning. For development and CI only.

The standard build ignores these variables, and its ARGON2_* settings are unchanged. A fresh FIPS deployment sets neither FIPS_TRANSITION nor FIPS_ALLOW_NON_FIPS_HOST: the first exists only for the move described below, the second only for development and CI. The Helm chart’s FIPS example (Kubernetes) sets neither.

Moving an existing deployment

Some data that a standard deployment already holds can only be read with algorithms that FIPS doesn’t approve:

  • password hashes made with argon2id, and imported bcrypt, MD5 or salted-digest hashes;
  • secrets at rest written before the AES-256-GCM change, which use XChaCha20-Poly1305.

No library can make reading this data FIPS-approved, but rIDM doesn’t make you throw it away either. Turning on FIPS_TRANSITION=true lets the FIPS build do those reads during a migration window. Every one of them is logged as a warning and counted in the ridm_fips_non_approved_total metric (labelled with the operation), so you can watch it drop to zero. With the flag off, which is the default, the FIPS build refuses them and says why: a FIPS server that finds XChaCha20 secrets in its database refuses to start, and names the steps below.

  1. Upgrade to a release that includes the FIPS work, still on the standard build. It reads AES-256-GCM as well as XChaCha20-Poly1305, so you can go back to it later.
  2. Switch to the -fips image with FIPS_TRANSITION=true. It writes new secrets with AES-256-GCM.
  3. Re-encrypt secrets at rest with ridm-api rotate-master-key (or ridm master-key rotate) on the FIPS build. It rewrites every XChaCha20 row onto AES-256-GCM, including rows already under the current master-key generation, so you don’t need a new master key. rotate-master-key --status shows what’s left in legacy_cipher_rows.
  4. Let passwords move over. When a user with an older hash signs in, rIDM checks the password once with the old algorithm and stores a PBKDF2-HMAC-SHA512 hash. PBKDF2 hashes from elsewhere (SHA-256 or SHA-512, passlib or Django form) are verified the approved way and need no transition, though they’re re-hashed too. To see who hasn’t moved yet, list a tenant’s users by hash format through the admin API, GET /admin/tenants/{slug}/users?password_algo=argon2id (also bcrypt, md5, …); a user’s detail shows its format as well. Reset those users’ passwords whenever you decide.
  5. Turn FIPS_TRANSITION off. From then on, the deployment runs only approved operations.

To import users with legacy hashes later, for example from another identity provider, turn the flag on for the import and the sign-ins that follow, then turn it off again.

Going back to the standard build needs no preparation. The standard build reads PBKDF2 hashes and AES-256-GCM rows, and it re-hashes passwords to argon2id as users sign in.

Feature by feature

FeatureIn the FIPS buildIssue
TLS (listener, mutual TLS, outbound connections)Uses rustls’ FIPS provider: AES-GCM suites only, with the P-256, P-384 and X25519MLKEM768 key exchange groups. X25519MLKEM768 is a post-quantum hybrid. rustls allows it in FIPS mode under NIST SP 800-56C rev 2, which permits a hybrid shared secret when one part (here ML-KEM, FIPS 203) is approved. Each configuration rIDM builds is checked with ServerConfig::fips() / ClientConfig::fips().#1
Token signing (RS256/384/512, ES256)Runs on aws-lc-rs, as it already does in both builds.#5
EdDSA signing keysAvailable in both builds. Whether Ed25519 is inside the AWS-LC FIPS module boundary is being confirmed against the module’s security policy. If it isn’t, the FIPS build marks EdDSA as non-approved, and admins can turn it off per tenant.#5
Key generation, hashing, HMAC, random numbersUses aws-lc-rs in both builds. Random values come from the module’s SP 800-90A DRBG.#5
TOTP (authenticator apps)RFC 6238 on aws-lc-rs HMAC in both builds. Existing enrolments keep working.#5
Secrets at restAES-256-GCM under a key derived per value (HKDF-SHA-256 with a random salt), with the IV generated inside the module. The standard build reads it too, and writes it from the next release.#3
PasswordsPBKDF2-HMAC-SHA512 (SP 800-132) with a 128-bit salt. Other formats move over at sign-in; only PBKDF2 is verified outside FIPS_TRANSITION. Bulk imports of other formats need FIPS_TRANSITION too.#4
Passkeys (WebAuthn)rIDM’s own relying party on aws-lc-rs in both builds: ES256 and RS256 signatures (what registration offers), challenges from the module’s DRBG. It replaced webauthn-rs, which needed OpenSSL, and stores passkeys in the same format, so existing passkeys keep working. RHEL’s OpenSSL FIPS provider couldn’t have done it alone: it has no Ed25519.#2
Kerberos desktop sign-inThe AES encryption types on aws-lc-rs in both builds: RFC 3962’s (17/18) and RFC 8009’s (19/20). The RFC 8009 types use only approved algorithms: AES, HMAC-SHA-256/384 and the SP 800-108 key derivation. RFC 3962’s key derivation (RFC 3961 “DK”) is built from AES, but it isn’t an SP 800-108 KDF, so prefer RFC 8009 keys where the KDC has them. Active Directory issues only RFC 3962 types, and RHEL IdM in FIPS mode issues the RFC 8009 ones.#6
AWS KMSUses the AWS SDK, as in the standard build. Its TLS goes through the FIPS provider. Its request signing is the SDK’s own code (see What isn’t covered).#1
Vault, Google Cloud KMS, Azure Key VaultREST over the FIPS TLS provider. The key itself is protected by the service’s own validation.—
PKCS#11 HSMThe HSM performs the key operations, so its own FIPS validation is the one that counts.—
SAML and OIDCSHA-1 signatures are already refused. RSA-OAEP with SHA-1 (JWE RSA-OAEP, SAML rsa-oaep-mgf1p) stays available for compatibility with existing integrations. NIST plans to retire SHA-1, so the FIPS build may make it opt-in.#7

What isn’t covered

  • The static musl release binaries. These are standard builds only. The AWS-LC FIPS module is validated on specific operating environments, and a static musl binary isn’t one of them.
  • The Postgres driver’s own code for password authentication, migration checksums and advisory-lock hashing, which still uses non-validated hashing. rIDM’s own advisory locks are hashed by the Postgres server. Using client-certificate authentication avoids the password path. The rest is tracked upstream in transact-rs/sqlx#4416, #4418 and #4421.
  • The AWS SDK’s request signing (SigV4, for KEY_WRAPPER=aws-kms), which uses its own, non-validated HMAC. The AWS SDK tracks this upstream. If you need every piece validated, the other key custody backends don’t have this gap.
  • The Postgres driver’s TLS settings. sqlx builds its own TLS configuration, which offers the non-approved ChaCha20 and X25519 next to the approved suites. A Postgres server in FIPS mode only accepts approved ones, so the connection still uses an approved suite.
  • Everything outside the rIDM process: your load balancer or ingress, Postgres, Valkey and the users’ browsers. Each needs its own FIPS configuration.

FIPS validation belongs to the cryptographic module (AWS-LC), not to rIDM. The FIPS build makes sure every security function runs inside it, apart from the gaps listed above. Whether a particular deployment counts as compliant is up to your assessor.

Checking a build

# The binary links only the FIPS module's AWS-LC:
nm target/release/ridm-api | grep -oE 'aws_lc_(fips_)?[0-9]+_[0-9]+_[0-9]+' | sort -u

# ChaCha20 and plain X25519 are refused:
openssl s_client -connect ridm.example.com:443 -tls1_3 -groups X25519   # must fail
openssl s_client -connect ridm.example.com:443 -tls1_2 -cipher ECDHE-RSA-CHACHA20-POLY1305   # must fail

The nm line should print only aws_lc_fips_.... cargo tree still lists aws-lc-sys, because rustls’ fips feature compiles it alongside the FIPS module, but nothing in the build uses it and the linker drops it. scripts/fips/check-binary.sh runs these checks (and that no OpenSSL is linked) on a binary, and scripts/fips/smoke.sh <image> boots an image and probes its TLS. CI runs both on every pull request, along with a cargo deny ban list for the FIPS build (deny-fips.toml).

Postgres and Valkey

rIDM keeps its system of record in Postgres and its short-lived shared state in Valkey. Both are required; a node that cannot reach either fails /readyz and most requests. Neither should be reachable from the internet: rIDM authenticates to them, but cannot protect them from anyone else who can connect.

Versions

ServiceSupportedTested
Postgres16 or later18.6 (CI, compose)
ValkeyValkey 9; Redis-compatible servers with GETDEL and EXPIRE … NX/GT (Redis 7.0+) should work but are not tested9.1.2 (CI, compose)

The migrations create three extensions: pgcrypto, and pg_trgm with btree_gin (the indexes behind the organization, client and tenant searches). All three are trusted extensions, so the migrator role can create them without being a superuser, but a managed Postgres service has to offer them.

Migrations run while the previous release keeps serving. An index on an existing table is built with CREATE INDEX CONCURRENTLY, which does not block writes; should one of those migrations fail part way (a lost connection, a deadlock with a long transaction), Postgres leaves an INVALID index behind that the retry skips. Drop it (DROP INDEX CONCURRENTLY <name>, the name is in the failed migration) and run ridm-api migrate again.

Two roles and row level security

rIDM is built to connect as two different Postgres roles:

RoleDefault name (compose)PrivilegesUsed by
migratorridm_migratorOwns the schema: CONNECT, CREATE, TEMP on the database, ALL on schema publicridm-api migrate
appridm_appCONNECT, TEMP; USAGE on the schema; SELECT, INSERT, UPDATE, DELETE on tables, USAGE, SELECT on sequences, EXECUTE on functionsthe running servers and ridm-api bootstrap (DATABASE_URL)

Neither is a superuser and both are NOBYPASSRLS. The compose file creates them with deploy/postgres/init-app-role.sh. On a database you run yourself, do the same as a superuser before the first migration:

CREATE ROLE ridm_migrator LOGIN PASSWORD '...'
    NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
CREATE ROLE ridm_app LOGIN PASSWORD '...'
    NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;

GRANT CONNECT, CREATE, TEMP ON DATABASE ridm TO ridm_migrator;
GRANT ALL ON SCHEMA public TO ridm_migrator;

GRANT CONNECT, TEMP ON DATABASE ridm TO ridm_app;
GRANT USAGE ON SCHEMA public TO ridm_app;
ALTER DEFAULT PRIVILEGES FOR ROLE ridm_migrator IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ridm_app;
ALTER DEFAULT PRIVILEGES FOR ROLE ridm_migrator IN SCHEMA public
    GRANT USAGE, SELECT ON SEQUENCES TO ridm_app;
ALTER DEFAULT PRIVILEGES FOR ROLE ridm_migrator IN SCHEMA public
    GRANT EXECUTE ON FUNCTIONS TO ridm_app;

The default privileges are what give the app role access to tables the migrator creates later, so every migration must run as the migrator. A migration run as some other role (a superuser, say) creates tables the app role cannot see.

What row level security does here

Every tenant-scoped table has row level security enabled and forced (so it applies to the table owner too), with one policy: a row is visible when its tenant_id equals the transaction’s app.tenant_id setting, or when the transaction has set app.bypass_rls to on. The server binds the tenant at the start of each transaction with set_config(..., true), which resets at commit or rollback, so a pooled connection never carries one request’s tenant into the next. A transaction that binds nothing sees no tenant rows at all. Explicitly cross-tenant work (bootstrap, global administration, background jobs) sets the bypass flag for its own transaction.

That makes row level security a second line behind the application’s own tenant checks: a query that forgets its tenant filter returns nothing from other tenants. It is not a boundary against code running as the app role, which can set the bypass flag itself. The role split is about DDL: a superuser ignores row level security outright, and a table owner can switch it off or rewrite the policies, so the running server holds neither power.

MIGRATE_ON_START=true applies migrations only when some are pending. With the server running as the app role, that is harmless once ridm-api migrate has brought the schema up to date; if a migration is pending, startup fails because the app role cannot apply it. Running the server as the migrator instead, so that it can migrate itself, gives up the split. See Container image.

Audit partitions under the two-role setup

The audit log is partitioned by month. Migrations create the current month’s partition and the next two; after that the daily audit_retention job creates upcoming partitions by calling audit_ensure_partitions, as the server’s role. The DML-only app role has no CREATE privilege, so the function is SECURITY DEFINER: it runs with the rights of its owner (the migrator), with a pinned search_path. EXECUTE on it is revoked from PUBLIC and granted, when the migration runs, to every role that holds INSERT on audit_events, which under the setup above is the app role.

An app role created after that migration ran does not get the grant automatically. Give it by hand, as the migrator:

GRANT EXECUTE ON FUNCTION audit_ensure_partitions(integer) TO ridm_app;

If partition creation fails anyway, the job logs the error and still runs its retention purge; rows written meanwhile land in the audit_events_default partition, where the purge reaches them. A month whose range already has rows in the default partition is skipped rather than failing the whole call, because Postgres refuses to create such a partition. Failures show in ridm_job_runs_total{job="audit_retention",outcome="error"} only when the purge itself fails, so watch the server log for audit: creating partitions failed (see Observability).

Migrations

Migrations live in api/migrations, are embedded in the binary, and are recorded in sqlx’s _sqlx_migrations table. They are forward-only. ridm-api migrate applies whatever is pending, under an advisory lock so concurrent runs are safe, and exits; sqlx migrate run --source api/migrations from a checkout does the same. How to run them in each environment is in Container image. A release’s migrations keep the release before it working, so upgrades can roll; see Upgrading, and take a backup before applying a new release’s migrations.

Connection pools

Each node opens one pool to DATABASE_URL and, when DATABASE_READ_URL is set, a second pool of the same size to the replica.

SettingDefaultMeaning
DB_POOL_MIN2Connections kept open per pool
DB_POOL_MAX20Upper bound per pool; must be at least DB_POOL_MIN
REDIS_POOL_MAX32Valkey connections per node

A request waits at most DB_ACQUIRE_TIMEOUT_MS (2 seconds by default) for a Postgres connection and then fails with 503. Fixed behaviour: idle connections close after 10 minutes, every connection is recycled after 30 minutes, and statements slower than 250 ms are logged as warnings. Connections identify themselves as application_name ridm-api (primary) and ridm-api-read (replica), which is how to find them in pg_stat_activity. Valkey connections time out after 5 seconds waiting, creating or recycling.

Sizing: the connections rIDM can open are nodes × DB_POOL_MAX against the primary, plus the same against the replica if there is one, plus a migrate job. Keep that under the server’s max_connections (the compose file sets 200) with room for maintenance. The README’s rule of thumb for DB_POOL_MAX is roughly twice the database server’s CPU count divided by the number of API nodes.

A connection pooler such as PgBouncer is not tested. rIDM’s tenant binding is transaction-local, which suits transaction pooling in principle, but sqlx uses prepared statements, so a pooler in transaction mode must support them.

Read replicas

DATABASE_READ_URL names a streaming replica. When set, listings and statistics run there inside read-only transactions: users, clients, groups, roles, invitations, webhook deliveries, the audit log and the console overview. A write sent there by mistake fails rather than succeeding somewhere unexpected. Everything else, and every read that feeds a decision (sign-in, token issuance, permission checks), stays on the primary. A listing may trail a change by the replica’s lag. Use the same app role on the replica; unset, the primary serves both.

Valkey

Topologies

REDIS_URL selects the topology by its scheme:

FormTopology
redis://host:6379 or redis://host:6379/2One server (the path picks the database number)
rediss://host:6380One server over TLS
redis+cluster://host1:7000,host2:7001A cluster; the listed nodes are seeds. Keys need no hash tags: writes and scripts touch one key at a time, and the one multi-key read (the MGET behind a user’s session list) is split by slot in the cluster client
redis+sentinel://sentinel1:26379,sentinel2:26379/mymasterSentinel-managed replication; the pool follows the current master of mymaster

Credentials go before an @ and apply to every listed host: redis+cluster://:secret@host1:7000,host2:7001. The cluster and Sentinel forms build plain redis:// connections to each host, so TLS to Valkey is available only in the single-server rediss:// form.

TLS and certificate authentication

rediss:// alone verifies the server against the system roots and authenticates with the password in the URL. Two settings go further, for the single-server form:

  • REDIS_TLS_CA_FILE: PEM roots to trust instead of the system’s, for a Valkey behind a private CA (cert-manager, the OpenShift service CA). It applies to the Valkey connection only; outbound HTTPS keeps the system roots.

  • REDIS_TLS_CERT_FILE and REDIS_TLS_KEY_FILE: a client certificate and its key, both PEM. Valkey can then authenticate the connection by the certificate instead of a password: with tls-auth-clients yes it accepts only certificates from its tls-ca-cert-file, and with tls-auth-clients-user CN it logs each connection in as the ACL user the certificate’s CN names. That user needs no password at all, which also keeps Valkey’s own password hashing out of the picture (the FIPS build wants this):

    tls-port 6380
    tls-cert-file /etc/valkey/tls/server.crt
    tls-key-file /etc/valkey/tls/server.key
    tls-ca-cert-file /etc/valkey/tls/ca.crt
    tls-auth-clients yes
    tls-auth-clients-user CN
    user default off
    user ridm on nopass ~ridm:* &* +@all -@admin
    

    with REDIS_URL=rediss://valkey.internal:6380 (no credentials) and a certificate whose subject is CN=ridm.

The files are read when a connection is made and read again whenever one of them changes, so a certificate renewed in place is picked up without a restart; a renewal that cannot be read yet keeps the previous certificate until it can. Each region’s Valkey has the same settings with its suffix (REDIS_TLS_CA_FILE_EU). On Kubernetes the chart’s redis.tls mounts the Secrets and sets the variables (Kubernetes).

Cache invalidation between nodes uses pub/sub on one channel. Under Sentinel the subscriber resolves the current master when it connects, and reconnects with backoff after a failover.

What lives in Valkey

In Valkey (short-lived, shared)In Postgres (durable)
Browser (SSO) sessions and each user’s set of live sessionsA durable record of each session (sso_sessions)
Authorization codes, PAR requests, device codes and user codesRefresh tokens, consents, personal access tokens
Login and logout flows in progress, one-time codes, magic links, email verification and password-reset tokens, TOTP and passkey enrolment state, identity-brokering stateUsers, credentials, groups, roles, clients, resource servers
The access-token denylist (revoked jtis until they expire)Signing keys (encrypted), tenant settings, provider settings
Opaque access tokens: the claims behind each at_… token, keyed by its SHA-256, until it expiresDCR initial access tokens (hashed)
Rate-limit countersAudit log, webhook deliveries, message queue
Background-job leader locks and each job’s last run (ridm:jobs:last_run)Invitations, trusted devices, login attempts
Cached copies of tenants, clients, scopes, JWKS, discovery documents, roles, IP rules

Keys are prefixed ridm:, so a shared Valkey is possible, though a dedicated one is simpler to size and secure.

Memory and eviction

Size Valkey so it does not reach maxmemory. With an allkeys-lru or volatile-* policy a full Valkey evicts whatever it likes, including live browser sessions (users are signed out) and denylist entries (revoked access tokens pass rIDM’s checks again until they expire). With noeviction a full Valkey refuses writes and requests fail loudly instead. The compose file’s 256 MB with allkeys-lru suits evaluation only. Enable append-only persistence (appendonly yes, as the compose file does) so a restart keeps sessions.

If Valkey is lost

Losing Valkey’s data (a flush, a restart without persistence, a failover to an empty replica) loses no durable data, but:

  • every browser session ends; users sign in again. Refresh tokens issued with offline_access keep working, being in Postgres; refresh tokens without it are bound to the session and stop with it;
  • opaque access tokens (at_…) end early, since their claims were only in Valkey; clients refresh or sign in again;
  • sign-ins, authorization codes, device authorizations and emailed links in flight fail and must be started again;
  • revoked access tokens that have not yet expired pass rIDM’s own checks again (introspection, userinfo, the account API), because the denylist is gone; APIs that validate JWTs locally never consulted it. Revoke again if that matters, or wait out the access-token lifetime;
  • rate-limit windows start from zero;
  • caches refill from Postgres, and each node clears its in-process cache when its invalidation subscriber reconnects.

While Valkey is unreachable, rather than empty, /readyz returns 503 and requests that need a session, a flow or the cache fail. The rate limiter alone fails open (with a warning), since it protects against abuse and is not an authorization control.

Backups

Back up Postgres; Valkey holds nothing that cannot be lost at the cost above. A Postgres backup contains encrypted secrets that decrypt only with the master key in use (and any listed in MASTER_KEY_PREVIOUS), so keep a copy of the key, stored apart from the backups, for as long as you keep the backups. Backups hold user data and credential hashes: encrypt them and control access. A logical dump must run as a role that bypasses row level security. Backup and restore has the commands, the restore procedure and what to do if the master key is lost.

Scaling and performance

rIDM scales out by adding nodes. Nodes are stateless and identical: any node serves any request, runs its share of background work, and needs nothing from the others except what they share through Postgres and Valkey. There is no leader to elect and no node to configure differently.

Adding nodes

Run more copies of the image with the same configuration (see Deployment overview for what must match) behind a load balancer that health-checks /readyz. No session affinity is needed: browser sessions, flows in progress, authorization codes and rate-limit counters are all in Valkey.

What grows with the node count:

  • Postgres connections: each node may open up to DB_POOL_MAX (default 20), and as many again to a read replica. Lower DB_POOL_MAX as you add nodes; see Postgres and Valkey.
  • Valkey connections: up to REDIS_POOL_MAX (default 32) per node, plus one pub/sub subscription per node.

Rolling restarts are safe. On SIGTERM a node stops accepting connections and gives in-flight requests up to 20 seconds to finish. /readyz does not change on shutdown, so deregister the node from the load balancer first (orchestrators do this when a pod terminates) so new requests go elsewhere. Schema migrations run as a separate step before the rollout (see Container image); a release’s migrations keep the release before it working during the roll (Upgrading).

Background jobs on many nodes

Every node runs the same scheduler. Each job waits 30 seconds plus up to 5 seconds of jitter after startup, then runs on its interval plus up to 30 seconds of jitter. At the start of each pass it tries to take a Valkey lock, ridm:lock:<job>, with SET NX PX; a node that does not get it skips that pass. The lock is released with a compare-and-delete, so a node can only release a lock it holds, and it carries a time-to-live so a node that dies mid-pass cannot hold it forever.

JobEveryLock time-to-liveWhat it does
key_rotation1 h10 minRotates and retires signing keys per each tenant’s key policy
audit_retention24 h30 minCreates upcoming audit partitions, purges audit rows past the tenant’s retention
saml_metadata_refresh1 h1 hRe-reads the metadata URL of SAML identity providers last read more than a day ago
ldap_sync5 min1 hSyncs the LDAP directories whose sync interval has passed (each directory also holds its own lock while it syncs)
user_purge24 h30 minHard-deletes soft-deleted users past the tenant’s retention, visiting only tenants that have some, 500 users per transaction
webhook_delivery30 s2 minRequeues deliveries a stopped node left in sending, then retries those whose backoff has elapsed, four tenants at a time
message_delivery30 s2 minRequeues messages a stopped node left in sending, then sends queued and retrying email and SMS, four tenants at a time
cleanup1 h10 minDeletes spent rows older than RETENTION_DAYS (default 30), in batches of 5,000, through indexes that cover every tenant at once

A job holding its lock for a long pass renews it as it goes, so the lock never lapses under it and lets a second node start the same work.

So however many nodes there are, each job runs on one node at a time, and adding nodes does not add job load. Webhooks and messages are first attempted as soon as the event happens, by the node that handled the request; the delivery jobs only pick up retries and anything left queued. The two delivery jobs find the tenants with due work in one cross-tenant query and visit only those, so their cost follows the backlog rather than the number of tenants. A tenant’s first signing key is created under a similar lock, so two nodes answering the tenant’s first request do not both create one, and a partial unique index allows one active key per tenant and algorithm.

Each pass’s outcome is counted in ridm_job_runs_total{job,outcome} and stored as the job’s last run in the Valkey hash ridm:jobs:last_run (no API endpoint exposes it yet; read it with HGETALL ridm:jobs:last_run). See Observability.

Caching and invalidation across nodes

The token path is built to touch the database as little as possible. Reads go through two layers:

  1. an in-process cache on each node (about 15 seconds; 30 seconds for “does not exist” answers, so unknown tenant slugs cannot hammer the database);
  2. Valkey, shared by all nodes, with longer lifetimes (minutes);
  3. Postgres, only on a miss in both.

Tenants, clients, signing keys, scopes, claim mappers, resource servers, IP rules, CORS origins, organizations, identity providers, message templates, personal access tokens and the dashboard’s statistics go through this path, as do a user’s effective roles and groups and the permissions a set of roles holds on a resource server. Users and key material (parsed signing keys, verification keys, SAML signers) are cached per node only, never in Valkey: user rows carry password hashes. The user row an authenticated request reads is kept for up to a minute and evicted on every node by any write to that user; bearer tokens verify against the published keys, parsed once per node under the keys version. Lifetimes are spread by a tenth either way, so entries cached together do not all expire in the same second.

The in-process cache holds at most 100,000 entries per node (the least used go first), and a missing key is loaded once however many requests ask for it at the same time. RSA JWT signing and SAML signing and verification run on the blocking thread pool, off the request threads.

A write evicts the affected keys from its own node’s cache, holds them in Valkey for five seconds (a loader that read the old row just before the write cannot put it back), and publishes them on the pub/sub channel ridm:cache:invalidate; every other node evicts them from its in-process cache on receipt. A node also refuses to cache a value it read before an eviction of that key arrived. Role-derived entries are keyed under two versions: the tenant’s, which a role, group-tree or permission change moves, and the user’s own, which a membership or a grant to that user moves, so stale entries simply stop being read and one membership change touches one user’s entries. The JWKS document and the signing and verification keys are keyed the same way under a per-tenant keys version, so nothing read before a key change is used after it. Nodes read these version tokens from Valkey at most every two seconds; a change reaches every node through the same pub/sub channel well before that.

If a node’s subscription drops, it reconnects with backoff and clears its whole in-process cache on reconnect, because it may have missed invalidations. The worst case for a missed message is therefore the in-process lifetime, about 15 seconds.

Discovery and JWKS documents are served with an ETag and Cache-Control: max-age=300 and answer 304 to If-None-Match, so relying parties and CDNs revalidate cheaply.

CPU and memory

The expensive operation is password hashing. argon2id defaults to 19 MiB of memory and 2 iterations per hash (ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST), and each password sign-in, registration, password change and reset does one. Size node memory for the number of concurrent password operations you expect times the memory cost, and expect password sign-ins, not token requests, to dominate CPU. Raising the cost parameters upgrades existing hashes as each user next signs in.

The FIPS build hashes with PBKDF2-HMAC-SHA512 instead (PBKDF2_ITERATIONS, default 210,000). That uses CPU rather than memory: a few kilobytes per hash, and about 80 ms of one core at the default on a fast desktop CPU (an i9-12900K), so expect 100 to 200 ms on typical cloud vCPUs (measure yours). Size FIPS nodes for CPU: concurrent password operations times that time per hash.

Client secrets are SHA-256 hashed, so /token does no password hashing.

Load testing

The load tests live in perf/: a k6 script, token.js, that drives /token with the client_credentials grant and revalidates the discovery and JWKS documents with If-None-Match.

RunLoadThresholdsWhere it runs
smoke (SMOKE=1)20 VUs on /token, 2 on the documents, 30 serrors below 1 %, token p95 below 500 ms, documents p95 below 200 msevery pull request (load-smoke job, debug build)
baseline200 VUs, 2 min (VUS, DURATION)errors below 1 %, token p99 below 50 ms, documents p99 below 10 msthe release workflow, on a release build

The baseline’s target is 5,000 token requests per second per node. No published figure exists yet: the release workflow runs the baseline on a shared CI runner, which is not the machine to publish a number from, and there has been no release. The one measurement recorded so far is a 30-second smoke on a debug build on a developer machine (WSL2, 22 VUs, everything on one box): about 2,500 requests per second in total, with token p95 around 15 ms and no failures.

Running it

The API under test needs RATE_LIMITS=false: the test comes from one address, and the per-address ceilings (600 token requests a minute per tenant, 6,000 across tenants, by default) would refuse it otherwise. Never set that in production.

The script needs a confidential client with the client_credentials grant. Either pass one:

docker run --rm --network host -v "$PWD/perf:/perf" grafana/k6 run \
  -e BASE_URL=http://localhost:8080 -e TENANT=acme \
  -e CLIENT_ID=<id> -e CLIENT_SECRET=<secret> /perf/token.js

or leave CLIENT_ID and CLIENT_SECRET out and open dynamic client registration on the tenant, in which case the script’s setup registers one. TENANT defaults to master and BASE_URL to http://localhost:8090. Add -e SMOKE=1 for the smoke shape, or -e VUS=200 -e DURATION=2m for the baseline.

For a meaningful baseline: a release build (cargo build --release or the container image), the default argon2 parameters, Postgres and Valkey on the same network as the node, and k6 on a separate machine so it does not compete for CPU. Record the hardware alongside the result.

Observability

rIDM gives an operator four signals: structured logs, Prometheus metrics, OpenTelemetry traces, and the audit log with an optional export sink. This page covers each, and the two health endpoints.

Health probes

EndpointChecksResponse
GET /healthzNothing beyond the process answering HTTPAlways 200 {"status":"ok","version":"0.4.1"} while the server runs
GET /readyzSELECT 1 on the primary Postgres pool and PING to Valkey, in parallel, and the depth of the in-process event queues200 {"status":"ok","checks":{"database":"ok","cache":"ok","events":"ok"}}, or 503 with "status":"degraded" and "fail" against the failed check ("events":"saturated" when a queue is past 80% of EVENT_QUEUE_CAPACITY)

Use /healthz for liveness and /readyz for readiness and load-balancer health checks. A failed readiness check is also logged as a warning with the error. Neither probe checks the read replica, the job scheduler or outbound services. Neither endpoint is rate limited or authenticated, and neither reveals more than the version.

Inside the container, /ridm-api --healthcheck probes /healthz at the bind address, over https when TLS_CERT is set; the image’s HEALTHCHECK runs it. See Container image.

Logs

Logs go to standard output through tracing.

VariableDefaultValues
LOG_FORMATjsonjson for production; pretty (or text) for a terminal
RUST_LOGinfoA tracing filter, e.g. info,ridm_api=debug,sqlx=warn

JSON lines carry the event’s fields flattened at the top level, with timestamp, level, target and the current span. Secret-bearing fields are excluded from serialisation, and the server’s own log lines do not print tokens.

Worth alerting on:

LogLevelMeaning
job failed (field job)errorA background job pass failed
readiness: database check failed, readiness: cache check failedwarn/readyz is returning 503
cache invalidation listener disconnectedwarnA node lost its pub/sub subscription; it will reconnect
audit: could not record eventerrorAn audit row was not written
audit: creating partitions failederrorThe audit_retention job could not create next months’ audit partitions; rows fall into the default partition (see Postgres and Valkey)
audit sink delivery failed; will retrywarnThe export sink’s receiver refused or could not be reached; the rows are sent again after a backoff
audit chain does not verifyerrorThe scheduled check found a tenant’s audit chain broken (see the scheduled check)
webhook delivery dead-letteredwarnA webhook exhausted its retries
slow statement warnings from sqlxwarnA query took more than 250 ms

Metrics

GET /metrics serves Prometheus text format (text/plain; version=0.0.4). It is open unless METRICS_TOKEN is set, in which case it demands Authorization: Bearer <token> (compared in constant time) and answers 401 otherwise. Either set a token or keep the path off the public proxy; the TLS and reverse proxies snippets do the latter.

scrape_configs:
  - job_name: ridm
    authorization:
      credentials_file: /etc/prometheus/ridm_metrics_token
    static_configs:
      - targets: ["10.0.1.11:8080", "10.0.1.12:8080"]

Scrape every node: counters are per process.

MetricTypeLabelsMeaning
ridm_http_requests_totalcountermethod, route, statusEvery request. route is the matched pattern (/t/{slug}/token), or unmatched, never the raw path
ridm_http_request_duration_secondshistogrammethod, routeRequest latency
ridm_token_requests_totalcountergrant, outcome/token requests; outcome is issued or the OAuth error code
ridm_logins_totalcountermethod, outcomeFirst-factor sign-ins. Password failures carry the reason (invalid_credentials, locked, disabled, …); successes carry the first authentication method
ridm_sessions_created_totalcounterBrowser sessions opened
ridm_rate_limit_rejections_totalcounterRequests refused by a rate limit
ridm_ip_rule_rejections_totalcounterscope (tenant, client)Requests refused by an IP rule
ridm_webhook_deliveries_totalcounteroutcome (delivered, retry, dead)Webhook delivery attempts
ridm_webhook_deliveries_pendinggaugeDeliveries waiting, refreshed by the delivery job
ridm_messages_queuedgaugeEmail and SMS waiting, refreshed by the delivery job
ridm_job_runs_totalcounterjob, outcome (ok, error)Background job passes
ridm_job_duration_secondshistogramjobBackground job pass duration
ridm_cleanup_rows_totalcountertableRows deleted by the cleanup job
ridm_audit_events_totalcounterAudit rows written
ridm_audit_queue_depthgaugeEvents waiting for the audit writer on this node
ridm_webhook_dispatch_queue_depthgaugeEvents waiting for the webhook dispatcher on this node
ridm_event_queue_depthgaugesubscriber (audit, webhooks)Events waiting in an in-process consumer’s queue, read at scrape time (so a stuck consumer still shows)
ridm_event_queue_capacitygaugesubscriberThat queue’s bound (EVENT_QUEUE_CAPACITY); /readyz fails at 80% of it
ridm_event_queue_dropped_totalcountersubscriberEvents dropped because the queue was full
ridm_audit_parked_eventsgaugeAudit events held for chains that cannot be written for now (a tenant being moved, a region down), retried every 5 s
ridm_audit_events_dropped_totalcounterAudit events dropped because too many were held for unavailable chains
ridm_audit_sink_rows_totalcounterAudit rows shipped to the sink
ridm_audit_sink_failures_totalcounterSink deliveries that failed (they are retried)
ridm_audit_sink_lag_rowsgaugeAudit rows recorded but not yet shipped, over every chain
ridm_audit_chain_breaks_totalcounterAudit chains the scheduled check found newly broken
ridm_audit_chains_brokengaugeAudit chains currently known broken

Every _seconds histogram uses buckets from 1 ms to 10 s (0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10). A metric appears only after its first observation, so a fresh node does not list, say, ridm_ip_rule_rejections_total until something is refused.

Notes for alerting:

  • A job that runs on a single node per pass still counts on whichever node took the lock, so sum ridm_job_runs_total across nodes. A node that skipped a pass because another held the lock records an ok pass too; alert on outcome="error" rather than on the absence of runs.
  • ridm_messages_queued and ridm_webhook_deliveries_pending are set only by the node that last ran the delivery job; take the maximum across nodes.

Traces

Set OTEL_EXPORTER_OTLP_ENDPOINT to an OpenTelemetry collector’s base URL (for example http://otel-collector:4318) and spans are exported over OTLP/HTTP with protobuf encoding to {endpoint}/v1/traces, batched, under the service name OTEL_SERVICE_NAME (default ridm) with service.version set. Unset, no exporter runs. If the exporter cannot be built, the server prints otlp: exporter not started and carries on without it. Pending spans are flushed on shutdown.

The exporter sees spans that pass the RUST_LOG filter. Each request opens one span at info level, so the default RUST_LOG=info exports it:

AttributeValue
span nameMETHOD /route/{template}, e.g. POST /t/{slug}/token; unmatched for a path no route matched
otel.kindserver
http.request.methodThe method
http.routeThe matched route template
http.response.status_codeThe response status, recorded when the response is ready

The span never records the concrete path or the query string, because those can carry invitation tokens and authorization codes. It is the only span rIDM creates today; database and Valkey calls are not traced as child spans.

The log output is unchanged at info: the log layer shows the request span (as a span object on JSON lines) only when RUST_LOG enables debug somewhere, and tower-http’s “started processing” and “finished processing” lines are debug events.

Audit log and export

Every domain event (sign-ins, token issuance, administrative changes, …) is written to the audit_events table as part of a per-tenant SHA-256 hash chain, so a changed or deleted row within the retained window breaks verification. Retention is a tenant setting; the audit_retention job purges older rows daily. How to read, filter and verify the log is in Webhooks and the audit log, and the event model in Events, audit and webhooks.

Rows are recorded asynchronously from an in-process event bus. The writer’s queue is bounded (EVENT_QUEUE_CAPACITY): ridm_audit_queue_depth rising and staying up means the database cannot keep up with the event rate, /readyz fails the node once the queue is 80% full, and ridm_event_queue_dropped_total{subscriber="audit"} counts events lost to a full queue — alert on it. A node that is stopped gives its queues 15 seconds to drain after its last request and loses what is left, so compare ridm_audit_events_total with your expectations after incidents.

To keep a copy off the host, set AUDIT_SINK_URL. One node at a time ships rows from the database, exactly as stored (with chain sequence and hash), and records per chain how far it got. A receiver that is down delays rows but loses none: they go out when it is back. See Shipping to an external system for the transports (https://, syslog://, syslog+tcp://, syslog+tls://), signing and delivery semantics.

Alert on ridm_audit_sink_lag_rows growing and on ridm_audit_chains_broken above 0.

Background job status

Besides the metrics, each job’s last pass (time, outcome, duration, error) is kept in the Valkey hash ridm:jobs:last_run, one field per job. No API endpoint or console page shows it yet; read it directly:

valkey-cli HGETALL ridm:jobs:last_run

A node that skipped a pass because another node held the lock also records its pass as successful, so an ok entry means the scheduler is alive, not that the job did work.

The jobs themselves are described in Scaling and performance.

Backup and restore

An rIDM deployment keeps its durable state in one place, the Postgres database, and encrypts the secrets in it under a key that is deliberately kept somewhere else. A backup you can restore is therefore two things kept apart: the database, and the master key it was written with.

What to back up

WhatBack upWhy
The Postgres databaseYes, continuously or at least dailyEverything durable: tenants, users, password hashes, clients, keys (encrypted), sessions’ durable records, refresh tokens (hashed), consents, audit log
Each regional database (DATA_REGIONS)Yes, like the home database, and restored together with itEverything of the tenants placed in that region; the home database only registers them. See Data residency
The master key, every generation still in useYes, once per generation, apart from the database backupsWithout it the database’s secrets cannot be read (below)
Configuration and the other secretsYes, with the rest of your deployment configurationEnvironment variables, database and Valkey passwords, METRICS_TOKEN, SMTP password, TLS certificates, proxy configuration
The Postgres rolesRecorded, not dumpedpg_dump covers one database; roles belong to the server. Keep the SQL that creates them (Postgres and Valkey)
ValkeyNoSessions, flows in progress, caches and counters. Losing them signs users out and nothing more lasting; see If Valkey is lost
The release you runIts version or image digestRestore with the same release or a newer one, never an older one (Upgrading)

The master key

The master key encrypts signing keys’ private halves, second-factor secrets, SMTP, SMS and CAPTCHA credentials, upstream identity providers’ client secrets and webhook secrets (the full list). rIDM never writes it anywhere, so it exists only where you put it.

  • Keep it apart from the backups. On its own, a database backup exposes personal data and password hashes, but its secrets stay encrypted. Together with the key it is everything. Store the key in a secret manager with its own access control, and keep a second copy offline (a sealed printout, a hardware token, a vault in another account) in case the secret manager is the thing you lose. Never put it in the same bucket, account or credentials as the database backups.
  • Keep every generation a retained backup needs. After a master-key rotation, the backups taken before it are encrypted under the old generation. Keep the old key, with its version number, for as long as you keep those backups.
  • With a key custody backend, the backups hold only wrapped data keys: restoring one needs the backend key that wrapped them, so keep that key (and rIDM’s access to it) for as long as those backups, and never schedule a KMS key for deletion while a retained backup’s master_key_generations names it.
  • Record the generation with each backup. ridm-api rotate-master-key --status (or ridm master-key status) prints how many rows each generation holds; normally that is the current MASTER_KEY_VERSION alone.

Backing up Postgres

Use Postgres’s own tools; there is nothing rIDM-specific to export. Two approaches:

Physical backups and point-in-time recovery

For production, prefer continuous archiving: the managed service’s automated backups and point-in-time restore, or pgBackRest, Barman or WAL-G on a server you run. They copy the data files and the write-ahead log, so a restore can land on any moment in the retention window, losing seconds rather than a day. They work below row level security, so the roles question below does not arise.

Logical backups with pg_dump

pg_dump is simple and portable, and fine for a small deployment or as a second copy next to physical backups. It takes a consistent snapshot while rIDM keeps running.

It must run as a role that bypasses row level security. rIDM forces row level security on every tenant table, for the tables’ owner too, and pg_dump refuses rather than silently dump nothing. Run as ridm_migrator or ridm_app it stops with:

pg_dump: error: query failed: ERROR:  query would be affected by row-level security policy for table "claim_mappers"

Any of these works:

  • A dedicated backup role (preferred): read-only, and nothing else. Create it once as a superuser:

    CREATE ROLE ridm_backup LOGIN PASSWORD '...'
        NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS;
    GRANT pg_read_all_data TO ridm_backup;
    
    pg_dump -h db.internal -U ridm_backup -d ridm -Fc \
        -f ridm-$(date -u +%Y%m%dT%H%M%SZ).dump
    
  • A superuser, such as ridm in the compose stacks:

    docker compose exec -T postgres pg_dump -U ridm -Fc ridm > ridm.dump
    
  • The migrator, asking the policy to let it through, for a managed service where you cannot create a BYPASSRLS role. The policy admits a transaction that sets app.bypass_rls, so pass it as a connection option and tell pg_dump to run with row security on:

    PGOPTIONS='-c app.bypass_rls=on' pg_dump -h db.internal -U ridm_migrator -d ridm \
        --enable-row-security -Fc -f ridm.dump
    

All three produce the same dump. Use the custom format (-Fc): it is compressed, and pg_restore can restore it in parallel or pick parts out of it.

Protecting the backups

A backup holds every user’s personal data, password hashes (argon2id) and the encrypted secrets. Encrypt it before it leaves the database host (for example with age or your backup tool’s own encryption), keep it in storage only your operators can read, and delete it on the schedule your data protection policy sets. A backup outlives erasure requests made after it was taken; decide how your restore procedure handles them.

Test the restore

A backup nobody has restored is a hope. Restore into a scratch database on a schedule, start a node against it (next section) and check that a token can be issued: that proves the dump, the roles and the master key together.

Restoring

The procedure below restores into a new database and switches to it, so the old one is there to fall back on until you are satisfied.

1. Stop rIDM

Stop every node: docker compose stop ridm, kubectl scale deployment/ridm --replicas=0, or your service manager. Nodes left running would keep writing to the old database and hold its state in their caches.

2. Prepare an empty database

If the Postgres server is new, create the two roles first, with the SQL in Postgres and Valkey. Then, as a superuser, create the database and its grants (the same grants that SQL gives, for the new database):

CREATE DATABASE ridm_restored;
GRANT CONNECT, CREATE, TEMP ON DATABASE ridm_restored TO ridm_migrator;
GRANT CONNECT, TEMP ON DATABASE ridm_restored TO ridm_app;
\c ridm_restored
GRANT ALL ON SCHEMA public TO ridm_migrator;
GRANT USAGE ON SCHEMA public TO ridm_app;

The table grants and default privileges for ridm_app come back with the dump.

3. Restore

From a pg_dump file, as the migrator, so that it owns what it restores:

pg_restore -h db.internal -U ridm_migrator -d ridm_restored \
    --no-owner --single-transaction --exit-on-error ridm.dump

This works without bypassing row level security: pg_dump puts the data before the policies, so the rows are loaded before row level security is switched on. The result matches the original: tables owned by the migrator, row level security forced, the app role’s privileges and the audit_ensure_partitions grant in place. A superuser can restore instead, without --no-owner, which keeps the original owners (the roles must exist under the same names).

From a physical backup, follow your tool’s or provider’s restore procedure; the roles, grants and policies are part of the data files.

4. Switch to it

Point DATABASE_URL (and the migrator’s URL) at the new database, or swap the names as a superuser once nothing is connected:

ALTER DATABASE ridm RENAME TO ridm_before_restore;
ALTER DATABASE ridm_restored RENAME TO ridm;

5. Clear rIDM’s keys from Valkey

Valkey still holds the old database’s state: cached tenants, clients, JWKS and role sets, and sessions pointing at records the restored database may not have. Remove everything rIDM put there. On a Valkey used by rIDM alone:

valkey-cli -h valkey.internal FLUSHDB

On a shared one, delete only the ridm: keys:

valkey-cli -h valkey.internal --scan --pattern 'ridm:*' | xargs -r valkey-cli -h valkey.internal unlink

In a cluster, run it against every primary. Users sign in again; see If Valkey is lost for everything else this drops.

6. Start with the backup’s master key

Configure the master key that was current when the backup was taken, with its MASTER_KEY_VERSION, and any older generation the backup still holds in MASTER_KEY_PREVIOUS. If you have rotated the master key since the backup, you can keep the new key current and list the backup’s generation in MASTER_KEY_PREVIOUS instead, then run ridm-api rotate-master-key to bring the restored rows up to date.

If the restore is onto a newer release than the backup was made with, run ridm-api migrate (or let the compose migrate service or the Helm hook do it) before starting the servers; migrations apply to a restored database as to any other.

Then start one node. At start-up every node decrypts a signing key of each generation the database holds, and refuses to start when the configured keys cannot read them:

fatal error=the configured master keys do not decrypt this database's signing keys (generation(s) [1]; MASTER_KEY_VERSION is 1). After a restore, configure the master key that was current when the backup was taken, and any older generation still in use in MASTER_KEY_PREVIOUS

That message means the wrong key, not a damaged backup. Other secrets (webhook, identity provider and messaging credentials, second factors) are sampled too; one that fails is logged as an error with its table and generation, and the node starts.

7. Check it

  • /readyz answers 200 on every node.
  • ridm-api rotate-master-key --status shows only generations you have keys for.
  • A global administrator can sign in to the admin console.
  • A client can get a token, and the tenant’s JWKS lists the keys you expect.

Then start the remaining nodes.

What restoring to an earlier point undoes

A restore returns the database to the moment of the backup. Everything after it is gone, and some of that matters more than the rest:

  • Security actions since the backup are undone. A signing key revoked after the backup is back and published again; a disabled user, a removed administrator, a revoked personal access token, SCIM token or refresh token, a rotated client secret and a changed password are back to their earlier state. Re-apply them before letting traffic in. If you ship the audit log off the host with AUDIT_SINK_URL, the sink holds the events that happened after the backup and is the list to work from; the database’s own audit log ends at the backup. The restored chains continue from the backup’s seq numbers, so the sink will send new rows under numbers it already sent for the lost ones: a receiver that deduplicates must do so on the row’s id.
  • Users sign in again. Browser sessions went with Valkey. Refresh tokens issued or rotated after the backup are unknown to the restored database, so those clients sign in again too.
  • Changes are lost: registrations, enrolled second factors and passkeys, clients, consents, tenant settings. Users who registered after the backup must register again.
  • Queued work may run twice. Webhook deliveries and email or SMS messages that were queued when the backup was taken and sent afterwards are queued again. A receiver that ignores a repeated X-RIDM-Delivery is unaffected (webhooks).
  • Signing keys created after the backup are gone. Tokens they signed stop verifying at rIDM; they were short-lived anyway.

If the master key is lost

The database can be recovered without its master key, but the secrets encrypted under it cannot: that is what the key is for. What remains is everything hashed rather than encrypted (passwords, client secrets, refresh tokens, personal access tokens, SCIM tokens) and all the plain data. Recovering means discarding the unreadable secrets and creating them again:

  1. Generate a new master key (openssl rand -hex 32) and give it a MASTER_KEY_VERSION higher than any generation in the database, so the old rows read as a generation no configured key belongs to, rather than as the current one.

  2. With every node stopped, delete the secrets that cannot be read, as the migrator:

    BEGIN;
    SELECT set_config('app.bypass_rls', 'on', true);
    DELETE FROM signing_keys;
    DELETE FROM credentials;
    DELETE FROM tenant_provider_settings;
    COMMIT;
    
  3. Start the nodes with the new key. Each tenant gets a new signing key the first time it signs. The start-up check logs an error for each identity provider and webhook whose secret it cannot read, until you re-enter them.

  4. Re-enter what was deleted or unreadable:

    • Identity providers: set the client secret again, in the console or with PATCH /admin/tenants/{slug}/identity-providers/{idp} and a new client_secret.
    • Webhooks: issue a new secret (POST /admin/tenants/{slug}/webhooks/{webhook}/secret, or the console) and give it to the receiver.
    • Email, SMS and CAPTCHA settings of tenants that had their own: Email, SMS and templates, CAPTCHA.
  5. Tell relying parties. Every token issued before is signed with a key that no longer exists, so it stops verifying once they fetch the new JWKS. Refresh tokens are hashed, not encrypted, and keep working: clients holding one get a new token signed with the new key, and the rest sign in again.

  6. Tell users. Their authenticator apps, passkeys and recovery codes are gone. They sign in with their password (or a magic link, where the tenant allows one) and, under a required MFA policy, enrol a factor during sign-in (MFA policy). A user who signed in with a passkey alone needs a password reset or a magic link first.

This is a disruptive recovery; the way to never need it is the second, offline copy of the key.

Upgrading

An upgrade is a new binary or image plus, usually, database migrations. The binary carries its migrations; you apply them as the schema owner, then roll the servers. Nothing else changes: the master key, Valkey and your configuration stay as they are unless the release notes say otherwise.

Versions and what they promise

Releases follow Semantic Versioning. From 1.0, a patch or minor release upgrades in place with no configuration changes; a major release may need some, and its notes say what. Before 1.0, a minor release (0.1 to 0.2) may break compatibility (a renamed setting, a changed admin API field), and its notes say how. A patch release never does.

Every release’s notes, on its GitHub release and in CHANGELOG.md, have an Upgrade notes heading whenever there is something to do beyond replacing the binary, including a release whose migrations are not compatible with the release before it (next section). Read the notes of every release between yours and the target.

Rolling upgrades and the one-release rule

During a rolling upgrade the old release keeps serving on the migrated schema until its last node is replaced; the Helm chart and the compose stack both migrate first and roll second. That only works if the migrations leave the old code working, so the rule for rIDM’s own migrations is:

A release’s migrations keep the release immediately before it working. They add tables, columns with defaults and indexes; they do not drop, rename or retype what the previous release reads or writes. A removal happens in two releases: the first stops using the thing, the next drops it.

The same holds for what nodes share in Valkey (sessions, flows in progress, cached documents) and for the cache invalidation messages they exchange: old and new nodes read each other’s entries during the roll.

A release that has to break the rule says so under Upgrade notes, and then the upgrade is stop-the-world: scale to zero, migrate, start the new release.

Because the rule covers one step, skipping releases (1.2 straight to 1.5) is also a stop-the-world upgrade: migrations are cumulative, and migrate applies all of them in order, but a 1.2 node is not promised to work on the 1.5 schema. Stop every old node first, or upgrade one release at a time with a rolling upgrade each.

Every pull request checks the rule. deploy/upgrade-smoke/run.sh, in CI’s packaging job, starts the previous release’s image on the compose stack and bootstraps an administrator. It then upgrades the same volumes to the image under test, which migrates, starts, answers /readyz and still publishes every signing key the old release published. Last, it starts the previous image again on the migrated database, as it would serve during a roll. A release whose upgrade notes break the rule runs it with ALLOW_BREAKING_SCHEMA=1, which skips that last step. To run it locally:

docker build -f api/Dockerfile -t ridm:ci .
TO_IMAGE=ridm:ci deploy/upgrade-smoke/run.sh     # FROM_IMAGE=… to pick the old image

Before you upgrade

  1. Read the release notes, back to the release you run.
  2. Take a backup of the database, or check that point-in-time recovery covers the present (Backup and restore). A migration cannot be undone any other way.
  3. Verify the release you are about to run (Releases and verification), and pin it by version or, better, by image digest.
  4. Try it on a copy first when the notes list migrations on large tables: restore last night’s backup into a staging environment and time the migration there. Most migrations take milliseconds; one that builds an index on a table of millions of rows takes as long as that index takes, and holds its locks meanwhile.

Upgrading

Helm

helm upgrade ridm oci://ghcr.io/zerosandonesllc/charts/ridm --version 1.2.3 -f values.yaml

The chart’s pre-upgrade hook runs ridm-api migrate in a Job as the migrator role. The Deployment rolls only after the Job succeeds, with maxUnavailable: 0, so capacity never drops; if the migration fails, the running pods are untouched and helm upgrade reports the failure. See Kubernetes (Helm).

docker-compose

Set the new image in .env (RIDM_IMAGE=ghcr.io/zerosandonesllc/ridm:1.2.3, or …@sha256:…), then:

docker compose pull
docker compose up -d

The one-shot migrate service runs first with the new image and the server is recreated only if it succeeds. The stack is one node, so requests fail for the few seconds the server takes to restart. See Production with docker-compose.

Static binaries and other setups

  1. Install the new binaries next to the old ones.
  2. Run ridm-api migrate once, with DATABASE_URL set to the migrator role. It takes an advisory lock, so a second run started by mistake waits and then finds nothing to do.
  3. Restart the nodes one at a time with the new ridm-api, waiting for /readyz on each before the next. The server finishes in-flight requests for up to 20 seconds after SIGTERM.

With MIGRATE_ON_START=true and the server running as the schema owner, the first new node migrates as it starts; see Container image for why the separate step is preferred.

After the upgrade

  • /readyz answers 200 on every node, and /healthz reports the new version.
  • The start-up log has no database migrations are pending warning.
  • Sign in to the admin console, and let a client get a token.
  • Watch the error rate and the job metrics for an hour (Observability).

Rolling back

Migrations are forward-only: there are no down migrations. What a rollback takes depends on what the new release migrated.

  • No migrations in between: run the previous release again. Nothing else changed.

  • Migrations that kept the one-release rule: the previous release runs on the new schema, as it did during the roll, so running it again works. It logs a warning at start-up:

    WARN the database has migrations this release does not know: a newer release migrated it. Run that release, or restore the backup taken before the upgrade count=1 newest=20270301120000
    

    The warning stays until you upgrade again. Its ridm-api migrate refuses to run (migration 20270301120000 was previously applied but is missing in the resolved migrations), which is correct: there is nothing it could apply. Upgrade again once the problem is fixed, rather than staying on the older release indefinitely.

  • Migrations the notes marked incompatible, or a skip over several releases: the previous release is not promised to work on the new schema. Restore the backup taken before the upgrade (Backup and restore), with the consequences listed there for everything since.

Never restore a backup made by a newer release and run an older release on it; that is the same as the last case.

The master key and signing keys

Upgrades do not touch either. Rotating the master key is a separate, online operation (Rotating keys); do not combine it with an upgrade, so that a problem in one is not mistaken for the other. Each node checks at start-up that the master key decrypts the database’s signing keys and refuses to start if not, so a node that comes up after an upgrade with a mistyped secret stops with a clear message instead of failing sign-ins.

Production checklist

Go through this before a deployment carries real users. Each item links to the page that explains it. Run a release, or a commit you have reviewed, and record which one (by image digest) you run.

Secrets and keys

  • The master key is generated once, stored in a secret manager, and backed up apart from the database. openssl rand -hex 32; supply it as MASTER_KEY_FILE (a mounted secret) rather than an environment variable where you can. Every node gets the same key. A database backup without its key cannot decrypt signing keys, MFA secrets or provider credentials, and anyone holding both has everything, so the key and the backups never sit in the same place. Signing keys and the master key, Container image
  • You know how to rotate the master key (MASTER_KEY_VERSION, MASTER_KEY_PREVIOUS, ridm-api rotate-master-key) and have done it once in a test environment. Rotating keys
  • Signing key rotation is set per tenant to the interval your relying parties can follow; the key_rotation job applies it hourly. Rotating keys
  • Database and Valkey passwords are not the compose defaults (ridm, ridm_migrator, ridm_app). docker-compose

URLs, TLS and proxies

  • PUBLIC_URL is the final https URL. Every issuer derives from it; changing it later breaks every relying party. TLS and reverse proxies
  • TLS terminates at your proxy, or natively with TLS_CERT and TLS_KEY, and plain http redirects to https. TLS and reverse proxies
  • COOKIE_SECURE is true (the default). Sign-in then works only over https, which is the point. TLS and reverse proxies
  • TRUSTED_PROXIES lists exactly your proxy tier: empty behind a proxy makes every user one address; too wide lets callers forge theirs. TLS and reverse proxies
  • The UI is served from the same origin as the API: the image’s embedded UI (UI_URL unset), or ui/out on the same host with the framing headers and HSTS added by the static server. Deployment overview, TLS and reverse proxies
  • Custom domains have DNS, a certificate and a proxy route before a tenant turns one on. Custom domains

Exposure

  • DOCS_ENABLED is off (the default), so Swagger UI is not served at /docs. /openapi.json is served either way; it describes the API and holds no data. Server configuration
  • /.well-known/security.txt names your security contact (SECURITY_CONTACT, or your own document in SECURITY_TXT_FILE); it answers 404 until you set one. Server configuration
  • /metrics is protected: METRICS_TOKEN is set, or the path is not routed from the internet, or both. Observability
  • Postgres and Valkey are not reachable from the internet, and the compose file’s published ports are removed on any shared host. Postgres and Valkey, docker-compose
  • Outbound traffic is allowed only where needed: mail, SMS webhook, webhook receivers, upstream identity providers, the breached-password API (or BREACH_CHECK_URL=off), audit sink, collector. rIDM refuses private and loopback destinations for the URLs tenant administrators choose, but an egress firewall is still the boundary. Deployment overview

Database

  • The API connects as the DML-only app role and migrations run separately as the migrator. MIGRATE_ON_START=true is harmless under that role once the schema is current, but never give the server the migrator’s credentials so that it can migrate itself. Postgres and Valkey, Container image
  • The app role can execute audit_ensure_partitions. The migration grants it to roles that already had INSERT on the audit log; an app role created later needs the grant by hand. Postgres and Valkey
  • Pool sizes fit max_connections across all nodes (and the replica pool, if any). Postgres and Valkey
  • Valkey has enough memory never to evict, and append-only persistence on. Eviction signs users out and forgets revoked access tokens. Postgres and Valkey

First administrator and tenants

  • The first global administrator is bootstrapped and has changed the initial password (forced at first sign-in unless --no-must-change was used). Remove BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD from the environment afterwards (or empty secrets/bootstrap_admin_password in the production compose stack). Production with docker-compose, Administrator access
  • Administrators use a second factor. MFA policy
  • Email works: SMTP_HOST, SMTP_FROM (required once SMTP_HOST is set), SMTP_SECURITY (starttls by default), or per-tenant settings. Send a test invitation or password reset. Email, SMS and templates

Abuse controls

  • Rate limits are on (RATE_LIMITS=true, the default) and RATE_LIMIT_IP_PER_MINUTE suits your traffic; tenant ceilings are reviewed. Never carry RATE_LIMITS=false over from a load test. Rate limits, IP rules and CAPTCHA
  • Volume attacks are absorbed in front of rIDM, by a CDN, WAF or edge proxy. rIDM limits what one caller can do; it cannot absorb a flood.
  • Clocks are synchronised (NTP) on every node. Token expiry, TOTP and DPoP proofs depend on it.

Operations

  • Liveness probes /healthz, readiness and load-balancer checks probe /readyz. Container image
  • Logs are JSON (LOG_FORMAT=json) and collected, with alerts on the lines listed in Observability.
  • Metrics are scraped from every node, with alerts on job errors, audit sink drops and failures, dead-lettered webhooks and readiness. Observability
  • Audit rows are shipped off the host with AUDIT_SINK_URL, if your policy requires a copy an intruder on the host cannot alter. Observability
  • RETENTION_DAYS and each tenant’s audit retention match your policy. Scaling and performance
  • Postgres is backed up, encrypted and access-controlled, and a restore has been tested with the master key, into a scratch database, as far as issuing a token. A logical dump needs a role that bypasses row level security. Backup and restore
  • The master key has a second, offline copy, and every older generation is kept for as long as a backup encrypted under it. Backup and restore
  • You know how you will upgrade and roll back: migrate first, roll second, a backup before every upgrade. Upgrading
  • Load has been tested on your hardware with perf/token.js if you expect high volume. Scaling and performance

Migrating to rIDM

This part of the guide is for moving an existing identity system onto rIDM. rIDM has no importer written for any particular product. It has two general-purpose import paths, and the product-specific pages (From Keycloak, From Auth0) show how to turn each product’s exports into them:

What movesHowWhere it is described
Configuration: settings, profile schema, resource servers and permissions, scopes, clients, roles, groups, claim mappers, templates, webhooks, IP rules, identity providersThe tenant configuration document (ridm.tenant/1), applied with ridm tenant import or POST /admin/tenants/{slug}/importConfiguration as code, Tenant configuration document
Users, their role and group memberships, and their password hashesBulk user import, POST /admin/tenants/{slug}/users/import (JSON or CSV, 10 000 rows per request)Users, invitations and bulk import
Users kept in step with an HR system or another directorySCIM 2.0SCIM provisioning
Users who keep signing in through the old system for a whileIdentity brokering, with the old system as an upstream OpenID Connect providerIdentity brokering

What carries over and what does not

ItemCarries over?Notes
Client IDsYesA client in the document keeps the client_id you give it (1–128 characters of A-Z a-z 0-9 . _ : -, starting with a letter or digit)
Redirect URIsYes, if exactrIDM compares redirect URIs as exact strings (a native client’s loopback redirect may use any port). Wildcards are not expanded; list every URI. Plain http is refused except for localhost, 127.0.0.1 and [::1]
Client secretsNoA client created by an import gets a new secret, shown once in the import report. There is no way to set a secret of your choosing, so every confidential client needs its new secret deployed
Issuer URLNoSee Issuer, endpoints and keys
Signing keysNoThe tenant has its own keys; no key material is imported
User IDs (sub)NoImported users get new ids. See Subject identifiers
Password hashesWhere the format is supportedVerified in the original format at the user’s first sign-in, then replaced with argon2id. See Passwords
TOTP secrets, passkeys, recovery codesNoThere is no import path for second factors; users enrol again
Sessions, refresh tokens, consentsNoUsers sign in again; consent screens are shown again unless the client has require_consent: false
Linked social or enterprise accountsNot directlyLinks are re-established at the user’s next brokered sign-in; see Brokered users
Audit historyNoKeep the old system’s logs for as long as your retention rules say

Issuer, endpoints and keys

A tenant’s issuer is {PUBLIC_URL}/t/{slug}, for example https://id.example.com/t/acme, or https://<host> when the tenant has a custom domain. Neither form has a trailing slash and neither can reproduce another product’s path layout (/realms/acme, or Auth0’s https://acme.us.auth0.com/ with its trailing slash). So every relying party must be pointed at the new issuer, and every API that validates tokens must accept it:

  • Clients should read the endpoints from discovery, {issuer}/.well-known/openid-configuration, rather than hard-coding them. SDKs that build a product’s own URL layout (Keycloak’s adapters, Auth0’s SDKs) need replacing with a generic OpenID Connect library.
  • APIs must fetch the new JWKS (jwks_uri from discovery) and check the new iss. During a cut-over an API can accept tokens from both issuers by running two validators and trying the one whose issuer matches the token’s iss. With ridm-auth that is one Validator per issuer.
  • A custom domain lets you keep a hostname that users recognise (for example moving login.example.com from the old system to rIDM), which matters for passkeys and for users’ password managers, even though the issuer string itself still changes.

Tokens are not portable. rIDM cannot validate a refresh token or session cookie issued by another system, so each user signs in once more after the switch.

Subject identifiers

An imported user gets a new UUID, and that UUID is the sub claim. An application that stores users by the old sub needs a mapping. The usual approach is to carry the old id into a profile attribute during the import and publish it as a claim:

  1. Declare the attribute in the profile schema with editable_by: none. Bulk import and SCIM may set such an attribute, while neither users nor administrators can change it afterwards in the consoles or through the admin API.
  2. Put the old id in each import row’s attributes.
  3. Publish it. Listing id_token, userinfo and access_token in the attribute’s visible_in emits it under its own name (legacy_id); a claim mapper, as below, can emit it under another name.
{
  "profile_schema": {
    "attributes": [
      { "name": "legacy_id", "type": "string", "editable_by": "none" }
    ]
  },
  "claim_mappers": [
    {
      "name": "legacy-sub",
      "config": {
        "type": "user_attribute",
        "attribute": "attributes.legacy_id",
        "claim": "legacy_sub",
        "include_in": ["id", "access", "userinfo"]
      }
    }
  ]
}

At the user’s first sign-in the application looks the account up by legacy_sub, records the new sub, and from then on uses sub. Alternatively build the mapping offline from the user export, which carries both the new id and the attributes:

curl -s "https://id.example.com/admin/tenants/acme/users/export?format=json" \
  -H "Authorization: Bearer $RIDM_TOKEN" \
  | jq '[.[] | {new_sub: .id, legacy_id: .attributes.legacy_id}]' > sub-map.json

Passwords

A password_hash in an import row is stored as given and checked in its own format the first time the user signs in; after a successful check it is replaced with an argon2id hash under the server’s parameters, and a user.password_hash_upgraded event records the old algorithm. The formats rIDM accepts are listed under Legacy password hashes: argon2 (PHC), bcrypt, PBKDF2-SHA256/SHA512 in passlib’s PHC layout or Django’s layout, salted and unsalted SHA-256/SHA-512, and MD5. A row whose hash is in no known format fails with an error; nothing is guessed.

Most exports need their hashes rewritten into one of those layouts. That is a mechanical transformation (the product pages give tested scripts), but check it before importing thousands of users: create one account with a known password in the old system, export it, convert it, import it into a scratch tenant, and sign in.

Users whose hash cannot be carried over (an unsupported algorithm, or an account that never had a password) are imported without one. They get in through password recovery, a magic link or one-time code if the tenant enables them, a temporary password an administrator sets (Passwords and credentials), or a brokered provider. (Invitations do not help here: they create a new account and are refused for an address that already has one.)

Second factors

rIDM has no import for TOTP secrets, passkeys or recovery codes, even where the old system can export them. Passkeys could not move anyway: they are bound to the relying party id, which is the host the user saw. Users enrol again. To make that happen at the first sign-in rather than whenever users notice, set the tenant’s MFA policy to required (or required_for_roles / required_for_admins): the flow then enrols a second factor before it finishes.

Brokered users

Users who signed in through a social or enterprise provider in the old system have no password to move. Import them with their verified email and no password, configure the same provider in rIDM, and choose a link_policy of verified_email: at the first sign-in through the provider, the upstream account is linked to the imported user because both sides have verified the address. See Linking upstream identities to users. The provider’s client must allow rIDM’s callback URL, {issuer}/broker/{alias}/callback.

Big-bang or gradual

Big-bang. Import configuration and users, switch every client and API to the new issuer in one change window, retire the old system. Simple to reason about; every user signs in again the same day, and anyone whose hash did not carry over has to recover their account.

Gradual, per application. Import everything, then move applications one at a time. Each application changes issuer independently; users who use two applications sign in to each system once. Passwords changed in the old system after the import are not seen by rIDM, so either freeze password changes in the old system, or import again before each move. A repeat import does not update existing users (their rows fail as conflicts), so a changed hash needs the account deleted and imported again, or a password reset.

Gradual, through brokering. Configure the old system as an upstream OpenID Connect provider of the rIDM tenant (the old system sees rIDM as one more client). Applications move to rIDM immediately; users who choose “Continue with” the old system are signed in there and linked to their imported rIDM account. When most users have signed in directly, remove the provider. This keeps the old system as the source of truth for passwords during the transition, at the cost of running both. rIDM has no hook that checks a password against another system at first sign-in (sometimes called lazy or trickle migration); brokering is the nearest equivalent.

Checklist

  1. Create the tenant: ridm tenant create acme --name "Acme" (needs a master owner).
  2. Write the tenant document from the old system’s export (see the product pages), including tenant.settings. Importing a document replaces the tenant’s settings as a whole, so anything left out returns to its default.
  3. Apply it: ridm --tenant acme tenant import -f acme.json. Keep the import report: it holds the new client secrets, shown once. Fix any per-item errors and run it again; applying the same document twice changes nothing. Roles or groups granting admin permissions you do not hold yourself are refused item by item; the plan shown before confirmation lists them.
  4. Set the secrets the document cannot carry: identity provider client secrets, SMTP, SMS and CAPTCHA provider settings.
  5. Convert the user export to import rows. Split it into requests of at most 10 000 rows and 32 MiB, run each with ?dry_run=true, then for real.
  6. Test with a known account: password sign-in, the upgraded hash, second-factor enrolment, a brokered sign-in, and the claims your APIs read.
  7. Deploy the new client secrets, point clients at the new issuer, and teach APIs the new issuer and JWKS.
  8. Verify (below), then retire the old system’s clients.

Splitting a large import file:

jq -c 'range(0; length; 10000) as $i | .[$i:$i+10000]' users.json | split -l 1 -d - chunk-
for f in chunk-*; do
  curl -s -X POST "https://id.example.com/admin/tenants/acme/users/import" \
    -H "Authorization: Bearer $RIDM_TOKEN" -H "Content-Type: application/json" \
    --data-binary @"$f" | jq '{total, created, failed, errors}'
done

The import needs ridm:users:write and ridm:invitations:write; a personal access token with those permissions works. Rows are independent: a failed row is reported with its 1-based position in that request and the rest are still created, so a failed row can be fixed and sent again on its own. A row that puts a user in a role or group carrying admin (ridm:*) permissions the importing administrator does not hold fails with “cannot grant permissions you do not hold”, in a dry run as well; import administrators with a token that holds those permissions, or grant them afterwards.

Verifying the result

Keep the converted tenant document under version control. After the import, and again after any manual change in the console, compare the tenant with it:

ridm --tenant acme tenant diff -f acme.json --exit-code

tenant diff prints the plan an import would carry out, changing nothing. With --exit-code it exits 0 when the tenant matches the document and 3 when it does not, so it can run in CI; --prune also lists configuration that exists in the tenant but not in the document (for example a client someone created by hand). Other exit codes are 1 for a failed request, or for a plan with items the import would refuse, and 2 for a usage error.

For users, compare counts and spot-check: the user export (GET /admin/tenants/acme/users/export) against the source export, and the import reports’ failed totals against the rows you expected to fail (for example accounts without a supported hash).

From Keycloak

Keycloak and rIDM share most of their vocabulary, so a realm maps onto a tenant closely. This page gives the concept mapping, how to export a realm, and two jq scripts: one that drafts a tenant document from the realm export, and one that turns the exported users, including their password hashes, into bulk import rows. Read Migrating to rIDM first for what carries over in general.

Concept mapping

KeycloakrIDMNotes
RealmTenantIssuer changes from https://kc.example.com/realms/acme to https://id.example.com/t/acme (or a custom domain)
Client (OpenID Connect)Clientclient_id and redirect URIs carry over; secrets do not
Bearer-only client, or a client that only represents an APIResource serverIts clientId becomes the resource server identifier, so the aud your API checks can stay the same
Client scopeScopeA scope’s claims list names the user claims it releases at userinfo (and in the ID token for clients with id_token_scope_claims); is_default corresponds to a default client scope. Protocol mappers do not hang off scopes; they become tenant-wide or per-client claim mappers
Realm roleRole (no client)
Client roleRole with client set to the client’s client_idReferenced as client_id/name in composites and group roles
Client role on an API clientPermission on that resource server, granted to rolesrIDM separates “what the user may do in this API” (permissions, in the permissions claim) from role names
Composite roleRole composites
Group, subgroups, group attributesGroup with path, attributesSibling names are unique; the same name may appear under different parents
Group role mappingsGroup roles
User attributes, user profile (declarative)Profile schema and user attributesUndeclared attributes are refused unless allow_undeclared is on
Protocol mapperClaim mapperSee Protocol mappers
Identity provider (OpenID Connect, social)Identity provider (oidc or oauth2, presets for Google, Microsoft, GitHub, Apple, GitLab)First-broker-login flow becomes link_policy
Identity provider (SAML)SAML identity provider (saml)
User federation (LDAP)LDAP directory (ldap): bind authentication, periodic and on-sign-in sync, group mapping, edit_mode read_only or writableKeycloak’s UNSYNCED mode has no equivalent; users are always linked to the directory
User federation (Kerberos), Kerberos authenticatorKerberos provider (kerberos): SPNEGO with the service’s keytab, users matched by name or looked up in an LDAP providerAES tickets only (no RC4, no RFC 8009 types); no credential delegation
Required actionsSee Required actions
Authentication flowsTenant settings: auth, mfa, mfa_methods, lockout, captcha, registrationrIDM’s flow is fixed; its steps are switched on and off, not rearranged
Password policysettings.passwordSee Realm settings
Brute-force detectionsettings.lockout
Events, admin eventsAudit log and webhooks
Themes, email templatessettings.branding, message templatesTemplates are rewritten, not converted
Service account of a clientservice_account: true on the client
Token exchangeToken exchange (RFC 8693), per-client allowed_audiences
Offline access, offline sessionsThe offline_access scopeA refresh token granted offline_access outlives the SSO session’s timeouts (not an explicit sign-out); without it, it ends with the session. Each resource server can refuse it with allow_offline_access: false
Authorization services (resources, policies, permissions, UMA)Not supportedrIDM’s authorization model is roles granting permissions on resource servers; there is no policy engine or UMA
Custom SPIs (authenticators, mappers, storage)Not supportedRebuild the behaviour with claim mappers, webhooks or in the application
SAML clientsSAML service providersRegistered by hand or from metadata; signing keys are rIDM’s own, not the realm’s

Exporting a realm

Use the command-line export. The admin console’s partial export (Realm settings → Action → Partial export) leaves users out and replaces secrets with *; it is enough for drafting the tenant document but not for users. See Keycloak’s Importing and exporting realms.

# Keycloak 17 and later (Quarkus distribution). The server must not be running;
# give the export the same database options the server uses.
bin/kc.sh export --dir /tmp/kc-export --realm acme --users different_files --users-per-file 1000

This writes acme-realm.json and acme-users-0.json, acme-users-1.json, and so on. --users realm_file puts the users into the realm file instead; skip leaves them out. Users from a user-federation provider are written to acme-federated-users-<n>.json and have no password credential.

The export contains every user’s password hash and TOTP secret. Treat the files as secrets, and delete them when the migration is done.

Drafting the tenant document

This script turns acme-realm.json into a first draft of a ridm.tenant/1 document. It keeps OpenID Connect clients (skipping Keycloak’s built-in account, account-console, admin-cli, broker, realm-management and security-admin-console), turns bearer-only clients into resource servers and their roles into permissions, and carries realm roles, client roles, composites, groups and a few realm settings.

# keycloak-tenant.jq: Keycloak realm export -> draft rIDM tenant document.
# Usage: jq -f keycloak-tenant.jq acme-realm.json > acme.json
def builtin_clients: ["account", "account-console", "admin-cli", "broker",
                      "realm-management", "security-admin-console"];
def builtin_role: startswith("default-roles-") or . == "offline_access"
                  or . == "uma_authorization";
def uris: if . == null then [] else split("##") | map(select(. != "")) end;
def single: map_values(if type == "array" and length == 1 then .[0] else . end);

. as $realm
| (.clients // [] | map(select((.protocol // "openid-connect") == "openid-connect"
                              and ((.clientId | IN(builtin_clients[])) | not)))) as $all
| ($all | map(select(.bearerOnly != true))) as $clients
| ($all | map(select(.bearerOnly == true)) | map(.clientId)) as $apis
| ($clients | map(.clientId)) as $cids
# A role reference in rIDM's notation: "name" (realm) or "client_id/name".
| def refs: [ ((.realm // [])[] | select(builtin_role | not)),
              ((.client // {}) | to_entries[] | select(.key | IN($cids[]))
               | .key as $c | .value[] | "\($c)/\(.)") ];
# Keycloak roles on a bearer-only client become permissions on a resource server.
  def perms: [ (.client // {}) | to_entries[] | select(.key | IN($apis[]))
               | .key as $c | .value[] | "\($c)#\(.)" ];
  def role(client): { name, client: client, description,
                      composites: (.composites // {} | refs),
                      permissions: (.composites // {} | perms) };
  def groups(parent): .[] | (parent + [.name]) as $p
      | { path: $p, attributes: (.attributes // {} | single),
          roles: ({ realm: .realmRoles, client: .clientRoles } | refs) },
        (.subGroups // [] | groups($p));
{
  format: "ridm.tenant/1",
  tenant: {
    slug: .realm,
    display_name: (.displayName // .realm),
    settings: {
      session: ({ idle_timeout_secs: .ssoSessionIdleTimeout,
                  absolute_timeout_secs: .ssoSessionMaxLifespan,
                  access_token_ttl_secs: .accessTokenLifespan }
                | with_entries(select(.value != null))),
      registration: ({ enabled: .registrationAllowed,
                       require_email_verification: .verifyEmail }
                     | with_entries(select(.value != null)))
    }
  },
  resource_servers: [ $apis[] as $a | {
    identifier: $a, name: $a,
    permissions: [ $realm.roles.client[$a] // [] | .[] | { name, description } ] } ],
  clients: [ $clients[] | . as $c | {
    client_id: .clientId,
    name: (.name // .clientId | if startswith("${") then $c.clientId else . end),
    status: (if .enabled == false then "disabled" else "active" end),
    service_account: (.serviceAccountsEnabled // false),
    client_type: (if .publicClient then "spa"
                  elif .serviceAccountsEnabled and (.standardFlowEnabled == false) then "machine"
                  else "web" end),
    token_endpoint_auth_method: (if .publicClient then "none"
                                 elif .clientAuthenticatorType == "client-jwt" then "private_key_jwt"
                                 else "client_secret_basic" end),
    redirect_uris: [ (.redirectUris // [])[]
                     | if startswith("/") then ($c.rootUrl // "") + . else . end ],
    post_logout_redirect_uris: (.attributes["post.logout.redirect.uris"] | uris),
    allowed_grants: ([ if .standardFlowEnabled != false then "authorization_code", "refresh_token" else empty end,
                       if .serviceAccountsEnabled then "client_credentials" else empty end,
                       if .attributes["oauth2.device.authorization.grant.enabled"] == "true"
                       then "urn:ietf:params:oauth:grant-type:device_code" else empty end ]),
    require_consent: (.consentRequired // false),
    cors_origins: [ (.webOrigins // [])[] | select(. != "+" and . != "*") ],
    jwks_uri: (if .clientAuthenticatorType == "client-jwt" then .attributes["jwks.url"] else null end),
    backchannel_logout_uri: .attributes["backchannel.logout.url"]
  } | with_entries(select(.value != null)) ],
  roles: [ (.roles.realm // [])[] | select(.name | builtin_role | not) | role(null) ],
  groups: [ .groups // [] | groups([]) ]
}
| .roles += [ $all[] | select(.clientId | IN($cids[])) | .clientId as $c
              | ($realm.roles.client[$c] // [])[] | role($c) ]

Then review the draft, dry-run it, and apply it:

jq -f keycloak-tenant.jq /tmp/kc-export/acme-realm.json > acme.json
ridm --tenant acme tenant diff   -f acme.json      # the plan; nothing changes
ridm --tenant acme tenant import -f acme.json      # plan, confirm, apply

The script was checked against Keycloak’s own sample realm exports from its test suite and a synthetic realm; each generated client was run through rIDM’s client validation. What it does not do, and what to review by hand:

  • Wildcard redirect URIs (https://app.example.com/*) are copied as they are. rIDM accepts them as URLs but compares redirect URIs exactly, so a wildcard never matches. Replace each with the real callback URLs.
  • Relative redirect URIs are prefixed with the client’s rootUrl; a client without one fails its import with “is not a valid URL”.
  • Public clients become spa. Change mobile and desktop apps to native, which allows custom URL schemes and any loopback port.
  • webOrigins of + (“the origins of the redirect URIs”) and * are dropped; list the origins a browser client calls the token endpoint from in cors_origins.
  • Clients that only used the direct access grant (resource owner password) fail with “authorization_code clients need at least one redirect_uri”, or import without the grant. rIDM does not support the password grant or the implicit flow; such applications have to move to the authorization code flow with PKCE.
  • client-secret-jwt authentication has no equivalent; the script maps it to client_secret_basic. client-jwt becomes private_key_jwt with the client’s jwks.url; a client with an inline key needs its jwks added by hand.
  • Group role mappings on bearer-only clients are dropped, because a group holds roles, not permissions. Grant those permissions through a role and give the group the role.
  • Keycloak display names such as ${client_account} are replaced by the client id.
  • Client scopes, protocol mappers and identity providers are not converted; add them as shown below.
  • Settings in the document replace the tenant’s settings as a whole; add the password policy and the rest before applying (next section).

Realm settings

KeycloakrIDM (tenant.settings)
Password policy length(n), maxLength(n)password.min_length, password.max_length
upperCase, lowerCase, digits, specialCharspassword.require_uppercase, require_lowercase, require_digit, require_symbol (booleans: rIDM requires at least one, not a count)
passwordHistory(n)password.history
forceExpiredPasswordChange(days)password.max_age_days
notUsername, notEmailAlways on: a password may not contain the username or the email’s local part (when four characters or longer)
hashAlgorithm, hashIterationsDeployment-wide argon2id parameters, ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST (Server configuration)
SSO Session Idle / Maxsession.idle_timeout_secs, session.absolute_timeout_secs
Access Token Lifespansession.access_token_ttl_secs (clients and resource servers can override)
User registration, Verify emailregistration.enabled, registration.require_email_verification
Terms and conditions (required action)registration.require_terms, registration.terms_url
Brute force detection: max login failures, wait incrementlockout.max_failures, lockout.lock_minutes (a fixed lock, not an increasing one)
Internationalization, supported localeslocale
OTP policyFixed: TOTP, SHA-1, six digits, 30-second steps

See Tenants and tenant settings for every setting.

Required actions

Keycloak required actionrIDM
UPDATE_PASSWORDmust_change_password: true on the import row (the user script below sets it)
CONFIGURE_TOTPMFA policy required (tenant-wide) or required_for_roles; there is no per-user flag
UPDATE_PROFILEMark profile attributes required: a user missing one is asked for it at sign-in
TERMS_AND_CONDITIONSregistration.require_terms: every user who has not accepted, imported users included, is asked at sign-in
VERIFY_EMAILNo equivalent at sign-in. Imported users keep the email_verified they had
webauthn-registerPasskey enrolment at the MFA step (auth.passkey)

Do not mark an attribute required before importing users that lack it: user creation checks required attributes and the row fails.

Protocol mappers

Claim mappers cover the common Keycloak mapper types. Keycloak’s “Add to ID token”, “Add to access token” and “Add to userinfo” switches become include_in (id, access, userinfo).

Keycloak mapperrIDM mapper config
User Attribute, User Property{"type": "user_attribute", "attribute": "attributes.department", "claim": "department"} (user fields: username, email, email_verified, phone, locale, id, …; a bare name that is not a user field is read as the profile attribute of that name)
Group Membership (Full group path on/off){"type": "groups", "claim": "groups", "full_path": true}
User Realm Role{"type": "roles", "claim": "realm_roles"}
User Client Role{"type": "roles", "claim": "portal_roles", "client_id": "portal"} (only that client’s roles)
Hardcoded claim{"type": "hardcoded", "claim": "tier", "value": "gold"}
Audience{"type": "audience", "audience": "orders-api"}
Script mapper{"type": "template", "claim": "...", "template": "{{user.username}}@{{tenant.slug}}"} where a Handlebars template can express it; otherwise not supported

A claim name is used literally: rIDM does not build nested objects from dotted names, so Keycloak’s realm_access.roles and resource_access.<client>.roles cannot be reproduced. Every access token already carries the effective role names in roles, the groups in groups, and, for a resource server, the granted permissions. Point APIs at those claims (checking permissions is the better design; see Resource servers, scopes and permissions). Protected claims (iss, sub, aud, exp, acr, amr, scope, cnf, act and the rest) and permissions cannot be set by a mapper, and roles and groups only by a mapper of that type, which then replaces the built-in claim; a mapper that breaks these rules is refused when it is saved.

Keycloak’s “User Attribute” mappers that only copy an attribute under its own name need no mapper at all: list id_token, userinfo and access_token as needed in the attribute’s visible_in in the profile schema.

In the tenant document a mapper is {"name": ..., "client": <client_id or absent>, "config": {...}}:

"claim_mappers": [
  { "name": "department",
    "config": { "type": "user_attribute", "attribute": "attributes.department",
                "claim": "department", "include_in": ["id", "userinfo"] } },
  { "name": "portal-roles", "client": "portal",
    "config": { "type": "roles", "client_id": "portal", "claim": "portal_roles",
                "include_in": ["access", "id"] } }
]

Identity providers

A Keycloak OpenID Connect or social identity provider becomes an entry in identity_providers, without its client secret (set it after the import). Keycloak’s first-broker-login behaviour maps onto link_policy:

Keycloak first broker loginrIDM link_policy
Automatically link an existing account with a verified, matching emailverified_email
Review profile / confirm link, then link after re-authenticationexplicit (the user links from the account console)
Always create a new accountalways_new

Register rIDM’s callback, https://id.example.com/t/acme/broker/<alias>/callback, with the upstream provider; it replaces Keycloak’s /realms/acme/broker/<alias>/endpoint. Keycloak stores the links between users and upstream accounts (federatedIdentities), but rIDM has no import for them; with verified_email they are re-created at each user’s next brokered sign-in.

Converting users and password hashes

Keycloak stores a password credential as two JSON strings. credentialData names the algorithm and its parameters; secretData holds the salt and hash, both standard base64 (see Keycloak’s Pbkdf2PasswordHashProvider and Argon2PasswordHashProvider):

{
  "type": "password",
  "secretData": "{\"value\":\"gD0eGu4V…Vym0=\",\"salt\":\"oYHN6Zdt8/w/gqBDTSSwyQ==\",\"additionalParameters\":{}}",
  "credentialData": "{\"hashIterations\":600000,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}"
}

How each algorithm converts:

Keycloak algorithmKeycloak defaultrIDM formatConversion
pbkdf2-sha256600 000 iterations, 256-bit key (older versions: 27 500 iterations, 512-bit key)$pbkdf2-sha256$<iterations>$<salt>$<hash>Copy: rIDM decodes standard base64 with padding, and verifies with the key length it finds
pbkdf2-sha512210 000 iterations, 512-bit key$pbkdf2-sha512$<iterations>$<salt>$<hash>Copy
argon2argon2id, version 1.3, m=7168 KiB, t=5, p=1, 32-byte hash$argon2<type>$v=19$m=<memory>,t=<iterations>,p=<parallelism>$<salt>$<hash>PHC string; strip the base64 = padding. Version 1.0 becomes v=16; types i and d become $argon2i$ and $argon2d$
pbkdf2 (HMAC-SHA1)1 300 000 iterations (older versions: 27 500)noneNot supported; those users need a password reset
a custom hash providerNot supported unless it produces one of the formats rIDM accepts

Every one of these is upgraded to argon2id under the server’s parameters at the first successful sign-in. PBKDF2 iteration counts above 10 000 000 are refused.

The user script:

# keycloak-users.jq: Keycloak realm export (users) -> rIDM bulk user import rows.
# Usage: jq -s -f keycloak-users.jq acme-users-*.json > users.json
def nopad: gsub("="; "");
def phc:
  (.credentialData | fromjson) as $d
  | (.secretData | fromjson) as $s
  | ($d.additionalParameters // {}) as $p
  | if $d.algorithm == "pbkdf2-sha256" or $d.algorithm == "pbkdf2-sha512" then
      "$\($d.algorithm)$\($d.hashIterations)$\($s.salt)$\($s.value)"
    elif $d.algorithm == "argon2" then
      "$argon2\($p.type[0])$v=\(if $p.version[0] == "1.0" then 16 else 19 end)"
      + "$m=\($p.memory[0]),t=\($d.hashIterations),p=\($p.parallelism[0])"
      + "$\($s.salt | nopad)$\($s.value | nopad)"
    else null end;       # "pbkdf2" (HMAC-SHA1) and custom providers: no rIDM equivalent
[ .[] | (.users // [])[]
  | select(.serviceAccountClientId == null)          # service-account users stay behind
  | {
      username,
      email,
      email_verified: (.emailVerified // false),
      status: (if .enabled == false then "disabled" else "active" end),
      attributes: (
        { keycloak_id: .id, given_name: .firstName, family_name: .lastName }
        + ((.attributes // {}) | map_values(if length == 1 then .[0] else . end))
        | with_entries(select(.value != null))
      ),
      roles: [ (.realmRoles // [])[]
               | select(startswith("default-roles-") or . == "offline_access"
                        or . == "uma_authorization" | not) ],
      groups: [ (.groups // [])[] | split("/") | last ],
      password_hash: ([ (.credentials // [])[] | select(.type == "password") | phc ] | first),
      must_change_password: ((.requiredActions // []) | index("UPDATE_PASSWORD") != null)
    }
]

-s slurps every user file into one array, so the same command works for one file or many, and for a realm file exported with --users realm_file. A pbkdf2-sha256 user comes out as:

{
  "username": "alice",
  "email": "alice@example.com",
  "email_verified": true,
  "status": "active",
  "attributes": {
    "keycloak_id": "550d2e8a-06f3-40ac-b4a7-5f4a32cf4ae4",
    "given_name": "Alice",
    "family_name": "Example",
    "department": "Sales"
  },
  "roles": ["editor"],
  "groups": ["sales"],
  "password_hash": "$pbkdf2-sha256$600000$oYHN6Zdt8/w/gqBDTSSwyQ==$gD0eGu4VA0woP+PLJI6jA/O6NAvmLMxE3ioVRL0Vym0=",
  "must_change_password": false
}

Before importing:

  • Declare the attributes the rows carry (given_name, family_name, keycloak_id and every custom attribute) in the tenant’s profile schema, or turn on allow_undeclared; an undeclared attribute fails its row. given_name and family_name are among the claims the profile scope releases. keycloak_id is there to map the old sub (Keycloak’s sub is the user’s id); declare it editable_by: none so that nobody can change it after the import; see Subject identifiers.
  • Roles and groups must exist (import the tenant document first). The import assigns realm roles only; client roles in clientRoles are not carried by the script, because a user row cannot name a client role. Grant them through a group, or afterwards through the admin API. Groups are matched by name, not path: where two groups share a name under different parents, the row lands in one of them, so rename one or fix the membership afterwards.
  • Second factors (otp and webauthn credentials in the export) are not imported; see Second factors.

Then dry-run and import as described in the checklist.

The conversion was tested end to end: credentials generated by a Java program that calls the same JDK PBKDF2WithHmacSHA256/SHA512 and BouncyCastle Argon2BytesGenerator code paths Keycloak does (pbkdf2-sha256 at 600 000 and at 27 500 iterations with the older 512-bit key, pbkdf2-sha512, argon2id with Keycloak’s defaults, and argon2i with other parameters, for a password with non-ASCII characters), converted with the script above, parsed by rIDM’s bulk-import parser, and verified by rIDM’s password verifier, which accepted the right password, refused a wrong one, and asked for each hash to be upgraded.

From Auth0

An Auth0 tenant maps onto an rIDM tenant. This page gives the concept mapping, how to get users (with password hashes) and configuration out of Auth0, and jq scripts that turn them into rIDM’s bulk user import rows and a first draft of a tenant document. Read Migrating to rIDM first for what carries over in general.

Concept mapping

Auth0rIDMNotes
TenantTenantIssuer changes from https://acme.us.auth0.com/ (trailing slash) to https://id.example.com/t/acme, or https://login.acme.example with a custom domain (no trailing slash)
Application: Single PageClient spa
Application: Regular WebClient web
Application: NativeClient nativeCustom URL schemes and any loopback port allowed
Application: Machine to MachineClient machineSee Machine-to-machine applications
Device authorization flow on an applicationClient device, or the device grant on another type
APIResource serverThe API identifier becomes the resource server identifier, so aud stays the same
API permissions (scopes)Permissions on the resource server, and scopes linked to it
RBAC “Add Permissions in the Access Token”Always on: a token for a resource server carries the user’s permissions
Roles, role permissionsRoles with permissions (<API identifier>#<permission>)
User role assignmentsroles on each import rowNot in the user export; see Role assignments
user_metadata, app_metadataUser attributes, declared in the profile schema with editable_by: user, admin or nonenone values are set only by bulk import and SCIM
API setting “Allow Offline Access”Resource server allow_offline_accessWithout it, offline_access is dropped from grants for that API and refresh tokens end with the user’s session
Database connectionLocal users with passwords
Social connectionsIdentity providers: presets for Google, Microsoft, GitHub, Apple and GitLab; any other OpenID Connect or OAuth 2.0 provider configured by hand
Enterprise: OpenID Connect, Microsoft Entra ID (Azure AD)Identity provider (oidc, or the microsoft preset with your directory id in the issuer)
Enterprise: SAML, ADFSSAML identity provider (saml)
Enterprise: AD/LDAPLDAP directory (ldap)rIDM connects to the directory itself; there is no connector agent, so the directory’s network must be reachable from rIDM
AD/LDAP connector’s Kerberos (Windows integrated auth)Kerberos provider (kerberos), with the LDAP directory owning the usersrIDM validates the ticket itself with the service’s keytab; no agent
Passwordless connections (email, SMS)settings.auth.magic_link, email_otp, sms_otp
Custom database connection with “Import users to Auth0” (lazy migration)No equivalentrIDM cannot check a password against another system at sign-in
OrganizationsOrganizationsMembers, verified domains with auto-join, invitations, per-organization roles and administrators; the org_id claim
Rules, ActionsClaim mappers, where they only add claimsSee Rules and Actions
MFA (Guardian): one-time password, SMS, email, WebAuthnSecond factors: TOTP, SMS and email codes, passkeys; policy in settings.mfaPush notifications are not supported. Enrolments do not carry over
Adaptive MFARisk-based authenticationNew device, new country, impossible travel and failed-attempt velocity add to a score that can require a second factor or refuse the sign-in
Attack protection: brute-force, suspicious IP throttlingsettings.lockout (per user and per IP)
Attack protection: breached password detectionsettings.password.check_breachedChecks passwords as they are set, against a Have I Been Pwned compatible range API
Bot detectionsettings.captcha (Turnstile or hCaptcha)
Universal Login branding, email templatessettings.branding, message templatesTemplates are rewritten, not converted
Log streamsWebhooks and the audit log
Custom domainCustom domain

Auth0’s SDKs (auth0-spa-js, nextjs-auth0 and the rest) are written for Auth0’s own endpoint layout and features. Replace them with a generic OpenID Connect library that reads discovery from the new issuer. rIDM’s endpoints are all under the issuer: /authorize, /token, /userinfo, /end_session (instead of /oauth/token and /v2/logout); see HTTP endpoints.

Exporting users

Auth0 exports users with a bulk export job on the Management API (Bulk User Exports). The token needs the read:users scope. Omit connection_id to export every connection, which includes social users; the json format produces newline-delimited JSON.

A0=https://acme.us.auth0.com
curl -s -X POST "$A0/api/v2/jobs/users-exports" \
  -H "Authorization: Bearer $A0_TOKEN" -H "Content-Type: application/json" \
  -d '{
        "format": "json",
        "fields": [
          {"name": "user_id"}, {"name": "email"}, {"name": "email_verified"},
          {"name": "username"}, {"name": "name"}, {"name": "given_name"},
          {"name": "family_name"}, {"name": "blocked"},
          {"name": "user_metadata"}, {"name": "app_metadata"}
        ]
      }'
# -> {"id": "job_…", "status": "pending", …}

curl -s "$A0/api/v2/jobs/job_…" -H "Authorization: Bearer $A0_TOKEN" | jq '{status, location}'
# when status is "completed", fetch location promptly (the link is short-lived)
curl -s -o users.json.gz "<location>" && gunzip users.json.gz

Each line is one user:

{"user_id":"auth0|60425dc43519d90068f82973","email":"ada@example.com","email_verified":true,"given_name":"Ada","family_name":"Lovelace","name":"Ada Lovelace"}

Password hashes

The export job never includes password hashes. Auth0 releases them only through a support request, on paid plans, after an eligibility review; the file arrives PGP-encrypted (Export Password Hashes and MFA Secrets). Once decrypted it is newline-delimited JSON, one line per database-connection user, with bcrypt hashes:

{"_id":{"$oid":"60425dc43519d90068f82973"},"email_verified":true,"email":"ada@example.com","passwordHash":"$2b$10$194fndLu5YaiEbZPc24zcuRddSGBuQj/45xU7hVf9AZsmHCxxIocS","password_set_date":{"$date":"2021-03-05T16:40:36.528Z"},"tenant":"acme","connection":"Username-Password-Authentication","_tmp_is_unique":true}

Auth0’s own documentation does not publish this layout; the example follows what Auth0 support has delivered and what other migration guides describe (for example ZITADEL’s). Check your file against it. The $oid is the part of user_id after auth0|, which is how the two files join. bcrypt ($2a$, $2b$, $2y$, any cost) is accepted by rIDM as it is, and upgraded to argon2id at the first sign-in; no conversion is needed.

The same request can include MFA secrets. rIDM cannot import them; users enrol again (see Second factors).

Without the hash export, import users without passwords and let them recover their accounts, or use the gradual brokering route in the overview.

Role assignments

Neither export says which roles a user holds. Fetch the members of each role from the Management API (Get a role’s users) into lines of {"role": …, "user_id": …}:

curl -s "$A0/api/v2/roles?per_page=100" -H "Authorization: Bearer $A0_TOKEN" \
  | jq -r '.[] | "\(.id)\t\(.name)"' |
while IFS=$'\t' read -r id name; do
  from=""
  while :; do
    page=$(curl -s "$A0/api/v2/roles/$id/users?take=100${from:+&from=$from}" \
             -H "Authorization: Bearer $A0_TOKEN")
    echo "$page" | jq -c --arg role "$name" '.users[] | {role: $role, user_id}'
    from=$(echo "$page" | jq -r '.next // empty')
    [ -n "$from" ] || break
  done
done > role-members.json

This uses checkpoint pagination (take and from, answered with users and next). It was not run against a live Auth0 tenant; check the parameters against the Management API reference for your tenant, and per_page if you have more than 100 roles.

Converting users

# auth0-users.jq: Auth0 user export + password hash export -> rIDM bulk import rows.
# Usage:
#   jq -n --slurpfile pw passwords.json --slurpfile rm role-members.json \
#      -f auth0-users.jq users.json > import.json
# passwords.json: the NDJSON file from Auth0 support (use /dev/null if you have none)
# role-members.json: NDJSON lines {"role": "<name>", "user_id": "<Auth0 user_id>"}
#                    (use /dev/null if you do not use Auth0 roles)
($pw | map({ key: ._id["$oid"], value: .passwordHash }) | from_entries) as $hashes
| ($rm | group_by(.user_id) | map({ key: .[0].user_id, value: map(.role) }) | from_entries) as $roles
| [ inputs
    | {
        username: (.username // .email),
        email,
        email_verified: (.email_verified // false),
        status: (if .blocked == true then "disabled" else "active" end),
        attributes: ({ auth0_user_id: .user_id, given_name, family_name, name }
                     | with_entries(select(.value != null))),
        roles: ($roles[.user_id] // []),
        password_hash: $hashes[.user_id | sub("^auth0\\|"; "")]
      }
  ]

A database user with a hash comes out as:

{
  "username": "ada@example.com",
  "email": "ada@example.com",
  "email_verified": true,
  "status": "active",
  "attributes": {
    "auth0_user_id": "auth0|60425dc43519d90068f82973",
    "given_name": "Ada",
    "family_name": "Lovelace",
    "name": "Ada Lovelace"
  },
  "roles": ["Support"],
  "password_hash": "$2b$10$194fndLu5YaiEbZPc24zcuRddSGBuQj/45xU7hVf9AZsmHCxxIocS"
}

Notes on the conversion:

  • Usernames. Auth0 database connections usually have no username, so the email is used. rIDM lower-cases usernames.
  • Attributes. Declare auth0_user_id, given_name, family_name and name in the profile schema (or turn on allow_undeclared), otherwise every row fails with “unknown attribute”. given_name, family_name and name are among the claims the profile scope releases. auth0_user_id keeps the old sub (Auth0’s sub is the user_id) for applications that need to map it; declare it editable_by: none so that nobody can change it after the import. See Subject identifiers.
  • Metadata. The script leaves user_metadata and app_metadata out. To carry a field, add it to attributes in the script (for example department: .app_metadata.department) and declare it in the schema: editable_by: user for what was user metadata, admin (or none, when only imports and SCIM should ever set it) for what was app metadata. An attribute that should appear in tokens under its own name can list id_token, userinfo or access_token in its visible_in, with no claim mapper needed.
  • Social and enterprise users (a user_id such as google-oauth2|…) are imported without a password. Configure the provider in rIDM with link_policy: verified_email, and the first sign-in through it links the upstream account to the imported user (see Brokered users). A user whose email Auth0 did not verify will not be linked this way.
  • Duplicate emails. Auth0 allows one address in several connections; rIDM allows it once per tenant. The second row fails as a conflict; decide which account survives before importing.
  • Roles must exist in the tenant before the users are imported (apply the tenant document first).

The conversion was tested with bcrypt $2b$10$ hashes generated for a password with non-ASCII characters, joined and converted by the script above, then parsed by rIDM’s bulk-import parser and checked by rIDM’s password verifier: the right password was accepted and flagged for upgrade, a wrong one refused.

Split, dry-run and import as described in the checklist.

Exporting applications and APIs

The Auth0 Deploy CLI exports a tenant’s configuration as files (Using as a CLI). Use the directory format, which writes one JSON file per resource in the Management API’s shapes, and --export_ids so client ids are included:

a0deploy export -c config.json --format directory --output_folder auth0-export --export_ids

config.json holds AUTH0_DOMAIN, AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET of a machine-to-machine application authorised for the Management API (Authenticating with your tenant).

This script drafts a tenant document from the clients/, resource-servers/ and roles/ folders:

# auth0-tenant.jq: Auth0 Deploy CLI export (directory format) -> draft rIDM tenant document.
# Usage (from the export folder):
#   jq -n --arg slug acme --arg name "Acme" -f auth0-tenant.jq \
#      clients/*.json resource-servers/*.json roles/*.json > acme.json
def kind: input_filename | split("/") | .[-2];
def standard: ["openid", "profile", "email", "phone", "address", "offline_access"];
def ridm_grants: ["authorization_code", "refresh_token", "client_credentials",
                  "urn:ietf:params:oauth:grant-type:device_code"];
reduce inputs as $o ({}; .[$o | kind] += [$o])
| (.["resource-servers"] // [] | map(select(.identifier | endswith("/api/v2/") | not))) as $apis
| ([ $apis[] | (.scopes // [])[].value ] | unique) as $api_scopes
| {
    format: "ridm.tenant/1",
    tenant: { slug: $slug, display_name: $name },
    resource_servers: [ $apis[] | {
      identifier, name,
      token_ttl_secs: .token_lifetime,
      allow_offline_access: (.allow_offline_access // false),
      permissions: [ (.scopes // [])[] | { name: .value, description } ] } ],
    scopes: [ $apis[] | .identifier as $rs | (.scopes // [])[]
              | { name: .value, description, resource_server: $rs } ],
    roles: [ (.roles // [])[] | {
      name, description,
      permissions: [ (.permissions // [])[]
                     | "\(.resource_server_identifier)#\(.permission_name)" ] } ],
    clients: [ (.clients // [])[] | ({
        spa: "spa", regular_web: "web", native: "native", non_interactive: "machine"
      }[.app_type // "regular_web"]) as $type | select($type != null) | {
      client_id: (.client_id // (.name | ascii_downcase | gsub("[^a-z0-9._:-]+"; "-"))),
      name,
      client_type: $type,
      token_endpoint_auth_method: (.token_endpoint_auth_method // "client_secret_basic"),
      redirect_uris: (.callbacks // []),
      post_logout_redirect_uris: (.allowed_logout_urls // []),
      backchannel_logout_uri: (.oidc_backchannel_logout.backchannel_logout_urls // [] | first),
      cors_origins: ((.web_origins // []) + (.allowed_origins // []) | unique),
      allowed_grants: [ (.grant_types // [])[] | select(IN(ridm_grants[])) ],
      allowed_scopes: (if $type == "machine" then $api_scopes else standard + $api_scopes end)
    } | with_entries(select(.value != null)) ]
  }

Each Auth0 permission becomes both a permission on the resource server (what roles grant, and what the permissions claim carries) and a scope linked to it (what a client may request in scope, as it did with Auth0). The script was run on hand-written files in the Deploy CLI’s directory layout, and every generated client passed rIDM’s client validation; it has not been run on a real tenant’s export, so review the draft:

  • Grants other than the authorization code, refresh token, client credentials and device grants are dropped. rIDM does not support the implicit flow or the password grant (including Auth0’s password-realm); applications using them move to the authorization code flow with PKCE.
  • Wildcards in callback URLs (https://*.acme.example/callback) are copied as they are but never match, because rIDM compares redirect URIs exactly; list the real ones.
  • Applications without --export_ids get a client_id made from their name, and their users’ configuration must change to it. Other application types (the SSO integrations) are skipped.
  • The Management API (identifier ending in /api/v2/) is left out; rIDM’s own admin API is urn:ridm:admin and is never imported.
  • Secrets are not in the document. Each confidential client gets a new secret in the import report, shown once.
  • Offline access. The script copies each API’s allow_offline_access, defaulting to false as Auth0 does. In rIDM that setting decides whether refresh tokens for the API may outlive the user’s sign-in session (offline_access), so check it for every API whose clients keep users signed in for days.
  • Signing algorithm. Auth0 APIs set to RS256 need nothing: that is rIDM’s default. rIDM does not sign with HS256; an API that used it must move to an asymmetric algorithm (RS256, RS384, RS512, ES256 or EdDSA, settable per resource server as signing_alg).
  • Settings are not converted; add tenant.settings (password policy, MFA, lockout, registration, branding) before applying, because an import replaces the tenant’s settings as a whole. See Tenants and tenant settings.
cd auth0-export
jq -n --arg slug acme --arg name "Acme" -f ../auth0-tenant.jq \
   clients/*.json resource-servers/*.json roles/*.json > ../acme.json
cd ..
ridm --tenant acme tenant diff   -f acme.json
ridm --tenant acme tenant import -f acme.json

Machine-to-machine applications

Auth0 authorises a machine-to-machine application for an API with a client grant (the Deploy CLI’s grants/ folder: client_id, audience, scope). In rIDM the client’s allowed_audiences lists the APIs it may get tokens for, and the permissions in its tokens come from roles held by its service account. For each grant, set service_account: true and allowed_audiences on the client in the document, create a role holding the granted permissions, and assign the role to the service account user (svc-<client_id>) in the console or through the admin API. See Machine-to-machine access.

Rules and Actions

Rules and Actions are code; rIDM runs no customer code during sign-in. What they commonly do, and where it goes:

Rule or Action that…In rIDM
adds a namespaced claim from user or app metadata (api.idToken.setCustomClaim("https://acme.example/plan", …))A user_attribute claim mapper with the same claim name, so APIs keep reading it: {"type": "user_attribute", "attribute": "attributes.plan", "claim": "https://acme.example/plan", "include_in": ["id", "access"]}
adds the user’s roles to the tokenA roles mapper: {"type": "roles", "claim": "https://acme.example/roles", "include_in": ["access"]}. Access tokens also carry roles by default
adds a fixed valueA hardcoded mapper
builds a string from user fieldsA template mapper (Handlebars over user, tenant, client, roles, groups)
requires MFA for some userssettings.mfa: required_for_roles or required_for_admins
blocks sign-in by email domain, IP or countryregistration.allowed_email_domains for sign-up; IP rules for addresses; country is a risk signal, not a rule (adaptive authentication)
notifies another system after sign-in or sign-upWebhooks on login.* and user.* events (after the fact, not in the flow)
calls an external API to decide, enriches the profile from one, or redirects mid-flowNot supported

Claim names are free, except the protected claims (iss, sub, aud, exp, scope, acr, amr, cnf, act and the rest) and permissions, which no mapper may set, and roles and groups, which only a mapper of that type may set (replacing the built-in claim). A mapper that breaks these rules is refused when it is saved.