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-authcrate 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.
/authorizeacceptsresponse_type=codeonly, and the password never travels to an application. Discovery advertisesresponse_types_supported: ["code"]. - No
plainPKCE. A code challenge must useS256; 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
| Section | Read it for | Starts at |
|---|---|---|
| Concepts | The model: tenants, clients, roles, tokens, keys, sessions, and why they work the way they do | Tenants and issuers |
| Quickstarts | A running server and a first integration, one scenario per page | Run rIDM locally |
| Admin guide | Day-to-day administration through the consoles, the ridm CLI and the admin API | The admin and account consoles |
| Reference | Endpoints, the admin API, token claims, environment variables, the tenant document, errors | HTTP endpoints |
| Deployment | Running rIDM in production: containers, TLS, Postgres and Valkey, scaling, observability | Deployment overview |
| Migration | Moving users and configuration from another identity provider | Migrating 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:
| Endpoint | Path |
|---|---|
| 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
/healthzand/readyz, which a load balancer asks whatever host it uses; /.well-known/webfingerand/.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_accessandfeatures; - the built-in resource server
urn:ridm:admin, carrying the catalogue ofridm:*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-consoleandridm-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.
| Section | Governs |
|---|---|
password | length, character classes, history, expiry, breached-password check |
session | SSO idle and absolute timeouts, concurrent sessions, trusted-device lifetime, default token lifetimes |
mfa, mfa_methods | who must pass a second step, and which second-step methods are offered |
auth | which first-factor methods the login page offers (password, magic link, email or SMS code, passkey) |
registration | self-registration, email verification, terms, allowed email domains |
lockout, captcha, rate_limits | brute-force protection and request ceilings |
keys | signing algorithm, RSA key size, rotation interval and overlap |
locale, branding | languages offered, and the login page’s look |
dcr, discovery | dynamic client registration and WebFinger email domains |
notifications, audit, account | security notices to users, audit retention, self-service rights |
custom_domain, features | the 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_PROXYare 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
localhostor 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.
| Type | What it is | Default authentication | Default grants |
|---|---|---|---|
spa | Browser application; cannot keep a secret | none (public, PKCE) | authorization_code, refresh_token |
web | Server-side web application | client_secret_basic | authorization_code, refresh_token |
native | Mobile or desktop application | none (public, PKCE) | authorization_code, refresh_token |
machine | Service calling other services, no user | client_secret_basic | client_credentials |
device | TV, CLI or kiosk that cannot show a browser | none (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:
| Method | How the client proves itself |
|---|---|
client_secret_basic | its secret in an HTTP Basic Authorization header |
client_secret_post | its secret as client_secret in the form body |
private_key_jwt | a JWT assertion (RFC 7523) signed with a private key whose public half is registered as jwks or at jwks_uri |
none | nothing: 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
| Grant | Used for |
|---|---|
authorization_code | Signing a user in through the browser. Always with PKCE S256 for public clients, and by default for confidential ones too (require_pkce). |
refresh_token | Renewing tokens without sending the user back to the login page. Refresh tokens rotate on every use. |
client_credentials | A confidential client acting as itself. With a service account, it acts as a user that holds roles. |
urn:ietf:params:oauth:grant-type:device_code | Signing a user in on a device without a usable browser (RFC 8628). |
urn:ietf:params:oauth:grant-type:token-exchange | Trading 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.
Consent
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:
| Status | Meaning |
|---|---|
active | may sign in |
pending | created but not yet complete (for example, awaiting email verification) |
locked | temporarily locked after too many failed sign-ins (settings.lockout) |
disabled | switched off by an administrator; disabling ends the user’s sessions |
deleted | soft-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:
- roles assigned to the user directly;
- roles assigned to any group the user belongs to, or to any ancestor of those groups;
- 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:
| Role | Grants |
|---|---|
ridm:owner | everything, including creating, deleting and importing tenants |
ridm:admin | everything except creating, deleting and importing tenants |
ridm:user-manager | users, invitations and groups; read roles, tenant settings and audit |
ridm:client-manager | clients, scopes, claim mappers and resource servers; read roles, tenant settings and audit |
ridm:org-admin | organizations, their members, domains and invitations; read roles — granted inside one organization it administers that one |
ridm:viewer | every 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 with403. - 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 type | Adds |
|---|---|
user_attribute | a 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 |
groups | effective group names, or parent/child paths |
roles | effective role names: realm-wide ones, or only those belonging to one named client |
hardcoded | a fixed value |
template | a Handlebars template over user, tenant, client, roles and groups |
audience | an 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, andcnfandact, 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;rolesandgroups, except by a mapper of that type: arolesmapper targetingroles(or agroupsmapper targetinggroups) 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:
| Tenant | Organization | |
|---|---|---|
| Isolation | Complete: separate issuer, users, clients, keys, settings | None: one pool of users, clients and roles, shared settings |
| A user belongs to | exactly one | as many as you put them in |
| Sign-in | its own issuer and login pages | the tenant’s, with the organization chosen during the flow |
| Use it for | separate products or deployments, or customers who must never share anything | customers 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:writepermission. - Organizations carry no settings, branding or policy of their own. Anything of that kind is the tenant’s.
Where to look next
- Organizations (admin guide): the console and the admin API.
- Tokens and the claims reference
for
org_id. - Sign-in flows and sessions for the stage the picker sits in.
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:
| Concept | Question it answers | Who decides | Where it shows up |
|---|---|---|---|
| Audience | Which API is this token for? | the client asks, the client’s configuration allows | aud |
| Scope | What has the user let this application do on their behalf? | the client asks, the user consents | scope |
| Permission | What is this user allowed to do in this API? | administrators, through roles | permissions |
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
resourceparameters naming resource server identifiers, at/authorizeand 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=S256A
resourcethat is not a registered resource server, or that the client is not allowed, is refused withinvalid_target. On a refresh,resourcenarrows 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 isinvalid_target, and withoutresourcethe refresh repeats the original audiences. -
The client’s defaults. With no
resourceparameter, the token is issued for every audience in the client’sallowed_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:
| Identifier | Used by |
|---|---|
urn:ridm:admin | the admin API (/admin/...); its permissions are the ridm:* catalogue |
urn:ridm:account | the 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:
| Token | Format | For | Default lifetime |
|---|---|---|---|
| Access token | signed JWT, typ: at+jwt (RFC 9068); or opaque at_... for clients that ask for it | calling an API | 5 minutes |
| ID token | signed JWT, typ: JWT, optionally encrypted | telling the client who signed in | 5 minutes |
| Refresh token | opaque string starting rt_ | getting new tokens without the user | 30 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 theclient_idfor a client acting as itself),client_idandazp(the client it was issued to),tid(the tenant); - what for:
aud(the resource servers it is meant for),scope, andpermissionsfor those resource servers; - about the user:
roles,groups, profile attributes whose schema listsaccess_tokeninvisible_in, plus anything claim mappers add; - about the sign-in:
sid,auth_time,amr,acr; - the usual
iss,iat,nbf,expand a uniquejti.
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_audiencesdoes 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 isinvalid_grant; an unsupported token type isinvalid_request. - With an
actor_token, the result is a delegation: itsactclaim names the acting party (sub,client_id), nesting any earlieracton 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:
| Setting | Default | Meaning |
|---|---|---|
default_alg | RS256 | algorithm for new keys: RS256, RS384, RS512, ES256 or EdDSA (Ed25519) |
rsa_bits | 2048 | modulus size for new RSA keys: 2048, 3072 or 4096 |
rotation_interval_days | 90 | rotate the active key once it is this old (0 = never automatically) |
retire_overlap_hours | 24 | how 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:
| Status | Signs tokens | Published in JWKS |
|---|---|---|
pending | no | yes |
active | yes | yes |
retiring | no | yes, until its overlap ends |
revoked | no | no |
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:
| What | Where |
|---|---|
| Signing keys’ private halves | signing_keys |
| MFA secrets: authenticator app seeds, passkeys, recovery codes (hashed, then encrypted), email and SMS factor records | credentials |
| SMTP, SMS gateway and CAPTCHA credentials | tenant_provider_settings |
| Upstream identity provider client secrets | identity_providers |
| Webhook signing secrets | webhooks |
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:
- Roll out a new
MASTER_KEYwithMASTER_KEY_VERSIONincremented, keeping the old key inMASTER_KEY_PREVIOUSas<old version>=<old key>. New writes use the new generation; existing rows still decrypt. - Re-encrypt every row under the current generation:
ridm-api rotate-master-keyon any node, orridm master-key rotateover the admin API.ridm-api rotate-master-key --status(orridm master-key status) shows how many rows remain on each generation. - 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
- 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. - 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). - 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. - 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. - When the stage is
done, the state includes afinish_url. The browser follows it; rIDM issues the code and redirects to the client’sredirect_uriin 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_URLhas to point at them. - The pages hold nothing worth stealing. The authorization code is minted
by the API at
finishand 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
donewithout 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=Laxcookie withPath=/, named per tenant:__Host-ridm_session_{slug}(for example__Host-ridm_session_acme), orridm_session_{slug}whenCOOKIE_SECURE=false, for plain-http development. The__Host-prefix makes browsers insist onSecure,Path=/and noDomain, so no other host can set or overwrite the cookie; the slug in the name keeps tenants on one host apart, andPath=/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
amrandacralways 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 underrequired_for_roles, all send the user back to the owed step (the second factor or the password change) before any code is issued; underprompt=nonethe answer islogin_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’sactclaim, and ends after the tenant’simpersonation.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:
| Setting | Default | Meaning |
|---|---|---|
idle_timeout_secs | 1800 (30 minutes) | the session ends after this long without use; each use restarts the clock |
absolute_timeout_secs | 43200 (12 hours) | the session ends this long after it opened, however active |
max_concurrent | 0 (unlimited) | sessions one user may hold; opening one more revokes the oldest |
remember_device_days | 30 | lifetime 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 anid_token_hintthat matches the browser’s session, the session ends at once and the browser goes to the client’s registeredpost_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_urithat 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 onlyend_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 withrevoke_sessions, a recovery password reset, the user being disabled or deleted (by an administrator or through SCIM), and eviction undermax_concurrent. The logout URI must resolve to a public address (see Outbound requests). - Front-channel logout. Clients registered with a
frontchannel_logout_uriare 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:
| Setting | Default | Method |
|---|---|---|
password | on | username or email, and password |
magic_link | off | a one-time link sent by email |
email_otp | off | a six-digit code sent by email |
sms_otp | off | a six-digit code sent by text message |
passkey | off | a 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:
| Setting | Default | Method |
|---|---|---|
totp | on | an authenticator app (RFC 6238: SHA-1, six digits, 30-second steps, one step of drift either way; each code accepted once) |
email_otp | off | a six-digit code to the account’s verified email address |
sms_otp | off | a 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"]}:
mode | Who is asked |
|---|---|
off (default) | nobody, unless the application asks for it |
optional | users who have enrolled a second factor |
required | everyone; a user without a factor enrols one during this sign-in |
required_for_roles | holders of any listed role (directly, through a group, or through a composite); everyone else as optional |
required_for_admins | anyone 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:Value Meaning 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) isurn:ridm:acr:singlefor a one-factor session andurn:ridm:acr:mfaonce a second factor has passed. Both are advertised in discovery asacr_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_requiredunderprompt=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.passkeyon, 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:
| Kind | How the identity is proved |
|---|---|
oidc | an ID token, verified against the provider’s published keys |
oauth2 | the provider’s userinfo endpoint, read with the access token |
saml | a 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
- The user presses “Continue with Google”. The browser goes to
/t/{slug}/broker/{alias}/start?flow={id}, carrying the current login flow. - rIDM redirects to the provider’s authorization endpoint with a fresh
state(whose record in Valkey remembers the flow), anonceand a PKCE challenge, and sets a short-livedSameSite=Laxcookie binding the sign-in to this browser. - The provider returns the browser to
/t/{slug}/broker/{alias}/callback(GET, or POST for providers usingform_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 noLaxcookie). The callback signs in only the browser holding the binding cookie: a callback URL someone else obtained, opened in another browser, is refused withbroker_error=invalid_state(login CSRF). - rIDM redeems the code, authenticating as the provider’s
token_endpoint_auth_methodsays (client_secret_basic,client_secret_post, ornonefor PKCE alone). Foroidcproviders it verifies the ID token’s signature against the provider’s JWKS (cached for an hour, refetched once for an unknownkid), its issuer (a Microsoft provider configured with thecommon,organizationsorconsumersissuer accepts any directory’s), audience, expiry and nonce. Foroauth2providers it reads the userinfo endpoint (for GitHub, the primary verified address comes from/user/emails). - 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_policy | Existing account with that email | No such account |
|---|---|---|
verified_email | linked, if both the upstream provider and the local account have verified the address; otherwise refused | a new account is created |
explicit | refused; the user signs in the usual way and links the provider from the account console | a new account is created |
always_new | a new account is created, without the email address | a 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:
| Mapper | Default |
|---|---|
subject | sub (id for GitHub) |
username | preferred_username |
email | email |
email_verified | email_verified |
attributes | profile 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"]
}
]
}
| Section | Contains |
|---|---|
tenant | display name and the complete settings document |
profile_schema | the user profile attributes |
resource_servers | APIs and their permissions |
scopes | custom scopes, and changes to the standard ones |
clients | OAuth clients, including whether each has a service account |
roles | roles, their composites and their permission grants |
groups | the group tree and each group’s roles |
claim_mappers | tenant-wide and per-client claim mappers |
message_templates | email and SMS template overrides |
webhooks | webhook endpoints and the events they receive |
ip_rules | tenant-wide and per-client IP allow and deny rules |
identity_providers | upstream 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, theurn:ridm:adminandurn:ridm:accountresource 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:
| Field | Meaning |
|---|---|
id | unique id (a time-ordered UUID) |
tenant_id | the tenant, or null for deployment-wide events such as master-key rotation |
occurred_at | when it happened |
actor | who caused it: {"type": "user" | "admin" | "client", "id": ...} or {"type": "system"} |
impersonator | the administrator behind it, when it happened in a session they opened as the user (impersonation) |
ip, user_agent | the request’s client address and browser, when there was a request |
kind | what 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:
| Family | Examples |
|---|---|
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:
| Header | Meaning |
|---|---|
X-RIDM-Event | the event name |
X-RIDM-Delivery | the delivery id, the same across retries |
X-RIDM-Webhook | the webhook’s id |
X-RIDM-Timestamp | when this attempt was signed |
X-RIDM-Signature | t=<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
| Tool | Version | Why |
|---|---|---|
| Rust | 1.98 or later (rust-toolchain.toml pins 1.98.1) | the server and the ridm CLI |
| Node.js | 24 | the consoles and sign-in pages, served by next dev |
| Docker with Compose v2 | any recent | Postgres, Valkey and Mailpit |
curl, jq, python3, openssl | any | the 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.
| Port | What |
|---|---|
| 5432 | Postgres (RIDM_PG_PORT moves it) |
| 6379 | Valkey (RIDM_VALKEY_PORT) |
| 8025 | Mailpit web UI (RIDM_MAILPIT_UI_PORT); SMTP on 1025 (RIDM_MAILPIT_SMTP_PORT) |
| 8090 | rIDM API (PUBLIC_URL, BIND_ADDR) |
| 3110 | rIDM 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_KEYencrypts 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_URLis 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 behttp://localhost:8090/t/demo.UI_URLis where the sign-in pages and consoles are served. In development that isnext devon 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=falseis needed on plainhttp://; browsers dropSecurecookies there. The session cookie is thenridm_session_{slug}rather than the__Host-ridm_session_{slug}a secure deployment uses.BOOTSTRAP_ADMIN_EMAILandBOOTSTRAP_ADMIN_PASSWORDmust be set together. The password has to satisfy themastertenant’s policy (at least 12 characters by default).SMTP_FROMis required wheneverSMTP_HOSTis 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/.
| Page | URL |
|---|---|
| Admin console | http://localhost:3110/console/ |
| Account console | http://localhost:3110/account/?tenant=master |
| Mailpit | http://localhost:8025 |
| Swagger UI over the admin API | http://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:
- Open the account console at http://localhost:3110/account/?tenant=master,
signed in as
admin. - 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:readandridm:clients:write. A token can never carry more than its owner holds. - 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:
- creates the tenant
demo(“Example Orders Co.”) if it does not exist; - imports
examples/demo-tenant.jsonwithridm tenant import: the resource serverhttps://orders.examplewith the permissionsorders:readandorders:write, matching scopes, the rolesorders-readerandorders-manager, the public clientorders-spaand the confidential clientorders-web; - creates two users and gives each one role;
- prints the
orders-webclient 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.
| User | Password | Role | May |
|---|---|---|---|
dana | Demo-Passw0rd!2026 | orders-manager | read and place orders |
sam | Demo-Passw0rd!2026 | orders-reader | read 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
- Protect a Rust API with ridm-auth: the orders API is the finished version.
- Sign in from a single-page app and Sign in from a server-side web app: the two demo clients.
- Machine-to-machine access: a token with no user behind it.
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:
| Step | Request |
|---|---|
| resource server | POST /admin/tenants/acme/resource-servers with identifier, name |
| permission | POST /admin/tenants/acme/resource-servers/{rs}/permissions with name, description |
| role | POST /admin/tenants/acme/roles with name, description |
| grant a permission to a role | PUT /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:buildanddiscoverrefuse a validator without one, because it would accept a token minted for any API of the tenant..discover()readsjwks_urifrom{issuer}/.well-known/openid-configurationand 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 localhttp://issuer and for nothing else; anhttp://issuer or key set is refused otherwise.Guardvalidates once for a route subtree and refuses anything short of what it asks for:.permission,.scopeand.roleeach add a requirement (all must hold)..realmnames the realm in theWWW-Authenticatechallenge (defaultapi).RidmClaimshands a handler the verified claims. Behind a guard it reuses what the guard verified; on an unguarded route (/whoami) it validates theAuthorizationheader itself and asks for nothing more.OptionalRidmClaimsdoes the same for a route that also serves anonymous callers; a token that is presented must still be valid.
Every check ridm-auth makes:
| Check | Refused 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 token | invalid_token |
alg is asymmetric, allowed, and matches the published key | invalid_token |
iss is the configured issuer, aud contains the configured audience | invalid_token |
exp and nbf, with 60 seconds of leeway | invalid_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 for | insufficient_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
Validatorper 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
kidthe 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’sCorsLayer, allowing theAuthorizationheader), as the orders example does. - Permission is decided per route. Which rows a subject may see is still your
query’s job:
claims.subis 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:
| Variable | Default |
|---|---|
NEXT_PUBLIC_RIDM_ISSUER | http://localhost:8090/t/demo |
NEXT_PUBLIC_CLIENT_ID | orders-spa |
NEXT_PUBLIC_API_URL | http://localhost:8081 |
NEXT_PUBLIC_API_AUDIENCE | https://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
| Setting | Why |
|---|---|
--type spa | public: token endpoint auth method none, grants authorization_code and refresh_token, PKCE required |
--redirect-uri | compared 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-uri | where rIDM may send the browser after sign-out |
--cors-origin | an 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. |
--audience | the resource servers this client may get tokens for |
--scope | the scopes it may ask for; given none, a spa gets openid profile email phone address offline_access |
--no-consent | skip 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_methodmust beS256. rIDM refusesplain, and refuses a challenge with no method because RFC 7636 would then default toplain.resource(RFC 8707) says which API the access token is for, and must name a resource server in the client’sallowed_audiences. Without it, rIDM mints the token for every audience inallowed_audiences; with that list empty too, the token’saudis the client itself and no API will accept it. Always sendingresourcekeeps the audience explicit. (A scope that the tenant binds to a resource server, such asinvoices:readbound to the invoices API, also adds that API to the audience when requested.)offline_accessasks 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_accessonly when the client is allowed it and every API the token is for hasallow_offline_accesson; 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
| Symptom | Cause |
|---|---|
| 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 error | the app’s origin is not in the client’s cors_origins |
invalid_target | resource names a resource server the tenant does not have, or one outside the client’s allowed_audiences |
invalid_scope | a 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 refresh | the 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 message | the 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).
| Variable | Default | Meaning |
|---|---|---|
RIDM_ISSUER | none | https://{host}/t/{tenant} |
RIDM_CLIENT_ID, RIDM_CLIENT_SECRET | none | the client’s credentials |
BASE_URL | http://localhost:3200 | where the app is reachable; the redirect URIs derive from it |
API_URL, API_AUDIENCE | http://localhost:8081, https://orders.example | the API it calls |
SCOPES | openid profile email offline_access orders:read orders:write | |
RIDM_ALLOW_HTTP | false | trust an http:// issuer; development only |
BIND_ADDR | 127.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:
-
an
errorparameter means rIDM refused; showerror_description; -
statemust be one you issued and have not spent; take it out of the store as you read it; -
spend the code, authenticating with the secret in the
Authorizationheader (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/tokenThe answer carries
access_token,id_token,refresh_tokenandexpires_in. rIDM accepts only the authentication method registered for the client; the same secret sent asclient_secret_postisinvalid_client. -
verify the ID token’s signature and claims.
ridm-authdoes this with a validator configured for ID tokens: the audience is the client, and the JOSEtypisJWTrather thanat+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 } } -
open a session: store the tokens server-side, and give the browser only an opaque session id:
Set-Cookie: session=…; Path=/; HttpOnly; SameSite=Lax; SecureHttpOnlykeeps it away from scripts.SameSite=Laxstill sends it on the top-level redirect back from rIDM.Secureonce the app is on https. Keep the session’ssidclaim 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:
-
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/revokeRevocation answers
200whatever it finds; a failure must not stop the sign-out. -
delete the local session and clear the cookie (
Max-Age=0); -
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 setsgrace_secs, at most 30 days), so a rolling deploy does not lock the app out. See Registering clients. private_key_jwtreplaces 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"
}
| Parameter | Meaning |
|---|---|
-u client_id:secret | client_secret_basic: the credentials in the Authorization header. rIDM accepts only the method registered for the client. |
grant_type=client_credentials | refused for public clients (unauthorized_client) |
resource | RFC 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. |
scope | optional; 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
| Request | Effect |
|---|---|
PUT /admin/tenants/{slug}/clients/{client}/service-account | creates 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-account | unlinks 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 header | Value |
|---|---|
alg | an asymmetric algorithm; HMAC (HS256 …) is refused |
kid | the key’s id in the client’s key set (may be omitted when the set has exactly one key) |
iss, sub | both the client_id |
aud | the tenant’s token endpoint URL, or its issuer |
exp | at most 600 seconds after iat |
jti | unique; 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 /token | Cause |
|---|---|
401 invalid_client | wrong secret, a secret sent with a method other than the registered one, a retired secret, or a bad assertion |
400 unauthorized_client | the client is public, or not allowed client_credentials |
400 invalid_scope | a 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_target | resource 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,vaultorregistry. 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.
| Keys | Does |
|---|---|
Ctrl K / ⌘ K, / | Search |
t | Tenant switcher (global administrators) |
? | The shortcut list |
g then o | Overview |
g then u / g / z / r | Users / Groups / Organizations / Roles |
g then c / b / a / p / m | Clients / SAML / Resource servers / Scopes / Claim mappers |
g then k / l / i / n | Signing keys / Audit log / IP rules / Client certificates |
g then w / e / d / v | Webhooks / Messaging / Identity providers / Provisioning |
g then t / s / f / x | Tenants / 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
| Page | Path | Needs | Covered in |
|---|---|---|---|
| Overview | /console/ | any admin permission | Sign-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:read | Users, invitations and bulk import |
| Groups, Roles | /console/groups/, /console/roles/ | ridm:groups:read, ridm:roles:read | Users, groups and roles |
| Organizations | /console/organizations/ | ridm:orgs:read | Organizations |
| Clients | /console/clients/ | ridm:clients:read | Registering 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 page | Runs 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 permission | Resource servers, scopes and permissions |
| Signing keys | /console/keys/ | ridm:keys:read | Rotating keys |
| Audit log | /console/audit/ | ridm:audit:read | Webhooks and the audit log |
| IP rules | /console/ip-rules/ | ridm:tenants:read | Rate limits, IP rules and CAPTCHA |
| Client certificates | /console/client-certificates/ | ridm:tenants:read | Mutual TLS |
| Webhooks | /console/webhooks/ | ridm:webhooks:read | Webhooks and the audit log |
| Messaging | /console/messaging/ | ridm:messaging:read | Email, SMS and templates |
| Identity providers | /console/identity-providers/ | ridm:idps:read | Identity brokering |
| Provisioning | /console/provisioning/ | ridm:scim:read | SCIM provisioning |
| Tenants | /console/tenants/ | ridm:tenants:read | Tenants and tenant settings |
| Settings | /console/settings/ | ridm:tenants:read | Tenants and tenant settings |
| Feature flags | /console/features/ | ridm:tenants:read | Feature flags |
| Export & import | /console/config/ | ridm:tenants:export | Configuration 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.
| Page | What the user can do |
|---|---|
| Profile | Edit 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 |
| Security | See 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 |
| Applications | See 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 |
| Requests | Approve or deny the backchannel sign-in requests applications sent them; the page refreshes itself while open |
| Your data | Download 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:
| Setting | Default | Effect |
|---|---|---|
self_deletion | true | Whether users may delete their own account |
deletion_retention_days | 30 | How long a deleted account is kept before it is purged |
personal_tokens | true | Whether users may mint personal access tokens |
personal_token_max_days | 365 | Longest 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.
| Flag | Environment | Default | Meaning |
|---|---|---|---|
--profile NAME | RIDM_PROFILE | the selected profile, else default | Stored profile to use |
--url URL | RIDM_URL | the profile’s | Server origin, such as https://id.example.com (a trailing slash is dropped) |
--tenant SLUG | RIDM_TENANT | the profile’s, else master | Tenant the command acts on |
--token TOKEN | RIDM_TOKEN | the profile’s credential | Bearer token to present; never written to disk |
--output text|json | — | text | json 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:
$RIDM_CONFIG, when set;- else
$XDG_CONFIG_HOME/ridm/config.json; - 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:
| Route | Command | Use it for |
|---|---|---|
Personal access token (rpat_…) | ridm login --url … and paste, or --token-stdin | Interactive use and CI; nothing to register |
| Client credentials | ridm login --url … --client-id ID --client-secret-stdin | Automation acting as a machine client’s service account |
| Device authorization grant | ridm login --url … --client-id ID --device | An 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_TOKENand skiplogin. - Client credentials. The client needs the
client_credentialsgrant,urn:ridm:adminin itsallowed_audiences, and a service account holding admin roles. With--client-idand neither--client-secret-stdinnor--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_codeand listurn:ridm:adminin 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 adeviceclient 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
| Command | Flags | Does |
|---|---|---|
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 | --all | Forget 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.
| Flag | Meaning |
|---|---|
--email EMAIL | Administrator’s email (else BOOTSTRAP_ADMIN_EMAIL, else prompted) |
--username NAME | Username (else BOOTSTRAP_ADMIN_USERNAME, else admin) |
--password-stdin | Read the password from stdin (else BOOTSTRAP_ADMIN_PASSWORD, else prompted twice) |
--no-must-change | Let the administrator keep this password past the first sign-in |
--no-migrate | Do not apply database migrations first |
tenant
[SLUG] defaults to the tenant from --tenant or the profile.
| Command | Flags | API call |
|---|---|---|
ridm tenant list | --limit N page size | GET /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, --yes | POST /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-code | POST /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
| Command | Flags | API call |
|---|---|---|
ridm key list | --status pending|active|retiring|revoked | GET /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, --yes | POST /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
| Command | Flags | API call |
|---|---|---|
ridm audit verify | --head HEX the hash the chain must end on; --global the global chain | GET /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 stdin | none: the file is checked here, trusting nothing else |
ridm audit export | -o, --out FILE (default: stdout); --format json|csv; --from, --to RFC 3339 bounds; --global | GET /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
| Command | Flags | API 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-password | POST /admin/tenants/{slug}/users |
ridm user reset USER | --password-stdin, --no-must-change, --skip-policy, --notify, --revoke-sessions | PUT /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
| Command | Flags | API 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-consent | POST /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
| Code | Meaning |
|---|---|
0 | Success |
1 | The command ran and failed: the API refused it, the network failed, an import reported item errors |
2 | The 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 |
3 | ridm 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
masterholdsridm:owner, it changes nothing and says so. - The password must satisfy the
mastertenant’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-changeis 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.
| Where | How |
|---|---|
| Console | Tenants (/console/tenants/) → New tenant; you land on the new tenant’s settings |
| CLI | ridm tenant create acme --name "Acme Corp" |
| API | POST /admin/tenants |
POST /admin/tenants
Authorization: Bearer rpat_…
Content-Type: application/json
{ "slug": "acme", "display_name": "Acme Corp", "settings": { "registration": { "enabled": true } } }
slugis 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_nameis 1–255 characters; it is shown on the login pages and in the console.settingsis optional: anything left out takes the defaults below.data_regionis optional: on a deployment with data regions, the region whose database keeps the tenant’s data (GET /admin/regionslists them; absent orhome: the home database). It is fixed at creation;ridm-api move-tenantmoves 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
| Task | Console | API | Permission |
|---|---|---|---|
| List tenants | Tenants | GET /admin/tenants?cursor=&limit= | ridm:tenants:read; tenant administrators see only their own |
| Read a tenant | Settings | GET /admin/tenants/{slug} | ridm:tenants:read |
| Rename, disable, change settings | Settings | PATCH /admin/tenants/{slug} | ridm:tenants:write |
| Delete | Settings → delete zone (global owners) | DELETE /admin/tenants/{slug} | ridm:tenants:delete, global |
| Export / import configuration | Export & import | GET …/export, POST …/import | ridm: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:
| Key | Governs | Details |
|---|---|---|
password | Password rules and history | below |
session | Browser sessions, trusted devices, default token lifetimes | below |
mfa | When a second factor is demanded | MFA policy |
mfa_methods | Which second factors users may enrol | MFA policy |
risk | Scoring sign-ins, and what an unusual one costs | Adaptive authentication |
impersonation | Whether administrators may sign in as users, and for how long | Impersonation |
auth | Which first-factor sign-in methods are offered | below |
registration | Self-registration | below |
lockout | Brute-force lockout | below |
captcha | When a CAPTCHA is demanded | Rate limits, IP rules and CAPTCHA |
rate_limits | Request ceilings on the OAuth and sign-in endpoints | Rate limits, IP rules and CAPTCHA |
notifications | Security notices to users | below |
account | What users may do in the account console | below |
locale | Languages of the login pages | below |
branding | Look of the login pages | below |
keys | Signing key algorithm and rotation | below |
discovery | WebFinger issuer discovery | below |
dcr | Dynamic client registration: disabled, open or initial_access_token | Registering clients |
audit | Audit log retention | below |
custom_domain | The tenant’s own issuer host | Custom domains |
features | Feature flags applications read | Feature flags |
password
| Key | Default | Meaning |
|---|---|---|
min_length | 12 | Minimum characters |
max_length | 128 | Maximum characters |
require_uppercase | false | Needs an uppercase letter |
require_lowercase | false | Needs a lowercase letter |
require_digit | false | Needs a digit |
require_symbol | false | Needs a character that is neither alphanumeric nor whitespace |
history | 5 | A new password must differ from this many previous ones, the current one included (0 off) |
max_age_days | null | Days until a password expires and must be changed (null never) |
check_breached | false | Refuse 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
| Key | Default | Meaning |
|---|---|---|
idle_timeout_secs | 1800 (30 min) | A browser session ends after this long without use |
absolute_timeout_secs | 43200 (12 h) | A session ends this long after sign-in, whatever its use |
max_concurrent | 0 | Live sessions per user; the oldest is signed out first, with back-channel logout to its clients (0 unlimited) |
remember_device_days | 30 | How long a “remember this device” cookie skips the second factor |
access_token_ttl_secs | 300 | Default access token lifetime |
refresh_token_ttl_secs | 2592000 (30 days) | Default refresh token lifetime |
id_token_ttl_secs | 300 | Default 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.
| Key | Default | Meaning |
|---|---|---|
password | true | Username or email and password |
magic_link | false | A sign-in link by email |
email_otp | false | A one-time code by email |
sms_otp | false | A one-time code by text message (needs an SMS gateway, see Email, SMS and templates) |
passkey | false | Passwordless sign-in with a passkey; also enables passkeys as a second factor |
registration
| Key | Default | Meaning |
|---|---|---|
enabled | false | Show “create an account” and accept self-registration |
require_email_verification | true | New accounts stay pending until the emailed link is followed |
require_terms | false | Registrants must accept the terms |
terms_url | null | Terms of service link, shown to registrants and on the terms step |
privacy_url | null | Privacy 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
| Key | Default | Meaning |
|---|---|---|
max_failures | 10 | Consecutive failed sign-ins before the account is locked temporarily (0 off) |
lock_minutes | 15 | How long that lock lasts |
ip_max_failures | 100 | Failures from one address within the window before it is throttled (0 off) |
ip_window_minutes | 15 | That 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.
| Key | Sent when |
|---|---|
new_device | Someone signs in from a device not seen before |
password_changed | The password changes |
mfa_changed | A second factor is added or removed |
email_changed | The 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.
| Key | Default | Meaning |
|---|---|---|
self_deletion | true | Users may delete their own account |
deletion_retention_days | 30 | Days a deleted account (by the user or an administrator) is kept before the daily purge removes it |
personal_tokens | true | Users may mint personal access tokens |
personal_token_max_days | 365 | Longest, and default, personal access token lifetime (0 no limit) |
locale
| Key | Default | Meaning |
|---|---|---|
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.
| Key | Default | Meaning |
|---|---|---|
logo_url | null | Logo on the login pages |
favicon_url | null | Browser tab icon |
primary_color | null | The 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_color | null | The 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_color | null | Page 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_switch | true | Show a light/dark switch so people can override mode on their device |
support_url | null | “Need help?” link |
headline | null | One 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 |
tagline | null | A sentence under the headline (at most 240 characters) |
custom_css | null | Stylesheet 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
| Key | Default | Meaning |
|---|---|---|
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_days | 90 | Rotate the active key automatically after this many days (0 only on demand) |
retire_overlap_hours | 24 | How long a retired key stays published so tokens it signed still verify |
See Rotating keys.
discovery
| Key | Default | Meaning |
|---|---|---|
email_domains | [] | WebFinger acct:user@<domain> lookups for these domains resolve to this tenant’s issuer |
audit
| Key | Default | Meaning |
|---|---|---|
retention_days | 365 | Days 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:
| Configuration | Endpoints | Page |
|---|---|---|
| CAPTCHA provider (site key, secret) | GET/PUT/DELETE /admin/tenants/{slug}/captcha | Rate limits, IP rules and CAPTCHA |
| Email and SMS providers, templates | /admin/tenants/{slug}/messaging/… | Email, SMS and templates |
| User profile schema | GET/PUT /admin/tenants/{slug}/profile-schema | Users |
| IP allow and deny rules | /admin/tenants/{slug}/ip-rules | Rate 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:
| Collection | Per tenant |
|---|---|
| Groups | 10,000 |
| Roles | 5,000 |
| Scopes | 1,000 |
| Resource servers | 1,000 |
| IP rules | 1,000 |
| Webhooks | 100 |
| SCIM tokens (live) | 100 |
| Trusted certificate authorities (mutual TLS) | 50 |
| Feature flags | 200 |
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
| Parameter | Meaning |
|---|---|
search | Case-insensitive prefix of the username or email |
status | active, disabled, locked, pending or deleted |
password_algo | Only 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_deleted | Include soft-deleted users (default false) |
cursor, limit | Paging: 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
| Where | How |
|---|---|
| Console | Users → New user: a temporary password revealed once, a chosen password, or none |
| CLI | ridm --tenant acme user create alice --email alice@example.com --temporary-password |
| API | POST /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
}
| Field | Default | Notes |
|---|---|---|
username | required | Trimmed and lower-cased; 1–255 characters, no whitespace; unique in the tenant |
email | null | Lower-cased; unique in the tenant |
email_verified | false | |
phone | null | E.164, such as +14155550100 |
phone_verified | false | |
status | active | active, disabled or pending; locked and deleted are refused with 400 |
attributes | {} | Profile attributes, validated against the profile schema |
locale | null | The user’s language, used when the request names none |
external_id | null | The 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_password | false | Generate 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.
| Task | Console (user page header) | API |
|---|---|---|
| Disable | Disable | PATCH …/users/{user} with {"status": "disabled"}; every session ends at once, with back-channel logout |
| Re-enable | Enable | PATCH with {"status": "active"} |
| Clear a lockout | Unlock | POST …/users/{user}/unlock: resets the failure counter and the locked status |
| Delete | Delete | DELETE …/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 }
| Field | Default | Meaning |
|---|---|---|
password | absent | Absent: generate a temporary password, returned once as temporary_password |
must_change | false | Force a change at the next sign-in (always on for a temporary password) |
skip_policy | false | Accept a password the policy and history would refuse |
notify | false | Tell the user their password changed |
revoke_sessions | false | End 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:
| Route | Does |
|---|---|
GET …/users/{user}/sessions | Live SSO sessions |
DELETE …/users/{user}/sessions | End every session (answers {"revoked": n}) |
DELETE …/users/{user}/sessions/{session_id} | End one |
GET …/users/{user}/devices | Trusted devices (“remember this device”) |
DELETE …/users/{user}/devices[/{device_id}] | Revoke all, or one: the second factor is asked again |
GET …/users/{user}/consents | Scopes 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.
| Where | How |
|---|---|
| Console | Users → Invite; open invitations under Users → ?view=invitations, with resend and revoke |
| API | POST /admin/tenants/{slug}/invitations (ridm:invitations:write) |
{ "email": "bob@example.com", "roles": ["<role id>"], "groups": ["<group id>"], "expires_days": 14 }
expires_daysdefaults 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}/resendissues a new token and expiry and emails it again; the previous link stops working.DELETE …/invitations/{id}revokes it. GET …/invitations?open_only=truelists 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
}
]
}
| Field | Default | Meaning |
|---|---|---|
name | required | ^[a-zA-Z][a-zA-Z0-9_]{0,63}$, unique |
type | string | string, number, boolean, email, url (http or https), phone, date (YYYY-MM-DD), enum, json |
label, description | null | Shown in forms |
required | false | Must be present and non-empty |
multivalued | false | An array of values of the type |
editable_by | user | user (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) |
order | 0 | Position 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/jsonortext/csv; anything else is refused.- At most 10 000 rows and 32 MiB per request.
?dry_run=truevalidates 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" }
]
| Field | Default | Meaning |
|---|---|---|
username | required | As for a created user |
email, email_verified, phone, phone_verified, locale, attributes | as for a created user | |
status | active | active, 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_password | false | Force 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,yesory(any case), false otherwise. rolesandgroupsare 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:
| Format | Example | Notes |
|---|---|---|
| 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
| Where | How |
|---|---|
| Console | Clients (/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 |
| CLI | ridm --tenant acme client create --name "Acme SPA" --type spa --redirect-uri https://app.acme.example/callback |
| API | POST /admin/tenants/{slug}/clients |
| Configuration as code | A clients entry in the tenant document |
| The client itself | Dynamic 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.
| Type | For | Auth method | Grants | PKCE | Scopes |
|---|---|---|---|---|---|
spa | Browser apps | none (public) | authorization_code, refresh_token | required | standard scopes |
web (default) | Server-side web apps | client_secret_basic | authorization_code, refresh_token | required | standard scopes |
native | Mobile and desktop apps | none (public) | authorization_code, refresh_token | required | standard scopes |
machine | Service-to-service | client_secret_basic | client_credentials | not required | none |
device | TVs, CLIs, kiosks | none (public) | device code, refresh_token | required | standard 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
| Field | Default | Notes |
|---|---|---|
client_id | generated | 1–128 characters of A-Z a-z 0-9 . _ : -, starting alphanumeric; cannot change later |
name | required | 1–255 characters; shown on the consent screen |
client_type | web | See the table above |
description | null | For administrators |
logo_uri, client_uri, tos_uri, policy_uri | null | Shown on the consent screen and in the account console’s application list |
status | active | PATCH only: disabled stops the client from getting tokens |
Authentication
| Field | Default | Notes |
|---|---|---|
token_endpoint_auth_method | by type | none, 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_email | null | For tls_client_auth, exactly one: the name its certificate must carry. See Mutual TLS |
jwks | null | Inline JWK set ({"keys": [...]}) for private_key_jwt, signed request objects and ID token encryption |
jwks_uri | null | The 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
| Field | Default | Notes |
|---|---|---|
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_uri | null | Receives a signed logout token when a session the client took part in ends |
frontchannel_logout_uri | null | Loaded 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_uri | null | Where 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
| Field | Default | Notes |
|---|---|---|
allowed_grants | by type | Any 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_mode | poll with the CIBA grant | poll or ping; only with the CIBA grant |
backchannel_client_notification_endpoint | null | Where a ping client is told; required in ping mode |
allowed_scopes | by type | Scopes 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.
Consent and PKCE
| Field | Default | Notes |
|---|---|---|
require_consent | true | Show the consent screen. Set false for first-party applications |
require_pkce | by type | Only meaningful for confidential clients; public clients always need PKCE |
Token lifetimes and format
| Field | Default | Notes |
|---|---|---|
access_token_ttl_secs | null | Overrides the tenant’s session.access_token_ttl_secs (default 300) |
refresh_token_ttl_secs | null | Overrides session.refresh_token_ttl_secs (default 30 days) |
id_token_ttl_secs | null | Overrides session.id_token_ttl_secs (default 300) |
access_token_format | jwt | jwt, or opaque: see Opaque access tokens. The built-in console clients refuse opaque |
subject_type | public | pairwise gives each client (or sector) its own sub for a user |
sector_identifier_uri | null | Groups clients that should share pairwise subjects |
id_token_scope_claims | false | Also 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_encryption | null | {"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, soridm-auth, which validates JWTs only, does not accept opaque tokens. - rIDM’s own endpoints accept them:
/userinfo,/introspect,/revoke, token exchange (asurn: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
| Field | Default | Notes |
|---|---|---|
dpop_bound_access_tokens | false (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_tokens | false | Every 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_requests | false | /authorize refuses the client’s requests unless they came through PAR (RFC 9126 §6) |
security_profile | none | fapi2 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
issandsubequal to theclient_id; - has
audequal to the token endpoint URL or the tenant’s issuer; - carries
expand ajti, lives no more than 600 seconds, and is not replayed (eachjtiis 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.
| Key | Default | Meaning |
|---|---|---|
mode | disabled | disabled: 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_pkce | true | Whether 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_methoddefaults toclient_secret_basic;none,client_secret_postandprivate_key_jwtare accepted.grant_typesdefaults to["authorization_code"]; only thecoderesponse type is supported.- The type is
nativeforapplication_type: "native", else derived from the grants and auth method (device,machine,spaorweb). scopesets the allowed scopes; audiences, CORS origins and token lifetimes cannot be registered.- Every registered client gets
require_consent: true. require_pushed_authorization_requestsis stored and enforced.backchannel_token_delivery_mode(pollorping) andbackchannel_client_notification_endpointregister a CIBA client; signed backchannel requests and user codes are refused.security_profilecannot 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:
| Route | Permission | Does |
|---|---|---|
GET /admin/tenants/{slug}/dcr/initial-access-tokens | ridm:clients:read | List the tenant’s tokens, newest first, never their values |
POST /admin/tenants/{slug}/dcr/initial-access-tokens | ridm:clients:write | Issue a token; answers 201 with the record and token, shown this once |
DELETE /admin/tenants/{slug}/dcr/initial-access-tokens/{id} | ridm:clients:write | Revoke it (204) |
The request body is optional in every field:
| Field | Default | Rules |
|---|---|---|
description | null | What the token is for; at most 200 characters |
expires_in_secs | no expiry | 1 second to ten years |
max_uses | no limit | Registrations 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.
| Permission | Allows |
|---|---|
ridm:tenants:read | View tenant settings, branding, feature flags, IP rules, CAPTCHA configuration, profile schema, dashboard statistics |
ridm:tenants:write | Change tenant settings, branding, feature flags, IP rules, CAPTCHA configuration, profile schema |
ridm:tenants:create | Create tenants (global administrators only) |
ridm:tenants:delete | Delete tenants (global administrators only) |
ridm:tenants:export | Export tenant configuration |
ridm:tenants:import | Import tenant configuration |
ridm:users:read | View users, their sessions, credentials, devices, tokens, consents, linked identities |
ridm:users:write | Create, change, disable and delete users; manage their sessions, credentials, devices, tokens, consents, roles and groups |
ridm:users:impersonate | Sign in as a user where the tenant allows it (impersonation) |
ridm:invitations:read | View invitations |
ridm:invitations:write | Create, resend and revoke invitations; bulk import users |
ridm:groups:read / write | View groups and members / create, change, delete groups and manage membership |
ridm:roles:read / write | View roles, composites and assignments / create, change, delete roles and composites |
ridm:clients:read / write | View OAuth clients / create, change, delete them; generate and rotate secrets |
ridm:scopes:read / write | View / manage scopes |
ridm:mappers:read / write | View / manage claim mappers |
ridm:resource-servers:read / write | View / manage resource servers and their permissions; grant permissions to roles |
ridm:idps:read / write | View / manage identity providers |
ridm:keys:read | View signing keys and master-key rotation status |
ridm:keys:write | Create, rotate, activate, retire and revoke signing keys; re-encrypt under the master key |
ridm:messaging:read / write | View / change messaging settings and templates; send test messages |
ridm:audit:read | View and export the audit log |
ridm:webhooks:read / write | View / manage webhooks; redeliver events |
ridm:scim:read / write | View / 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.
| Role | Holds |
|---|---|
ridm:owner | Every permission, including tenant lifecycle |
ridm:admin | Every permission except ridm:tenants:create, ridm:tenants:delete, ridm:tenants:import and ridm:users:impersonate |
ridm:user-manager | ridm: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-admin | ridm:orgs:read, ridm:orgs:write, ridm:invitations:read, ridm:invitations:write, ridm:roles:read — meant to be granted inside one organization |
ridm:client-manager | ridm: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:viewer | Every *: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
mastertenant 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:admininaud. The token endpoint only puts it there for clients that list it inallowed_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
masteruser’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(defaulttrue) switches minting off for a tenant;settings.account.personal_token_max_days(default365,0for no limit) caps the lifetime and is the default when none is asked for.last_used_atis recorded at most once a minute. Administrators list and revoke a user’s tokens withGET /admin/tenants/{slug}/users/{user}/patsandDELETE …/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 raisepersonal_token.createdandpersonal_token.revoked; introspection reports a live token astoken_type: personal_access_tokenwith 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:
- Register a
machineclient (client credentials) withurn:ridm:admininallowed_audiences. - Create its service account:
PUT /admin/tenants/{slug}/clients/{client}/service-account. This makes a user namedsvc-<client_id>thatclient_credentialstokens are issued for. - Assign that user the roles it needs, such as
ridm:vieweror a custom role. - Request tokens with
grant_type=client_credentialsandresource=urn:ridm:admin, or runridm 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:
| Field | Notes |
|---|---|
| Name | shown to members on the login page’s picker |
| Slug | lowercase letters, digits and hyphens. Clients may name it in an organization request parameter, so treat it as an identifier, not a label |
| Status | disabled takes no new members and cannot be signed in to; its members keep their accounts |
| Description | for 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:
| Answer | Meaning |
|---|---|
| 200 | the record was found; the domain is verified from now on |
| 400 | no matching record yet (also what a wrong value looks like) |
| 503 | the 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:
| Refused | Why |
|---|---|
GET /organizations, POST /organizations, DELETE /organizations/{org} | an organization’s existence is the tenant’s business |
PATCH with slug or status | the 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 groups | those 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 here | the 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"}.
| Mode | Who is asked for a second factor |
|---|---|
off | Nobody, unless the client asks for it with acr_values |
optional | Users who have enrolled a second factor |
required | Everyone. A user with no factor enrols one during sign-in before continuing |
required_for_admins | Users holding any admin permission in the tenant, as for required; everyone else as for optional |
required_for_roles | Holders 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_rolestakesroles, 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_adminslooks at the user’s permissions onurn:ridm:adminin their own tenant. Inmasterthat is every global administrator. Setting it onmasteris 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;optionalis 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
| Key | Default | Factor |
|---|---|---|
totp | true | An authenticator app: six-digit codes, 30-second period |
email_otp | false | A one-time code to the account’s email address (offered only to users with one) |
sms_otp | false | A 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.
| Setting | Default | Meaning |
|---|---|---|
settings.session.remember_device_days | 30 | How long a trusted device lasts (minimum 1) |
- The cookie holds a random secret; only its hash is stored. It is
HttpOnly, host-only withPath=/, and named per tenant (__Host-ridm_device_acme, orridm_device_acmewithCOOKIE_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
GETandDELETE /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_codecredential 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_valuesis a preference list, most preferred first. rIDM honours the first class it recognises: a class ending in:mfais 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
acris 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. Withprompt=nonethe request fails withlogin_requiredinstead. Addmax_age=0orprompt=loginto 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:
-
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_coderow with it, as in the account console. The user is notified whensettings.notifications.mfa_changedis on. -
Revoke trusted devices, so no remembered browser skips the new enrolment:
DELETE /admin/tenants/{slug}/users/{user}/devices. -
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.
| Signal | Raised when | Needs |
|---|---|---|
new_device | The browser sent no live trusted-device cookie and the user has signed in before, but never from this user agent | Nothing |
new_country | The user has never signed in from this country before | A geo source |
impossible_travel | The distance from where the user was last seen cannot be covered in the time since, at impossible_travel_kmh | A geo source that yields coordinates |
velocity | The address is behind velocity_max_failures or more failed sign-ins in the last velocity_window_minutes | Nothing |
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
}
}
}
| Score | Outcome |
|---|---|
Below step_up_at | Nothing: the sign-in proceeds as it always would |
step_up_at or above | Step up. The sign-in must pass a second factor |
block_at or above | Block. 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:
- 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.
- When a live session is reused at
/authorizeor 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 answersaccess_denied. The session is the browser’s own history, sonew_devicenever 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.
| Event | Payload |
|---|---|
risk.step_up | user_id, score, signals, country |
risk.blocked | user_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
- Configure a geo source, unless you only want the device and velocity signals.
- Make sure at least one second-factor method is enabled.
- Start with
block_at: 0, so the worst that happens is a second factor. - Watch
risk.step_upin the audit log for a week; the history is filling in during that time, so expect more step-ups early on than later. - Lower
block_atinto 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}}}'
| Field | Default | Meaning |
|---|---|---|
enabled | false | Whether anyone may impersonate this tenant’s users |
max_minutes | 60 | How 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:impersonatefor the tenant. Among the built-in roles onlyridm:ownerholds it, andridm:admindeliberately does not. A custom role can be given it, so a support team can impersonate without owning the tenant. A global administrator whose role inmasterholds 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
amris empty and there is noacr. - 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_valuesstill 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:
- ends the session, its tokens with it, and tells its relying parties (back-channel and front-channel logout, as for any sign-out);
- puts back the browser’s own session in the tenant, if it had one and it is still live;
- 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
| Event | Actor | Payload |
|---|---|---|
impersonation.requested | the administrator | user_id, reason |
impersonation.started | the administrator | user_id, session_id, impersonator_id, impersonator_tenant_id, reason |
impersonation.ended | the administrator when they ended it from the session, otherwise system | user_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.
| Field | Default | Notes |
|---|---|---|
backchannel_token_delivery_mode | poll when the grant is allowed | poll: 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_endpoint | null | Required 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 }
| Parameter | Required | Meaning |
|---|---|---|
scope | yes | Must include openid; the client’s default scopes are not used |
login_hint | one hint | The user’s username or email address, as they sign in with |
id_token_hint | one hint | An ID token this tenant issued to this client (expired is fine) |
login_hint_token | — | Refused: it has no standard format |
binding_message | no | Up 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_token | ping mode | The bearer token rIDM sends to the notification endpoint (up to 1024 characters) |
requested_expiry | no | Seconds the request stays open, 30–1800; 600 by default |
acr_values | no | Recorded with the request |
resource | no | Audiences, 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,amrandacrare that session’s, and a refresh token withoutoffline_accessends 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.
| Rule | Where it is enforced |
|---|---|
Confidential client (web or machine) authenticating with private_key_jwt, tls_client_auth or self_signed_tls_client_auth | registration |
Grants limited to authorization_code, refresh_token, client_credentials and CIBA | registration |
| HTTPS redirect URIs, exact match | registration, /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 string | every 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 EdDSA | the 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 seconds | always 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:
| Field | Matched against |
|---|---|
tls_client_auth_subject_dn | the 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_dns | a DNS name in the subject alternative names, without regard to case |
tls_client_auth_san_uri | a URI SAN, exactly (a SPIFFE ID, for instance) |
tls_client_auth_san_ip | an IP address SAN |
tls_client_auth_san_email | an 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 certificate | in 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.
| Field | Default | Notes |
|---|---|---|
name | required | Shown on the sign-in and consent pages |
entity_id | required | The SP’s entity ID, unique in the tenant |
acs_urls | required | HTTP-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_binding | none, redirect | Where rIDM sends logout requests, and how (redirect or post) |
name_id_format | persistent | persistent (opaque, stable, different for every SP), transient (new on every sign-in), email, or unspecified (the user’s id) |
allowed_scopes | openid, profile, email | Whose claims may be released as attributes |
attributes | [] | See Attributes |
require_consent | false | Ask 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_requests | false | Refuse requests not signed with one of them |
sign_response, sign_assertion | true, true | At least one stays on |
encrypt_assertion, encryption_certificate | false, none | Encrypt the assertion to the SP’s RSA certificate |
data_encryption | aes256-gcm | Also aes128-gcm, and aes256-cbc / aes128-cbc for SPs that cannot read GCM |
key_transport | rsa-oaep-mgf1p | RSA-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_state | false, none | See IdP-initiated sign-in |
assertion_ttl_secs | 300 | How long the SP may accept the assertion (30–3600) |
client_id | generated | rIDM’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 whenrequire_signed_requestsis on) names the NameID andSessionIndexrIDM gave it. rIDM ends that session, sends the browser through every other SAML SP that took part with a signedLogoutRequestof its own, waits for eachLogoutResponse, and finally answers the SP that asked, withPartialLogoutunderSuccessif 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):
- Start a rollover —
POST /admin/tenants/{slug}/saml/keys. A pending key is listed in the metadata at once, signing nothing. - 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 asretiring. - 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 format | Persistent |
| Principal type | Subject NameID |
| HTTP-POST binding response | On (rIDM answers by HTTP-POST only) |
| Want AuthnRequests signed | On, RSA_SHA256 |
| Validate signatures | On, with the certificate from rIDM’s metadata |
| Want assertions signed | On |
| Want assertions encrypted | Either; 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.
EncryptedIDin 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 ID | the 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) |
| Certificates | in 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
| Field | Default | Notes |
|---|---|---|
entity_id | required | The IdP’s entity ID, the Issuer of its messages; unique in the tenant |
sso_url, sso_binding | required, redirect | Where AuthnRequests go, and how (redirect or post) |
slo_url, slo_binding | none, redirect | The IdP’s logout service; without one, sign-out stays local to rIDM |
signing_certificates | required | PEM or base64 certificates the IdP signs with, one to ten (two during its key rollover). Nothing unsigned is ever accepted |
name_id_format | none | Asked for in NameIDPolicy: persistent, email, transient, unspecified, or none to leave it to the IdP. Import picks persistent when the IdP offers it |
sign_requests | true | Sign AuthnRequests. Logout messages are always signed |
want_assertions_signed | true | The assertion itself must carry a signature; a signed Response around an unsigned assertion is refused |
require_encrypted_assertions | false | Refuse plaintext assertions |
force_authn | false | Ask 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_unsolicited | false | Accept IdP-initiated sign-in (below) |
unsolicited_client_id | none | Where an IdP-initiated sign-in lands: this client’s initiate_login_uri; the account console when unset |
metadata_url | none | Re-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 theResponsearound it, or the assertion itself (required whenwant_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
Issueris 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
AuthnRequestrIDM sent (InResponseTo), within its validity window, allowing three minutes of clock skew. TheResponseitself 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:
| Claim | Attributes that count |
|---|---|
email | mail, 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_username | uid, 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_name | givenName, urn:oid:2.5.4.42, …/claims/givenname |
family_name | sn, surname, urn:oid:2.5.4.4, …/claims/surname |
name | displayName, 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
LogoutRequestat…/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 namedSessionIndex, 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 signedLogoutResponse. 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
LogoutRequestfor 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
LogoutRequestat 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 itsLogoutResponse.
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:
- Add the provider in rIDM from
{keycloak}/realms/{realm}/protocol/saml/descriptor. rIDM asks for a persistent NameID. - 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). - On that client, set the Name ID format to
persistentand turn on Force name ID format, so the NameID rIDM links the account by is an opaque identifier that survives a rename in Keycloak. - Add User Property mappers for
email,firstName(asgivenName) andlastName(assn). 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
| Metric | Labels | Meaning |
|---|---|---|
ridm_saml_sp_responses_total | outcome: accepted, status, invalid, replay, no_request, no_subject | Responses at the assertion consumer services |
ridm_saml_metadata_refresh_total | outcome: changed, unchanged, failed | Metadata 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.
| Setting | Default | Meaning |
|---|---|---|
vendor | other | active_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 |
starttls | off | Upgrade an ldap:// connection to TLS before anything is sent |
ca_certificate | none | PEM certificate(s) the directory’s certificate must chain to (an internal CA). None trusts the platform’s roots |
bind_dn, bind_password | none | The service account that searches (and, when writable, writes). Without one, searches are anonymous |
users_dn, search_scope | —, subtree | Where users are searched (one for direct children only) |
user_object_filter | AD: (&(objectCategory=person)(objectClass=user)), else (objectClass=inetOrgPerson) | Which entries are users |
username_attribute | AD: sAMAccountName, else uid | A new account’s username |
login_attributes | the username attribute and mail | What a typed identifier is matched against (up to five) |
uuid_attribute | AD: objectGUID, else entryUUID | The attribute that never changes for an entry. The link between entry and account |
edit_mode | read_only | See Write-back |
sync_interval_minutes | 60 | Incremental sync every N minutes (5–10080); 0 turns the periodic sync off |
full_sync_interval_hours | 24 | A full sync every N hours (1–720) |
groups_dn | none | Where groups are searched. None turns group sync off |
group_object_filter | AD: (objectClass=group), else (objectClass=groupOfNames) | Which entries are groups |
group_name_attribute | cn | A synced group’s name |
group_membership, group_member_attribute | dn, member | Whether members are DNs (groupOfNames, AD) or usernames (username, memberUid for posixGroup) |
group_parent_id | none | The rIDM group synced groups are created under |
timeout_secs | 10 | For 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, orwhenChangedon 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
unicodePwdon 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.
- Passwords are written with the Password Modify operation (RFC 3062), or as
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
| Metric | Labels | Counts |
|---|---|---|
ridm_ldap_syncs_total | outcome: success, failure | Sync 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
DisableAuthNegotiateCnameLookuppolicy). 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-96andaes128-cts-hmac-sha1-96(RFC 3962, what Active Directory issues), andaes256-cts-hmac-sha384-192andaes128-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.
| Setting | Default | Meaning |
|---|---|---|
keytab | — | The service’s keytab file, base64 (write-only) |
service_principal | from the keytab | HTTP/<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 |
realms | the service’s realm | The 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_form | local_part | How a principal names a user: local_part (alice) or principal (alice@CORP.EXAMPLE) |
ldap_idp_id | none | An LDAP provider that owns these users. See Accounts |
ldap_attribute | by vendor and name form | The directory attribute holding the name. AD: sAMAccountName (local part) or userPrincipalName (principal); other directories: uid or krbPrincipalName |
match_username | on | Without a directory: sign in the local account whose username is the name |
create_users | off | Without a directory: create an account, named by the principal, for one no account matches |
trusted_networks | none | CIDRs (or addresses) from which the login page tries Kerberos on its own (up to 100) |
max_skew_seconds | 300 | Allowed 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
AuthServerAllowlistpolicy. That policy is also the only way on macOS and Linux. - Firefox:
network.negotiate-auth.trusted-uris, set tosso.corp.exampleor 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=loginormax_age=0, typically to switch accounts), rIDM answers401withWWW-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 answers204and 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:
- a principal linked to an account before signs that account in, even after a rename;
- with
match_username, the local account whose username is the name is linked and signed in; - with
create_users, a new account is created (username from the name, no email, no password) and linked; - 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:
error | Meaning |
|---|---|
kerberos_ntlm | The browser offered NTLM: it had no ticket for this host (principal not registered, a CNAME, a machine off the domain) |
kerberos_unsupported | No Kerberos token, or Kerberos was not the browser’s first choice |
kerberos_invalid | The 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_replay | The authenticator was used before |
kerberos_no_account | No account matches the principal |
account_disabled | The 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
| Metric | Labels | Counts |
|---|---|---|
ridm_kerberos_negotiations_total | outcome: success, ntlm, unsupported, invalid, replay, no_account, disabled | Negotiate 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:
| Field | Meaning |
|---|---|
| name | 1–64 characters: lowercase letters, digits, ., _ and -, starting with a letter or digit. Up to 200 flags per tenant. |
enabled | The tenant-wide value. A new flag starts off. |
description | What turning it on changes, for the people who toggle it (up to 500 characters). |
organizations | Values 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:
- The tenant’s own configuration: SMTP, or an HTTP endpoint that receives each message as JSON.
- The deployment’s SMTP defaults, from the
SMTP_*environment variables. - 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
| Variable | Default | Meaning |
|---|---|---|
SMTP_HOST | unset | SMTP server. Unset means there are no deployment defaults |
SMTP_PORT | 587 | Port |
SMTP_USERNAME, SMTP_PASSWORD | unset | Credentials; both must be set for authentication to be used |
SMTP_FROM | required when SMTP_HOST is set | Sender, e.g. rIDM <no-reply@example.com> |
SMTP_SECURITY | starttls | starttls, 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:
| Event | Sent when | SMS | |
|---|---|---|---|
verify_email | self-registration needs the address confirmed | yes | |
password_reset | a user asks for a reset link | yes | |
magic_link | passwordless sign-in by link | yes | yes |
otp | a one-time code (sign-in, second factor, contact change) | yes | yes |
invitation | an administrator invites someone | yes | |
new_device | security notice: sign-in from a new browser | yes | yes |
password_changed | security notice | yes | |
mfa_changed | security notice | yes | |
email_changed | security notice, sent to the previous address | yes | |
backchannel_request | an application asks, over the back channel, to sign the user in (CIBA); sent whatever settings.notifications says | yes | yes |
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:
- the exact tag, lower-cased (
de-ch); - its language (
de); - the tenant’s default locale (
settings.locale.default); en;- 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.
| Variable | Available in |
|---|---|
tenant.display_name, tenant.slug | every event |
user.username | every event except invitation |
link | verify_email, password_reset, magic_link, invitation, backchannel_request (the account console’s approvals page) |
code | otp |
expires_minutes | verify_email, password_reset, magic_link, otp, backchannel_request |
expires_days, inviter | invitation |
when | the four security notices and backchannel_request (YYYY-MM-DD HH:MM UTC) |
ip, user_agent | new_device |
change | mfa_changed (for example “TOTP enrolled”) |
new_email | email_changed |
client_name, binding_message | backchannel_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.
| Status | Meaning |
|---|---|
queued | waiting for its first attempt or its next retry |
sending | claimed by a delivery pass; a message stuck here for 10 minutes (a node died mid-send) is queued again |
sent | the provider accepted it |
dead | gave 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) | Default | Sent when |
|---|---|---|
new_device | true | a sign-in from a browser the user has not used before |
password_changed | true | the password changes through recovery, a forced change, the account console, or an administrator’s reset with notify set (never the initial password) |
mfa_changed | true | a second factor is added or removed, or a recovery code is used |
email_changed | true | the 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=nonesends 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.
| Path | Methods |
|---|---|
/ServiceProviderConfig | GET |
/ResourceTypes | GET |
/Schemas | GET |
/Users | GET (list, filter), POST |
/Users/{id} | GET, PUT, PATCH, DELETE |
/Groups | GET (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 attribute | rIDM user |
|---|---|
id | the user’s id (read-only) |
userName (required) | username |
externalId | external_id, unique per tenant |
emails | email: the entry marked primary, else the first; one address is kept |
phoneNumbers | phone: the primary entry, else the first |
active | true = active, false = disabled (default true) |
locale | locale |
name.givenName | profile attribute given_name |
name.familyName | profile attribute family_name |
displayName | profile attribute display_name |
groups | direct group memberships, read-only |
meta | created, 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 attribute | rIDM group |
|---|---|
id | the group’s id |
displayName (required) | name |
externalId | attributes.externalId |
members | member 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 whenServiceProviderConfigsays 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).
| Event | Payload fields |
|---|---|
tenant.created, tenant.updated, tenant.deleted | tenant_id |
tenant.moved | tenant_id, from_region, to_region (null: the home database) |
tenant.profile_schema_updated | tenant_id |
system.bootstrapped | admin_user_id |
user.created, user.updated, user.deleted | user_id |
user.password_changed | user_id, by_user |
user.password_hash_upgraded | user_id, from_algo |
user.registered | user_id, verified |
user.email_verified | user_id |
user.email_changed | user_id, old_email, new_email |
user.password_reset_requested, user.password_reset_completed | user_id |
user.locked | user_id, until_secs |
user.terms_accepted | user_id |
login.succeeded | user_id, method |
login.failed | identifier, reason |
login.passwordless_sent | user_id, method |
login.new_device | user_id, session_id |
login.brokered | user_id, idp_id, provider |
logout.upstream | idp_id, provider |
directory.synced | idp_id, provider, full, created, updated, disabled, enabled |
mfa.changed | user_id, change |
risk.step_up | user_id, score, signals, country |
risk.blocked | user_id, score, signals, country |
impersonation.requested | user_id, reason |
impersonation.started | user_id, session_id, impersonator_id, impersonator_tenant_id, reason |
impersonation.ended | user_id, session_id, impersonator_id |
audit.chain_broken | seq, reason (the scheduled check found this tenant’s chain broken) |
device.trusted, device.revoked | user_id, device_id |
session.created, session.revoked | session_id, user_id |
authorization.granted | user_id, client_id, scopes (also raised when the user approves a backchannel request) |
backchannel.requested | request_id, client_id, user_id, scopes, binding_message (actor: the client) |
backchannel.denied | request_id, client_id, user_id |
consent.granted | user_id, client_id, scopes |
consent.revoked | user_id, client_id |
token.refresh_reuse_detected | family_id, client_id, user_id |
token.revoked | user_id, session_id, count |
personal_token.created | user_id, token_id, scopes |
personal_token.revoked | user_id, token_id |
client.created | client_id, public_id |
client.updated, client.deleted, client.secret_rotated | client_id |
dcr_token.created, dcr_token.revoked | token_id (a dynamic registration initial access token) |
group.created, group.updated, group.deleted | group_id |
group.member_added, group.member_removed | group_id, user_id |
organization.created, organization.updated, organization.deleted | org_id |
organization.member_added, organization.member_removed | org_id, user_id |
organization.domain_added, organization.domain_verified, organization.domain_removed | org_id, domain_id |
role.created, role.updated, role.deleted | role_id |
role.assigned, role.unassigned | role_id, user_id or group_id |
role.composite_added, role.composite_removed | parent_role_id, child_role_id |
invitation.created | invitation_id, email |
invitation.accepted | invitation_id, user_id |
invitation.revoked | invitation_id |
invitation.resent | invitation_id |
scope.created, scope.updated, scope.deleted | scope_id |
claim_mapper.created, claim_mapper.updated, claim_mapper.deleted | mapper_id |
resource_server.created, resource_server.updated, resource_server.deleted | resource_server_id |
permission.created, permission.deleted | resource_server_id, permission_id |
permission.granted, permission.revoked | role_id, permission_id |
signing_key.created | key_id, kid, alg |
signing_key.status_changed | key_id, kid, status |
master_key.rotated | new_version |
master_key.generation_created | version, backend |
saml_key.created | key_id |
saml_key.status_changed | key_id, status (active or deleted) |
webhook.created, webhook.updated, webhook.deleted, webhook.secret_rotated | webhook_id |
webhook.test | webhook_id |
webhook.delivery_dead | webhook_id, delivery_id, event_name |
scim_token.created, scim_token.revoked | token_id |
ip_rule.created, ip_rule.updated, ip_rule.deleted | rule_id |
mtls_trust_anchor.created | anchor_id, fingerprint |
mtls_trust_anchor.deleted | anchor_id |
identity_provider.created, identity_provider.updated, identity_provider.deleted | idp_id |
identity.linked | user_id, idp_id, external_subject |
identity.unlinked | user_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.
| Field | Default | Rules |
|---|---|---|
name | required | 1–255 characters |
url | required | see Target restrictions |
events | required | exact names (user.created), prefixes ending in * (user.*), or * for everything; lower case, at most 64 characters each |
enabled | true | a 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_attempts | 8 | 1–20 |
| Route | Does |
|---|---|
GET /admin/tenants/{slug}/webhooks | list |
GET, PATCH, DELETE …/webhooks/{webhook} | read, change any field above, delete (its deliveries go with it) |
POST …/webhooks/{webhook}/secret | new secret, returned once; later deliveries use it |
POST …/webhooks/{webhook}/test | send 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}/redeliver | send one delivered, failed or dead delivery again now |
POST …/deliveries/redeliver-dead | requeue 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-…"}
}
}
| Header | Content |
|---|---|
X-RIDM-Event | the 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-Timestamp | Unix seconds when this attempt was signed |
X-RIDM-Delivery | the delivery id; the same on every retry and redelivery of this delivery |
X-RIDM-Webhook | the webhook id |
X-RIDM-Signature | t=<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:
- recompute the HMAC over the raw body before parsing it, and compare in constant time;
- 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);
- remember the
X-RIDM-Deliveryids 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.
| Outcome | Result |
|---|---|
2xx | delivered |
connection error, timeout, 5xx, 408, 425, 429 | failed, retried after a backoff |
any other status (including 3xx and other 4xx) | dead at once |
attempts reach max_attempts | dead |
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. Plainhttpis allowed only forlocalhost,127.0.0.1and[::1], for development. - A literal private address is refused when the webhook is saved (
400): RFC 1918 ranges, link-local (including169.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)_PROXYis 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:
| Field | Content |
|---|---|
id | the event id |
tenant_id | the tenant, or null in the global chain |
seq | position in the chain, from 1 |
occurred_at, recorded_at | when it happened, when it was written |
name | the dotted event name |
actor_type, actor_id | user, client, admin or system; a SCIM token acts as client |
subject_id | the 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_id | the administrator behind the event, when it happened in a session they opened as the user (impersonation) |
ip, user_agent | when the event came from a request that recorded them |
payload | the event’s kind document |
prev_hash, hash | the 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'
| Parameter | Meaning |
|---|---|
from, to | RFC 3339 bounds on occurred_at |
name | exact event name, or a prefix when it ends with . or * |
actor_id, subject_id, impersonator_id | exact match |
user_id | rows where the user is the actor, the subject or the impersonator |
limit | page size, 50 by default, at most 500 |
cursor | the 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….
| Flag | Proves |
|---|---|
--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 setsridm_audit_chains_brokento 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_URL | Transport |
|---|---|
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:port | The same over TCP, newline-delimited. |
syslog+tls://host:port | The 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 onseq: after a database restore, a chain reuses theseqnumbers 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 withridm 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_depthandridm_webhook_dispatch_queue_depth); a node whose queue passes 80% ofEVENT_QUEUE_CAPACITYreports 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 (inlogin.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
| Status | Signs tokens | In the JWKS |
|---|---|---|
pending | no | yes |
active | yes | yes |
retiring | no | yes, until expires_at |
revoked | no | no |
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:
| Setting | Default | Meaning |
|---|---|---|
default_alg | RS256 | algorithm for new keys and for signing: RS256, RS384, RS512, ES256 or EdDSA |
rsa_bits | B2048 | RSA modulus for new RSA keys: B2048, B3072 or B4096 |
rotation_interval_days | 90 | rotate the active key when it is this old; 0 turns automatic rotation off |
retire_overlap_hours | 24 | how 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):
| Route | Does |
|---|---|
GET /admin/tenants/{slug}/keys?status= | list keys |
POST /admin/tenants/{slug}/keys/rotate | new key with the default algorithm, active at once (rotate another algorithm’s key by creating one with activate: true) |
POST /admin/tenants/{slug}/keys | new key, pending unless activate is true |
GET /admin/tenants/{slug}/keys/{key} | one key with its public JWK |
POST …/keys/{key}/activate | start signing with it; the previous active key of its algorithm retires |
POST …/keys/{key}/retire | stop signing; stays published for the overlap |
POST …/keys/{key}/revoke | unpublish 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.
| Variable | Default | Meaning |
|---|---|---|
MASTER_KEY | required, unless MASTER_KEY_FILE or KEY_WRAPPER is set | 32 bytes, hex or base64. Generate with openssl rand -hex 32 |
MASTER_KEY_FILE | unset | read the key from a file (a mounted secret) instead |
MASTER_KEY_VERSION | 1 | the current key’s generation, at least 1 |
MASTER_KEY_PREVIOUS | empty | older 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
-
Generate the new key and keep the old one:
openssl rand -hex 32 -
Roll it out to every node: the new key as
MASTER_KEY,MASTER_KEY_VERSIONincremented, the old key inMASTER_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.
-
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 scriptsor on a host with the server’s configuration (the same environment as the nodes):
ridm-api rotate-master-key --status ridm-api rotate-master-keyThe 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-keyandPOST /admin/master-key/rotate, which need a global administrator withridm:keys:read/ridm:keys:write. -
Check that nothing is left:
ridm master-key statusmust report 0 rows on an older generation, and the rotation report must show nofailedrows. -
Remove the old key from
MASTER_KEY_PREVIOUSon 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 domain | With one | |
|---|---|---|
Issuer (iss in every token) | https://id.example.com/t/acme | https://login.acme.example |
| Discovery | https://id.example.com/t/acme/.well-known/openid-configuration | https://login.acme.example/.well-known/openid-configuration |
| Endpoints in discovery | https://id.example.com/t/acme/token, … | https://login.acme.example/token, … |
| JWKS | …/t/acme/.well-known/jwks.json | https://login.acme.example/.well-known/jwks.json |
| Identity-provider callback | …/t/acme/broker/{alias}/callback | https://login.acme.example/broker/{alias}/callback |
| Passkey relying party id | the UI’s host | login.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 host | Served as |
|---|---|
/healthz, /readyz | The deployment’s probes, unchanged, so a load balancer can probe any host |
/.well-known/webfinger, /.well-known/security.txt | The 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 HEAD | The 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.exampleis admitted as an origin under the tenant’s paths, alongside the origins registered on its clients. - DPoP: a proof’s
htumay name either the primary URL or the custom one. - Registration management:
registration_client_urifor 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:
-
Point the name at the deployment (a
CNAMEto the primary host, or the same addresses). -
Obtain a certificate for it and terminate TLS for it: at your reverse proxy or load balancer, or natively with
TLS_CERT/TLS_KEYif one certificate can cover every host the node answers for. -
Make the proxy pass the requested host through. rIDM picks the tenant from:
X-Forwarded-Host, but only when the TCP peer is inTRUSTED_PROXIES;- otherwise the
Hostheader; - otherwise the request’s authority (HTTP/2).
Behind a proxy that rewrites
Host, setX-Forwarded-Hostand list the proxy inTRUSTED_PROXIES, or the request lands on the primary host’s routes and answers404. The RFC 7239Forwarded: host=parameter is not read. -
Check
https://login.acme.example/.well-known/openid-configurationanswers 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
issagainst 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-authtakes 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,/authorizeincluded. 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 atUI_URLcall 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.
| Control | Scope | Configured in |
|---|---|---|
| Request ceilings | per address, per client, per tenant, per deployment | settings.rate_limits, RATE_LIMITS, RATE_LIMIT_IP_PER_MINUTE |
| IP rules | tenant-wide and per client | /admin/tenants/{slug}/ip-rules |
| Account lockout | per user, per address | settings.lockout |
| CAPTCHA | per sign-in flow | settings.captcha and /admin/tenants/{slug}/captcha |
| Breached passwords | every password set | settings.password.check_breached, BREACH_CHECK_URL |
| Cross-origin policy | every response | client cors_origins |
| Security headers | every response | HSTS_MAX_AGE |
| Outbound request policy | every URL a tenant or client chooses | fixed |
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_PROXIESempty 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.
| Family | Endpoints (under /t/{slug}) | Refusal format |
|---|---|---|
| token | /token, /introspect, /revoke, /userinfo, /device_authorization | OAuth JSON: 429, {"error": "slow_down"} |
| authorize | /authorize (HTML page), /par and dynamic registration (OAuth JSON) | as noted |
| flows | the 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:
| Bucket | Key | Limit |
|---|---|---|
| deployment, per address | ridm:rl:ip:{addr} | RATE_LIMIT_IP_PER_MINUTE per 60 s, across every tenant and family |
| tenant, per address and family | ridm:rl:t:{tenant}:{family}:ip:{addr} | token_per_ip, authorize_per_ip or flows_per_ip per window |
| tenant, per client | ridm:rl:t:{tenant}:client:{client} | token_per_client per window, token family only |
| tenant total | ridm:rl:t:{tenant}:all | tenant_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):
| Setting | Default | Meaning |
|---|---|---|
enabled | true | switch the tenant’s own buckets off (the deployment bucket still applies) |
window_secs | 60 | window length, 1–3600 seconds |
token_per_ip | 600 | token family, per address |
token_per_client | 1200 | token family, per client |
authorize_per_ip | 300 | authorize family, per address |
flows_per_ip | 600 | flows family, per address |
tenant_total | 0 | everything 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:
| Variable | Default | Meaning |
|---|---|---|
RATE_LIMITS | true | master switch; false disables every ceiling, for tests and local experiments only |
RATE_LIMIT_IP_PER_MINUTE | 6000 | per-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:
- the most specific rule whose network contains the address decides (a
/32beats a/24), whether it saysallowordeny; - an address that matches no rule passes, unless the scope has any
allowrule, 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).
| Where | Refusal |
|---|---|
| flow API | 403, application/problem+json |
| OAuth JSON endpoints | 403 with an OAuth error body |
/authorize, brokering | 403 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
allowlocks out everyone else, including remote administrators signing in to the console through this tenant. Keep a way in (another tenant, such asmaster, 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_PROXIESwrong, 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:
| Setting | Default | Meaning |
|---|---|---|
max_failures | 10 | consecutive wrong passwords before the account is locked; 0 = off |
lock_minutes | 15 | how long the lock lasts |
ip_max_failures | 100 | failed password attempts from one address within the window before that address is throttled; 0 = off |
ip_window_minutes | 15 | the 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:
| Setting | Default | Meaning |
|---|---|---|
after_failures | 3 | demand a challenge once a sign-in flow has this many failed attempts; 0 = never |
on_registration | true | demand 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.
| Variable | Default | Meaning |
|---|---|---|
BREACH_CHECK_URL | https://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:
| Origin | Admitted on |
|---|---|
the UI’s and the API’s own origins (UI_URL, PUBLIC_URL) | everything |
| any origin | discovery, 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 clients | that tenant’s paths |
| anything else | nothing: 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:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | no-referrer |
Content-Security-Policy | default-src 'none'; frame-ancestors 'none' (under /docs, only frame-ancestors 'none', since Swagger UI needs its scripts) |
Strict-Transport-Security | max-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:
| Target | Chosen by |
|---|---|
| Webhook receivers | tenant administrator |
| Back-channel logout URIs | client registration |
Client jwks_uri (for private_key_jwt) | client registration |
| Identity-provider discovery, token, userinfo and JWKS endpoints | tenant administrator |
| A tenant’s HTTP email gateway and SMS webhook | tenant administrator |
A tenant’s CAPTCHA verify_url | tenant administrator |
| A tenant’s SMTP server | tenant 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_PROXYandHTTPS_PROXYare 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 namelocalhostmay reach loopback. Any other name resolving to loopback is refused. - Networks the operator opens:
OUTBOUND_ALLOW_NETWORKSlists private networks these requests may reach anyway, for applications that live on an internal network (a back-channel logout endpoint athttps://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.
| Family | Tenant setting | Refusal format |
|---|---|---|
token | rate_limits.token_per_ip, token_per_client | OAuth JSON ({"error": "slow_down"}) |
authorize | rate_limits.authorize_per_ip | HTML page for /authorize, OAuth JSON for /par and /register |
flows | rate_limits.flows_per_ip | RFC 9457 problem document; HTML page for brokering |
Global
| Method | Path | Auth | Limit | Purpose |
|---|---|---|---|---|
| GET | /healthz | none | — | Liveness: {"status": "ok", "version": "..."}. Never touches a dependency. |
| GET | /readyz | none | — | 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 | /metrics | Bearer <METRICS_TOKEN> when that variable is set, otherwise none | — | Prometheus exposition (text/plain; version=0.0.4). See Observability. |
| GET | /openapi.json | none | — | The admin and account API’s OpenAPI 3 document. Always served. |
| GET | /docs | none | — | Swagger UI over /openapi.json; mounted only when DOCS_ENABLED=true. |
| GET | /.well-known/webfinger | none | — | 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.txt | none | — | 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
| Method | Path | Auth | Limit | Purpose | Specification |
|---|---|---|---|---|---|
| GET | /t/{slug}/.well-known/openid-configuration | none | — | 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.json | none | — | 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}/authorize | client identified by client_id | authorize | Authorization 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) | authorize | Delivers 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}/par | client authentication | authorize | Pushed 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}/token | client authentication | token | Token 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_authorization | client authentication | token | Starts the device authorization grant; the client must be allowed the device-code grant. | RFC 8628 §3.1 |
| POST | /t/{slug}/bc-authorize | client authentication (confidential clients only) | token | Backchannel 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}/userinfo | access token, JWT or opaque (Authorization: Bearer or DPoP, or an access_token form field on POST) | token | Claims about the token’s subject; the token must carry openid. See Token claims. | OIDC Core §5.3, RFC 6750 |
| GET | /t/{slug}/features | access token of the tenant (Bearer or DPoP) | token | The feature flags that are on for the token’s organization (org_id), or tenant-wide: {"features": [...], "org_id": ...}. | — |
| POST | /t/{slug}/introspect | client authentication (confidential clients only) | token | Token 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}/revoke | client authentication | token | Revokes 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_session | none (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/confirm | CSRF 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}/register | none, or an initial access token (Bearer iat_..., issued by an administrator; see Registering clients), per settings.dcr.mode | authorize | Dynamic 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) | authorize | Read, replace or delete a dynamically registered client. | RFC 7592 |
| GET | /t/{slug}/branding | none | — | 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.
| Method | How |
|---|---|
none | public client: client_id in the body only (PKCE protects the code) |
client_secret_basic | Authorization: Basic base64(client_id:secret) |
client_secret_post | client_id and client_secret in the form body |
private_key_jwt | client_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_type | Notes |
|---|---|
authorization_code | PKCE 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_token | Rotation 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_credentials | Tokens 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_code | RFC 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:ciba | auth_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-exchange | subject_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.
| Member | Value |
|---|---|
scopes_supported | the tenant’s scopes (standard plus custom) |
response_types_supported | code |
response_modes_supported | query, fragment, jwt, query.jwt, fragment.jwt, form_post.jwt |
grant_types_supported | 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 |
backchannel_token_delivery_modes_supported | poll, ping |
backchannel_user_code_parameter_supported | false |
subject_types_supported | public, pairwise |
id_token_signing_alg_values_supported | RS256, RS384, RS512, ES256, EdDSA |
id_token_encryption_alg_values_supported | RSA-OAEP-256, RSA-OAEP |
id_token_encryption_enc_values_supported | A256GCM, 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_tokens | true when mutual TLS is configured, absent otherwise (RFC 8705 §3.3) |
mtls_endpoint_aliases | with 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_supported | RS256, RS384, RS512, ES256, EdDSA |
request_object_signing_alg_values_supported | the same five |
authorization_signing_alg_values_supported (JARM) | the same five |
dpop_signing_alg_values_supported | ES256, ES384, RS256, RS384, RS512, PS256, PS384, PS512, EdDSA: the algorithms the DPoP verifier accepts |
code_challenge_methods_supported | S256 |
acr_values_supported | urn:ridm:acr:single, urn:ridm:acr:mfa |
claims_supported | sub, 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_supported | normal |
prompt_values_supported | none, login, consent, select_account, create |
ui_locales_supported | the tenant’s settings.locale.supported |
claims_parameter_supported | false |
request_parameter_supported | true |
request_uri_parameter_supported | true (PAR request_uri values only) |
require_request_uri_registration | false |
require_pushed_authorization_requests | false (tenant-wide; a client can be registered to require PAR) |
authorization_response_iss_parameter_supported | true |
backchannel_logout_supported, backchannel_logout_session_supported | true |
frontchannel_logout_supported, frontchannel_logout_session_supported | true |
op_policy_uri, op_tos_uri | the tenant’s registration.privacy_url and registration.terms_url, when set |
service_documentation | the 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /t/{slug}/flows/{id} | Current flow state. |
| POST | /t/{slug}/flows/{id}/password | Identifier and password. |
| POST | /t/{slug}/flows/{id}/password-change | New password when one is required (expired or flagged). |
| POST | /t/{slug}/flows/{id}/register | Self-registration (when settings.registration.enabled). |
| POST | /t/{slug}/flows/{id}/magic-link | Send a sign-in link by email. |
| POST | /t/{slug}/flows/{id}/magic-link/verify | Redeem the link’s token. |
| POST | /t/{slug}/flows/{id}/email-otp | Send a one-time sign-in code by email. |
| POST | /t/{slug}/flows/{id}/email-otp/verify | Check the emailed code. |
| POST | /t/{slug}/flows/{id}/sms-otp | Send a one-time sign-in code by SMS. |
| POST | /t/{slug}/flows/{id}/sms-otp/verify | Check the texted code. |
| POST | /t/{slug}/flows/{id}/passkey/start | Passkey sign-in: discoverable-credential challenge. |
| POST | /t/{slug}/flows/{id}/passkey/finish | Passkey sign-in: verify the assertion. |
| POST | /t/{slug}/flows/{id}/kerberos | Kerberos 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/enroll | Second step: start authenticator-app enrolment. |
| POST | /t/{slug}/flows/{id}/mfa/totp/confirm | Second step: confirm the first code. |
| POST | /t/{slug}/flows/{id}/mfa/verify | Second step: TOTP or recovery code. |
| POST | /t/{slug}/flows/{id}/mfa/passkey/register | Second step: passkey creation options. |
| POST | /t/{slug}/flows/{id}/mfa/passkey/register/finish | Second step: store the new passkey. |
| POST | /t/{slug}/flows/{id}/mfa/passkey/start | Second step: passkey challenge. |
| POST | /t/{slug}/flows/{id}/mfa/passkey/finish | Second step: verify the passkey. |
| POST | /t/{slug}/flows/{id}/mfa/email/enroll, .../confirm | Second step: enrol email codes. |
| POST | /t/{slug}/flows/{id}/mfa/email/send, .../verify | Second step: send and check an email code. |
| POST | /t/{slug}/flows/{id}/mfa/sms/enroll, .../confirm | Second step: enrol SMS codes (phone in E.164). |
| POST | /t/{slug}/flows/{id}/mfa/sms/send, .../verify | Second step: send and check an SMS code. |
| POST | /t/{slug}/flows/{id}/profile | Complete required profile attributes. |
| POST | /t/{slug}/flows/{id}/terms | Accept the terms (when settings.registration.require_terms). |
| POST | /t/{slug}/flows/{id}/consent | Grant or deny the requested scopes. |
| POST | /t/{slug}/flows/{id}/cancel | Abandon the flow: answers {"stage": "cancelled", "redirect_to"}, the client’s redirect URI with error=access_denied. |
| GET | /t/{slug}/flows/{id}/finish | Browser 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.
| Method | Path | Purpose |
|---|---|---|
| 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/password | Request a password-reset message for an identifier. The answer does not reveal whether the account exists. |
| POST | /t/{slug}/recovery/password/confirm | Set a new password with the reset token. |
| POST | /t/{slug}/verification/email/resend | Send the email-verification message again. |
| POST | /t/{slug}/verification/email/confirm | Confirm 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/verify | The 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.
| Method | Path | Auth | Limit | Purpose | Spec |
|---|---|---|---|---|---|
| GET | /t/{slug}/saml/metadata | none | authorize | The 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/sso | the SP’s signature when it has certificates registered | authorize | AuthnRequest 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/init | the user’s session | authorize | IdP-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/slo | the SP’s signature when it has certificates registered | authorize | LogoutRequest 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) | authorize | Continues a logout that started at rIDM through the session’s SAML SPs. | |
| GET | /t/{slug}/saml/respond/{ticket} | none (a one-time id) | authorize | Posts a failure Response (RequestDenied, AuthnFailed) to the SP after a cancelled, refused or blocked sign-in. |
Identity brokering
| Method | Path | Auth | Limit | Purpose |
|---|---|---|---|---|
| GET | /t/{slug}/broker/{alias}/start | none (?flow= a login flow, or ?ticket= a link ticket from the account API) | flows | Sends the browser to the upstream provider named by alias. |
| GET, POST | /t/{slug}/broker/{alias}/callback | state of the upstream request | flows | Receives the upstream response (POST for providers that post it back), then continues the login flow or finishes linking. |
| GET | /t/{slug}/broker/{alias}/saml/metadata | none | flows | rIDM’s SP metadata for a SAML provider (its URL is the SP entity ID). |
| POST | /t/{slug}/broker/{alias}/saml/acs | the IdP’s signature, RelayState | flows | Assertion 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/slo | the IdP’s signature | flows | The 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) | flows | Send 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.
| Method | Path | Purpose |
|---|---|---|
| GET, DELETE | /t/{slug}/account/me | Identity with the session’s auth_time, acr and amr; DELETE deletes the account (when settings.account.self_deletion). |
| GET, PATCH | /t/{slug}/account/profile | Profile attributes the schema lets the user see and edit. |
| GET, PUT | /t/{slug}/account/password | Password status; change it. |
| POST, DELETE | /t/{slug}/account/email/change | Start (or cancel) an email change. |
| POST | /t/{slug}/account/email/confirm | Confirm the new address with its code. |
| POST, DELETE | /t/{slug}/account/phone/change | Start (or cancel) a phone change. |
| POST | /t/{slug}/account/phone/confirm | Confirm the new number. |
| DELETE | /t/{slug}/account/phone | Remove the phone number. |
| GET | /t/{slug}/account/mfa | Enrolled second factors and remaining recovery codes. |
| POST | /t/{slug}/account/mfa/totp/enroll, .../confirm | Enrol an authenticator app. |
| POST | /t/{slug}/account/mfa/email/enroll, .../confirm | Enrol email codes. |
| POST | /t/{slug}/account/mfa/sms/enroll, .../confirm | Enrol SMS codes. |
| POST | /t/{slug}/account/mfa/passkey/register, .../register/finish | Register a passkey. |
| POST | /t/{slug}/account/mfa/recovery-codes | Replace 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/devices | Trusted devices; forget all. |
| DELETE | /t/{slug}/account/devices/{device_id} | Forget one trusted device. |
| GET, DELETE | /t/{slug}/account/sessions | Live 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/apps | Applications the user consented to. |
| DELETE | /t/{slug}/account/apps/{client_id} | Withdraw consent and the application’s refresh tokens. |
| GET | /t/{slug}/account/backchannel-requests | Backchannel (CIBA) sign-in requests waiting on the user. |
| POST | /t/{slug}/account/backchannel-requests/{id}/approve, .../deny | Answer 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/identities | Linked upstream identities. |
| POST | /t/{slug}/account/identities/link | Start 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/tokens | Personal access tokens; mint one (shown once). |
| DELETE | /t/{slug}/account/tokens/{token_id} | Revoke a personal access token. |
| GET | /t/{slug}/account/export | Everything held about the user as one JSON document. |
A few behaviours the table leaves out:
- Password change.
PUT passwordneeds the current password while one is set;sign_out_others: trueends 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=1or?link_error=<code>. - Personal access tokens.
GET tokensalso 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 meneeds 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /scim/v2/{slug}/ServiceProviderConfig | Supported features. |
| GET | /scim/v2/{slug}/ResourceTypes | User and Group. |
| GET | /scim/v2/{slug}/Schemas | Core schemas. |
| GET, POST | /scim/v2/{slug}/Users | List (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}/Groups | List 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 atGET /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.
| Tag | Covers |
|---|---|
auth | the caller’s identity (GET /admin/me) and the permission catalogue with the built-in roles (GET /admin/permissions) |
tenants | tenants and their settings, CAPTCHA provider, profile schema, statistics |
tenant_config | configuration as code: GET .../export, POST .../import |
clients | OAuth / OIDC clients, secrets, service accounts, registration access tokens, and the initial access tokens of dynamic registration (/admin/tenants/{slug}/dcr/initial-access-tokens) |
users | users and everything attached to them: password, sessions, credentials, trusted devices, roles, groups, consents, linked identities, personal access tokens, bulk import and export |
groups | groups, membership and group roles |
organizations | organizations, their members, domains, invitations and the roles granted inside them |
roles | roles, composites, permission grants and holders |
resource_servers | resource servers (audiences) and their permissions |
scopes | OAuth scopes |
mappers | claim mappers |
keys | signing keys, and master-key rotation |
invitations | invitations |
messaging | email and SMS delivery settings, templates, the delivery log |
audit | the audit log, export and chain verification |
webhooks | webhooks and their deliveries |
scim | SCIM provisioning tokens |
ip_rules | IP allow and deny rules |
identity_providers | upstream OpenID Connect and OAuth 2.0 providers |
account | the 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
audincludesurn:ridm:admin: a JWT (typ: at+jwt), or an opaqueat_...token of a client registered withaccess_token_format: opaque. The token endpoint grants that audience only to clients that list it inallowed_audiencesexplicitly; 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 asAuthorization: DPoPwith 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
GETis401, and a ticket presented anywhere is spent. - The caller must hold what the export requires (
403otherwise, 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:
| Resource | Permissions |
|---|---|
| tenants | ridm:tenants:read, write, create, delete, export, import |
| users | ridm:users:read, write |
| invitations | ridm:invitations:read, write (also bulk user import) |
| groups | ridm:groups:read, write |
| roles | ridm:roles:read, write |
| clients | ridm:clients:read, write |
| scopes | ridm:scopes:read, write |
| claim mappers | ridm:mappers:read, write |
| resource servers | ridm:resource-servers:read, write |
| identity providers | ridm:idps:read, write |
| keys | ridm:keys:read, write |
| messaging | ridm:messaging:read, write |
| audit | ridm:audit:read |
| webhooks | ridm:webhooks:read, write |
| SCIM tokens | ridm: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:
| Role | Grants |
|---|---|
ridm:owner | every permission |
ridm:admin | everything except ridm:tenants:create, ridm:tenants:delete, ridm:tenants:import |
ridm:user-manager | users, invitations, groups and SCIM tokens; read tenant settings, roles and audit |
ridm:client-manager | clients, scopes, claim mappers, resource servers; read tenant settings, roles and audit |
ridm:viewer | every *: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
| Convention | Detail |
|---|---|
| Bodies | application/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. |
| Identifiers | Path parameters take the resource’s UUID; clients also accept the public client_id, identity providers their alias. |
| Create | POST 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. |
| Update | PATCH 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, link | PUT 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. |
| Delete | DELETE, usually 204. Users are soft-deleted and purged after the tenant’s retention. |
| Secrets on read | Never returned. Reads report secret_set, password_set or client_secret_set instead; omitting a secret on update keeps the stored one. |
| Caching | Responses 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..."
}
| Parameter | Meaning |
|---|---|
limit | page size, default 50, clamped to 1–500 |
cursor | the 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:
| Collection | Filters |
|---|---|
users | search (username or email prefix), status, org_id, include_deleted |
clients | search (prefix of client_id or name) |
invitations | open_only |
roles | client_id, realm_only |
claim-mappers, ip-rules | client_id, tenant_wide |
keys | status (pending, active, retiring, revoked) |
audit | from, to, name (exact, or a prefix when it ends in . or *), actor_id, subject_id, user_id |
stats | days (1–365, default 30) |
users/export, audit/export | format (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.
| Token | typ |
|---|---|
| Access token | at+jwt (RFC 9068) |
| ID token | JWT |
| Back-channel logout token | logout+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.
| Claim | Present | Value |
|---|---|---|
iss | always | the tenant’s issuer: {PUBLIC_URL}/t/{slug}, or https://{custom_domain} |
sub | always | the user’s subject (below); for client_credentials without a service account, the client_id |
aud | always | the 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_id | always | the client the token was issued to |
azp | always | the same client_id |
iat, nbf | always | issue time (seconds since the epoch) |
exp | always | expiry; see lifetimes |
jti | always | a UUIDv7; revocation denylists it until exp |
tid | always | the tenant’s UUID. A relying party serving several tenants of one deployment keys on it |
org_id | the sign-in acts in an organization | the 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 |
features | the features scope was granted | the tenant’s feature flags that are on for the sign-in’s organization, sorted ([] when none); no mapper can write it |
scope | always | the granted scopes, space-separated (see Scopes in the token) |
roles | a user subject | names of the user’s effective roles: direct, inherited through groups and their ancestors, and expanded composites; a roles claim mapper replaces it |
groups | a user subject | names (not paths) of the user’s groups, including ancestor groups; a groups claim mapper replaces it |
permissions | a user subject whose roles hold permissions on a requested resource server | permission names, e.g. orders:read |
sid | the token came from a browser session | the session’s UUID |
auth_time | the token came from an authentication | when the user authenticated |
amr | the token came from an authentication | authentication methods (below) |
acr | the session has a class | urn:ridm:acr:single, urn:ridm:acr:mfa, or the *:mfa class the client asked for |
cnf | a 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) |
act | token exchange with an actor_token, or a sign-in an administrator opened as the user | token 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 attributes | the attribute lists access_token in its visible_in and the user has a value | one 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 orclient_credentials) gets the tenant’s scopes markedis_defaultthat the client may hold;client_credentialsnever getsopenidoroffline_accessthis way. - Bound scopes. A scope bound to a resource server (
resource_server_id) adds that server to the audiences, and is refused withinvalid_scopewhen 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_accessis kept only when every resource server in the audience hasallow_offline_accesson; 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.
| Claim | Present | Value |
|---|---|---|
iss, sub | always | as in the access token |
aud | always | the client_id |
azp | always | the client_id |
iat, exp | always | issue time and expiry |
auth_time | always | when the user authenticated; a token minted from a refresh token repeats the original value (OIDC Core §12.2) |
tid | always | the tenant’s UUID |
org_id | the sign-in acts in an organization | as in the access token |
features | the features scope was granted | as in the access token |
nonce | the authorization request had one | echoed |
sid | a browser session exists | the session’s UUID, the value back- and front-channel logout name |
amr, acr | as in the access token | refresh-derived ID tokens repeat the original values |
act | an administrator opened the session as the user | as in the access token (impersonation) |
at_hash | always (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
userinfoinvisible_in; - the claims of mappers with
userinfoininclude_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:
| Scope | Seeded claims |
|---|---|
profile | name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
email | email, email_verified |
phone | phone_number, phone_number_verified |
address | address |
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.
| Claim | Value |
|---|---|
iss | the tenant’s issuer |
sub | the user’s subject as that client sees it |
aud | the client_id |
iat | issue time |
exp | iat + 120 seconds |
jti | a UUIDv7 |
sid | the 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.
| Method | amr |
|---|---|
| Password | pwd |
| Magic link, email one-time code, invitation acceptance | otp |
| SMS one-time code | otp, sms |
| Passkey sign-in with user verification | hwk, user, mfa |
| Passkey sign-in without user verification | hwk |
| Upstream identity provider (brokering) | fed |
| Kerberos desktop sign-in | kerberos (RFC 8176 registers no value for it) |
| Second step: authenticator app, email code | adds otp, mfa |
| Second step: recovery code | adds mfa only |
| Second step: SMS code | adds otp, sms, mfa |
| Second step: passkey | adds 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
| Value | Meaning |
|---|---|
urn:ridm:acr:single | one 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:mfa | a second factor passed (or a passkey with user verification signed in) |
any class ending in :mfa | the 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.
type | Fields | Produces |
|---|---|---|
user_attribute | attribute, 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 |
groups | claim, full_path (default false) | the user’s group names, or parent/child paths with full_path |
roles | claim, 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) |
hardcoded | claim, value (any JSON) | the fixed value |
template | claim, template | a 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 |
audience | audience | adds 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
rolesmapper may write a claim namedroles, and only agroupsmapper one namedgroups. In an access token their output replaces the built-in claim (to emit one client’s roles, or group paths). No mapper may writepermissions, which is computed from the audience’s grants. A mapper breaking these rules is refused when saved. - A
rolesmapper’sclient_idis 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
| Token | Default | Overridden by |
|---|---|---|
| Access token | 300 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 token | 300 s (settings.session.id_token_ttl_secs) | the client’s id_token_ttl_secs |
| Refresh token | 30 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 token | 120 s | fixed |
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,onand0,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 code2. - Secrets can come from files.
DATABASE_URL,DATABASE_READ_URL,REDIS_URL(and each region’sDATABASE_URL_<NAME>,DATABASE_READ_URL_<NAME>,REDIS_URL_<NAME>),MASTER_KEY,SMTP_PASSWORD,METRICS_TOKEN,AUDIT_SINK_TOKEN,AUDIT_SINK_SECRETandBOOTSTRAP_ADMIN_PASSWORDeach also have a*_FILEform (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
| Variable | Type | Meaning |
|---|---|---|
DATABASE_URL | Postgres URL | Postgres 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_URL | URL | Valkey (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_URL | http/https URL | The 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_FILE | 32 bytes | The 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
BIND_ADDR | socket address | 0.0.0.0:8080 | Address 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_URL | URL | PUBLIC_URL | Base 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_UI | boolean | true | Serve 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_ENABLED | boolean | false | Serve Swagger UI at /docs. /openapi.json is served either way. Keep it off in production. |
MIGRATE_ON_START | boolean | false | At 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
DATABASE_READ_URL | Postgres URL | unset | A 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_MIN | integer | 2 | Minimum Postgres connections per node. Must not exceed DB_POOL_MAX. |
DB_POOL_MAX | integer | 20 | Maximum 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_MS | integer ≥ 1 | 2000 | How 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_MAX | integer | 32 | Valkey connections per node (at least 1). |
REDIS_TLS_CA_FILE | path | unset | PEM roots to verify a rediss:// server against, instead of the system’s. Single-server form only. |
REDIS_TLS_CERT_FILE, REDIS_TLS_KEY_FILE | paths | unset | A 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_REGIONS | region list | unset | Regional 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
MASTER_KEY_VERSION | integer ≥ 1 | 1 | Generation number of the current master key, stored with every ciphertext. Bump it when rotating the master key. |
MASTER_KEY_PREVIOUS | version=key list | unset | Older 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_WRAPPER | pkcs11, aws-kms, vault, gcp-kms or azure-key-vault | unset | An 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_PREVIOUS | backend list | unset | Further backends still holding older generations, comma-separated, while rows move off them. |
COOKIE_SECURE | boolean | true | Set 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_PROXIES | CIDR/IP list | empty | Comma-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_NETWORKS | CIDR/IP list | empty | Comma-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_AGE | integer (seconds) | 63072000 (two years) | Strict-Transport-Security max-age, sent only when PUBLIC_URL is https. 0 disables the header. |
SECURITY_CONTACT | URI list | unset | Comma-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_URL | https URL | unset | Written as the document’s Policy field. Needs SECURITY_CONTACT. |
SECURITY_TXT or SECURITY_TXT_FILE | text | unset | The 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_KIB | integer | 19456 (19 MiB) | argon2id memory cost for password hashes. |
ARGON2_T_COST | integer | 2 | argon2id iterations. |
ARGON2_P_COST | integer | 1 | argon2id lanes. |
BREACH_CHECK_URL | URL, or off/none/false | https://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.
| Variable | Type | Default | Meaning |
|---|---|---|---|
GEOIP_COUNTRY_HEADERS | header name list | cloudfront-viewer-country,cf-ipcountry,x-geo-country | Comma-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_HEADERS | header name list | cloudfront-viewer-latitude,x-geo-latitude | Coordinates from the same proxy, needed for impossible travel. A country without them still works. |
GEOIP_LONGITUDE_HEADERS | header name list | cloudfront-viewer-longitude,x-geo-longitude | |
GEOIP_DB | file path | unset | A 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
TLS_CERT | file path | unset | PEM certificate chain. |
TLS_KEY | file path | unset | PEM 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.
| Variable | Type | Default | Meaning |
|---|---|---|---|
MTLS_BIND | socket address | unset | A 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_KEY | file path | TLS_CERT / TLS_KEY | That 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_URL | URL | unset | Where 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_HEADER | header name | unset | The 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.
| Variable | Type | Default | Meaning |
|---|---|---|---|
FIPS_TRANSITION | boolean | false | Allows 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_ITERATIONS | integer | 210000 | PBKDF2-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_HOST | boolean | false | Lets 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. |
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.
| Variable | Type | Default | Meaning |
|---|---|---|---|
SMTP_HOST | host name | unset | Setting 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_PORT | integer | 587 | |
SMTP_USERNAME | string | unset | |
SMTP_PASSWORD | string | unset | |
SMTP_FROM | mailbox | required when SMTP_HOST is set | From header, e.g. "rIDM <no-reply@example.com>". |
SMTP_SECURITY | starttls, tls, none | starttls | tls 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:
| Variable | Type | Default | Meaning |
|---|---|---|---|
RATE_LIMITS | boolean | true | Master switch for every request ceiling. Off only for tests and local experiments. Tenant IP rules are enforced either way. |
RATE_LIMIT_IP_PER_MINUTE | integer | 6000 | Requests 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
LOG_FORMAT | json, pretty (or text) | json | Log line format. JSON lines carry the current span’s fields. |
RUST_LOG | tracing filter | info | Log filter, e.g. info,ridm_api=debug,sqlx=warn. |
METRICS_TOKEN | string | unset | When set, GET /metrics demands Authorization: Bearer <token>; unset, it is open. |
OTEL_EXPORTER_OTLP_ENDPOINT | URL | unset | OTLP/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_NAME | string | ridm | service.name on exported traces. |
AUDIT_SINK_URL | URL | unset | Also 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_TOKEN | string | unset | Sent as Authorization: Bearer <token> to an HTTP(S) audit sink. |
AUDIT_SINK_SECRET | string | unset | Signs each HTTP(S) batch: X-RIDM-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">. |
AUDIT_SINK_CA_FILE | path | unset | PEM certificates to trust for the sink (HTTPS or syslog+tls) instead of the system’s roots. |
HOSTNAME | string | - | 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
| Variable | Type | Default | Meaning |
|---|---|---|---|
RETENTION_DAYS | integer ≥ 1 | 30 | Days 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_CAPACITY | integer ≥ 1000 | 100000 | Events 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.
| Variable | Type | Default | Meaning |
|---|---|---|---|
BOOTSTRAP_ADMIN_EMAIL | unset | Email of the first owner. | |
BOOTSTRAP_ADMIN_PASSWORD | string | unset | Initial password; the account must change it at first sign-in. |
BOOTSTRAP_ADMIN_USERNAME | string | admin | Username of the first owner. |
BOOTSTRAP_SAMPLE_CLIENT | boolean | false | Also 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.
| Command | Purpose |
|---|---|
ridm-api migrate | Apply 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 openapi | Print the admin API’s OpenAPI document; needs no database. |
ridm-api --healthcheck | Exit 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.
| Variable | Flag | Meaning |
|---|---|---|
RIDM_URL | --url | Server origin, e.g. https://id.example.com. |
RIDM_TOKEN | --token | Bearer token to present (an access token or a personal access token). Never written to disk. |
RIDM_TENANT | --tenant | Tenant slug to act on (master by default). |
RIDM_PROFILE | --profile | Stored profile to use. |
RIDM_CONFIG | Path 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.
| Variable | When | Meaning |
|---|---|---|
NEXT_PUBLIC_API_URL | build time | Base URL of the API when the UI is hosted on another origin. Empty (the default) means same origin. |
API_PROXY | next dev only | Proxy /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.
| Operation | API | CLI | Permission |
|---|---|---|---|
| Export | GET /admin/tenants/{slug}/export | ridm 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 |
| Apply | POST /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": []
}
| Field | Required | Meaning |
|---|---|---|
format | yes | must be ridm.tenant/1; anything else is refused |
tenant | yes | name and settings (below) |
profile_schema | no | the user profile schema; defaults to an empty schema |
resource_servers … saml_service_providers | no | collections, 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-consoleandridm-account-consoleclients, the built-inridm:*roles, and the built-in resource serversurn:ridm:adminandurn: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
| Field | Meaning |
|---|---|
slug | informational only: an import applies to the tenant named in the URL, so one document can configure several tenants |
display_name | the tenant’s name |
settings | the 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 section | Fields (default) |
|---|---|
password | min_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) |
session | idle_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_methods | totp (true), email_otp (false), sms_otp (false) |
risk | enabled (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) |
impersonation | enabled (false), max_minutes (60, 1–480) |
auth | first-factor methods: password (true), magic_link, email_otp, sms_otp, passkey (all false) |
registration | enabled (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 |
locale | default ("en"), supported (["en"]) |
branding | logo_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) |
keys | default_alg ("RS256"; also RS384, RS512, ES256, EdDSA), rsa_bits ("B2048"; also B3072, B4096), rotation_interval_days (90, 0 = never), retire_overlap_hours (24) |
discovery | email_domains ([]): WebFinger acct: domains that resolve to this tenant |
dcr | mode ("disabled"; also open, initial_access_token), allowed_grants ([]), require_pkce (true) |
lockout | max_failures (10), lock_minutes (15), ip_max_failures (100), ip_window_minutes (15) |
captcha | after_failures (3, 0 = never), on_registration (true) |
notifications | new_device, password_changed, mfa_changed, email_changed (all true) |
audit | retention_days (365, 0 = forever) |
account | self_deletion (true), deletion_retention_days (30), personal_tokens (true), personal_token_max_days (365, 0 = no limit) |
rate_limits | enabled (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_domain | null, 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:
| Field | Meaning |
|---|---|
name | attribute name, stored under the user’s attributes |
type | string, number, boolean, email, url, phone, date (YYYY-MM-DD), enum, json |
label, description | shown in forms |
required, multivalued | booleans, default false |
editable_by | user (default), admin, none |
visible_in | any of id_token, userinfo, access_token: the attribute appears there as a claim of its name (see Token claims) |
validation | min_length, max_length, pattern (anchored regular expression), min, max, values (for enum) |
order | position in forms |
The schema is replaced as a whole when it differs.
resource_servers
Keyed by identifier.
| Field | Default | Meaning |
|---|---|---|
identifier | the audience URI; immutable | |
name | display name | |
token_ttl_secs | null | caps the lifetime of access tokens for this audience |
signing_alg | null | RS256, 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_access | true | false 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:
| Field | Default | Meaning |
|---|---|---|
status | active | or disabled |
service_account | false | whether 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.
| Field | Meaning |
|---|---|
name | role name |
client | client_id of the client the role belongs to; absent for a realm role |
description | |
composites | role references: name for a realm role, client_id/name for a client role |
permissions | resource-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.
| Field | Meaning |
|---|---|
path | names from the root down, e.g. ["staff", "engineering"]; the last is the group’s own name |
description | |
attributes | a JSON object ({} by default) |
roles | role 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.
| Field | Meaning |
|---|---|
channel | email or sms |
event | the message event, e.g. password_reset |
locale | e.g. en |
subject | email subject (email only) |
body_text | required |
body_html | optional 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).
| Field | Default | Meaning |
|---|---|---|
alias | the provider’s name in /broker/{alias}/... | |
kind | oidc | or oauth2, saml |
display_name | button label | |
preset | null | google, microsoft, github, apple, gitlab |
enabled, hidden | true, false | |
issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri | null | upstream endpoints |
client_id | rIDM’s client id at the provider | |
token_endpoint_auth_method | client_secret_basic | or client_secret_post, none |
scopes | [] | scopes requested upstream |
pkce | true | |
link_policy | verified_email | or explicit, always_new |
trust_email | false | |
mappers | {} | subject, username, email, email_verified (claim names) and attributes (profile attribute → claim) |
sort_order | 0 | |
saml | absent | a 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
- 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, emptyattributesandheadersmade{}. - 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 anupdate, with the changed fields; an item only in the tenant is adelete, but only withprune. Withoutprunean import only adds and changes. - 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": []
}
}
| Field | Meaning |
|---|---|
changes[].resource | tenant, profile_schema, resource_server, scope, client, role, group, claim_mapper, message_template, webhook, ip_rule, mtls_trust_anchor, identity_provider |
changes[].key | the natural key |
changes[].op | create, update, delete |
changes[].fields | for updates: {field, from, to} for each top-level field that differs |
summary | counts; unchanged counts items present on both sides and equal |
applied | changes 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 |
secrets | present 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:
tenanthas nosettings, 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.exampleis the audience of the Orders API and declares two permissions. Tokens requested for it (the client lists it inallowed_audiences, so it is the default audience) carry the permissions the user’s roles hold on it in thepermissionsclaim. - The two custom scopes belong to that resource server.
profile,emailand the other standard scopes are not listed: they exist in every tenant, and withoutprunean import never deletes anything. - The role
orders-managergrants both permissions, referenced asidentifier#name. Users are not in the document; assign the role to users or groups afterwards. orders-webis a confidential client (client_secret_basic). The document carries no secret: the first import creates the client and returns its secret once undersecrets.clients. Omitted fields (token lifetimes,subject_type, and so on) take thewebtype’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:
| Where | Format |
|---|---|
OAuth 2.0 / OIDC endpoints (/token, /par, /device_authorization, /bc-authorize, /introspect, /revoke, /register) | RFC 6749 §5.2 JSON: {"error", "error_description"?} |
/authorize | an HTML page, or a redirect to the client with error |
/userinfo, and the resource side of ridm-auth | RFC 6750 WWW-Authenticate challenge plus a JSON body |
| Admin API, account API, flow API and the other browser endpoints | RFC 9457 application/problem+json |
| SCIM | RFC 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:
error | Status | Typical cause |
|---|---|---|
invalid_request | 400 | missing or repeated parameter; /token body not application/x-www-form-urlencoded |
invalid_client | 401 | unknown 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_grant | 400 | code 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_client | 400 | the client is not allowed this grant type |
unsupported_grant_type | 400 | a grant type rIDM does not know |
invalid_scope | 400 | a 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_target | 400 | an 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_proof | 400 | malformed, 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_pending | 400 | device or CIBA grant: the user has not approved yet (RFC 8628, CIBA Core §11) |
slow_down | 400 | device or CIBA grant: polling faster than the interval |
expired_token | 400 | device or CIBA grant: the device code or auth_req_id expired |
access_denied | 400 | device or CIBA grant: the user denied; /bc-authorize: five requests already wait on the user; dynamic registration disabled (403); a forbidden operation |
unknown_user_id | 400 | /bc-authorize: the hint names no active, unlocked user (CIBA Core §13) |
invalid_binding_message | 400 | /bc-authorize: binding_message longer than 64 characters or holding anything but letters, digits, spaces and -_.:# |
temporarily_unavailable | 503 | a dependency (for example the IP-rule store) could not be read |
server_error | 500 | an 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 page | Cause |
|---|---|
invalid_request | unknown or disabled client, unregistered redirect_uri, a POST that is not form-encoded (415) |
invalid_request_object | a request JWT that does not verify against the client’s keys (RFC 9101) |
invalid_request_uri | an unknown, expired or already used PAR request_uri |
access_denied | the 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
error | Cause |
|---|---|
invalid_request | missing or malformed parameter, missing PKCE where required |
unsupported_response_type | anything other than code |
invalid_scope | a scope the client may not request |
unauthorized_client | the client may not use the authorization code grant |
request_not_supported | a request parameter inside a request object |
request_uri_not_supported | a request_uri that /par did not issue |
login_required, consent_required | prompt=none and the user would have to sign in or consent |
access_denied | the 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"}
| Status | error | Cause |
|---|---|---|
| 401 | invalid_request | no token presented |
| 401 | invalid_token | the 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 |
| 403 | insufficient_scope | the 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:
| Failure | Status | WWW-Authenticate | Body |
|---|---|---|---|
No Authorization: Bearer header | 401 | Bearer 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 verify | 401 | Bearer realm="<realm>", error="invalid_token", error_description="..." | {"error": "invalid_token", "error_description"} |
| Missing scope, permission or role | 403 | Bearer realm="<realm>", error="insufficient_scope", error_description="..." | {"error": "insufficient_scope", "error_description"} |
| Key set or discovery document unreachable | 503 | none | {"error_description"}, with Retry-After: 5 |
| Validator misconfigured | 500 | none | {"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`"
}
| Field | Meaning |
|---|---|
type | a stable URN identifying the kind of error (below); match on this, not on detail |
title | the HTTP reason phrase |
status | the HTTP status |
detail | a human-readable explanation; omitted for 5xx, whose details are logged instead |
errors | for validation only: [{"field", "message"}] |
type | Status | Meaning |
|---|---|---|
urn:ridm:error:bad-request | 400 | malformed 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:validation | 400 | field-level validation failure, listed in errors |
urn:ridm:error:unauthorized | 401 | no token, or a token that is not acceptable |
urn:ridm:error:forbidden | 403 | authenticated 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-required | 403 | a security change needs a recent sign-in (below) |
urn:ridm:error:impersonation-forbidden | 403 | something 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-found | 404 | the resource (or tenant) does not exist or is not visible to the caller |
urn:ridm:error:conflict | 409 | a uniqueness violation (already exists) or a conflicting state |
urn:ridm:error:rate-limited | 429 | a rate limit refused the request; Retry-After is set |
urn:ridm:error:unavailable | 503 | a dependency (CAPTCHA provider, IP-rule store, a mail or SMS backend) failed |
urn:ridm:error:internal | 500 | a 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}
error | Step |
|---|---|
invalid_credentials | password |
account_locked | password, after settings.lockout.max_failures |
invalid_code | one-time codes, magic links, second-step codes |
invalid_passkey | passkey 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:
| Family | Body |
|---|---|
/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 verification | problem document of type urn:ridm:error:rate-limited |
/authorize, brokering | an 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
| Part | What it is | Required |
|---|---|---|
ridm-api | The HTTP server: OIDC and OAuth endpoints, the flow API the sign-in pages drive, the admin, account and SCIM APIs, background jobs | yes |
| Postgres | The system of record: tenants, users, credentials, clients, keys, refresh tokens, audit log, queues. Version 16 or later; CI and the compose file run 18.6 | yes |
| Valkey | Shared 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.1 | yes |
| UI | ui/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 separately | yes, for any browser sign-in |
The deployment’s SMTP defaults (SMTP_*), which tenants may override with their own SMTP server or an HTTP mail API | for verification, recovery, invitations, email one-time codes | |
| SMS | A per-tenant HTTP webhook configured in the admin console; there is no deployment-wide SMS default | only 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_PROXIESandCOOKIE_SECURE; - the same master key (
MASTER_KEYorMASTER_KEY_FILE, withMASTER_KEY_VERSIONandMASTER_KEY_PREVIOUSduring 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 toui/out. LeaveUI_URLunset and build the UI withNEXT_PUBLIC_API_URLempty. TLS and reverse proxies has nginx and Caddy starting points. - Separate host. Build the UI with
NEXT_PUBLIC_API_URL=https://id.example.comand setUI_URLon 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 underUI_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:
| Destination | When |
|---|---|
| SMTP server or HTTP mail API | any tenant sends email |
| SMS webhook | a tenant configured SMS |
| Webhook receivers | a tenant registered webhooks |
| Upstream identity providers (discovery, token and JWKS endpoints) | a tenant uses identity brokering |
| Client JWKS URLs | a client authenticates with private_key_jwt and a jwks_uri |
https://api.pwnedpasswords.com/range/ or BREACH_CHECK_URL | a tenant turns on the breached-password check; BREACH_CHECK_URL=off disables it for the deployment |
AUDIT_SINK_URL | audit export is configured |
OTEL_EXPORTER_OTLP_ENDPOINT | trace 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: the compose file in
deploy/, for evaluation and development. - Production with docker-compose: one host behind nginx, Caddy or Traefik, secrets in files, the backends unreachable from outside.
- Kubernetes (Helm): the chart in
deploy/helm/ridm. - Container image: building and running the image, migrations, health checks.
- TLS and reverse proxies:
PUBLIC_URL,TRUSTED_PROXIES, cookies, the nginx, Caddy and Traefik configurations. - FIPS 140-3: the optional FIPS build, for organizations that must run validated cryptography. Everyone else can skip it.
- Postgres and Valkey: roles, row level security, pools, topologies.
- Scaling and performance and Observability.
- Production checklist.
- Every environment variable: Server configuration.
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
| Service | Profile | Image | What it does |
|---|---|---|---|
postgres | always | postgres:18.6-alpine | The database. On first initialisation it runs deploy/postgres/init-app-role.sh, which creates the migrator and app roles |
valkey | always | valkey/valkey:9.1.2-alpine3.24 | Valkey with append-only persistence, maxmemory 256mb, allkeys-lru |
migrate | always | the rIDM image | One-shot: runs ridm-api migrate as the migrator role, then exits |
api | prod | the rIDM image | The API with JSON logs, Secure cookies, Swagger UI off, no seeding |
mailpit | dev | axllent/mailpit:v1.31.1 | Catches every outgoing email; web UI on 8025, SMTP on 1025 |
api-dev | dev | the rIDM image | The 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.
| Variable | Default | Used for |
|---|---|---|
MASTER_KEY | none, required | The master key |
PUBLIC_URL | http://localhost:${RIDM_HTTP_PORT} | Externally visible base URL; issuers are {PUBLIC_URL}/t/{slug} |
RIDM_HTTP_PORT | 8080 | Host port of api / api-dev |
RIDM_PG_PORT | 5432 | Host port of Postgres |
RIDM_VALKEY_PORT | 6379 | Host port of Valkey |
RIDM_MAILPIT_UI_PORT | 8025 | Host port of the Mailpit web UI |
RIDM_MAILPIT_SMTP_PORT | 1025 | Host port of Mailpit’s SMTP listener, for an API run outside compose |
RIDM_IMAGE | ghcr.io/zerosandonesllc/ridm:latest | Image for migrate, api, api-dev |
POSTGRES_PASSWORD | ridm | Password of the ridm superuser |
RIDM_MIGRATOR_USER, RIDM_MIGRATOR_PASSWORD | ridm_migrator / ridm_migrator | The schema-owning role migrate connects as |
RIDM_APP_USER, RIDM_APP_PASSWORD | ridm_app / ridm_app | The DML-only role the API connects as |
LOG_FORMAT | json (api); pretty in api-dev | Log format |
RUST_LOG | info (api); info,ridm_api=debug in api-dev | Log filter |
TRUSTED_PROXIES | empty | Proxies whose forwarding headers are honoured |
COOKIE_SECURE | true (api); forced false in api-dev | Secure cookies |
DOCS_ENABLED | false (api); forced true in api-dev | Swagger UI at /docs |
BOOTSTRAP_ADMIN_EMAIL, BOOTSTRAP_ADMIN_PASSWORD | admin@ridm.local / ChangeMe-Now-1234 | api-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
| Volume | Mounted at | Holds |
|---|---|---|
pgdata | /var/lib/postgresql | The database |
valkeydata | /data | Valkey’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
| Service | Image | Networks | What it does |
|---|---|---|---|
postgres | postgres:18.6-alpine | backend | The database. First initialisation creates the migrator and app roles (deploy/postgres/init-app-role.sh) with passwords from secret files |
valkey | valkey/valkey:9.1.2-alpine3.24 | backend | Valkey with a password, append-only persistence and noeviction |
migrate | the rIDM image | backend | One-shot: ridm-api migrate as the schema owner, then exits |
ridm | the rIDM image | edge, backend | The server, with the embedded UI. Not published on the host |
caddy, nginx or traefik | see below | edge | The 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
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.
| Proxy | Image | Certificates | Configuration |
|---|---|---|---|
| Caddy | caddy:2.11.4-alpine | ACME (RIDM_TLS_MODE=acme, default) or files | deploy/proxy/caddy/Caddyfile |
| nginx | nginx:1.30.5-alpine | files only | deploy/proxy/nginx/templates/default.conf.template |
| Traefik | traefik:v3.7.13 | ACME (default) or files | the 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):
| Variable | Default | Used for |
|---|---|---|
COMPOSE_PROFILES | none | The proxy: caddy, nginx or traefik |
RIDM_DOMAIN | required | The public host; PUBLIC_URL is https://{RIDM_DOMAIN} |
RIDM_CUSTOM_DOMAINS | empty | Tenants’ custom domains, space-separated |
RIDM_TLS_MODE | acme | acme or files (nginx always uses files) |
BOOTSTRAP_ADMIN_EMAIL | empty | First administrator, created on first start |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_FROM, SMTP_SECURITY | empty, 587, empty, empty, starttls | Server-wide SMTP fallback |
RIDM_IMAGE | ghcr.io/zerosandonesllc/ridm:latest | The rIDM image |
RIDM_MEMORY_LIMIT | 1g | Memory limit of the server container |
RIDM_PG_SHARED_BUFFERS | 512MB | Postgres shared_buffers |
RIDM_VALKEY_MAXMEMORY | 512mb | Valkey maxmemory; with noeviction, writes fail when it is reached, so size it with headroom |
RIDM_HTTP_PORT, RIDM_HTTPS_PORT | 80, 443 | Host ports of the proxy |
RIDM_EDGE_SUBNET, RIDM_PROXY_ADDRESS | 172.30.53.0/24, 172.30.53.10 | The proxy network and the proxy’s address in it; change both together, and only on a clash |
RIDM_SECRETS_DIR, RIDM_CERTS_DIR | ./secrets, ./certs | Where the secret files and certificates are |
RUST_LOG | info | Log 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.
/metricsis refused at the proxy and needs the token insecrets/metrics_token. Scrape it from a Prometheus attached to theedgenetwork:http://ridm:8080/metricswith that bearer token. - Logs. JSON lines on the server’s standard output:
docker compose logs ridm. - Upgrading. Change
RIDM_IMAGEanddocker compose up -d.migrateruns 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 superuserridm, which row level security does not stop). Keep a copy ofsecrets/master_keysomewhere else: never beside the dumps. See Backup and restore. - Security contact. Set
SECURITY_CONTACTin.envso that/.well-known/security.txttells 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=...) runsnpm ciandnpm run buildinui/withNEXT_PUBLIC_API_URLempty, producing the same-origin static exportui/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 buildsridm-apiin release mode with--lockedand--features embedded-ui, which compilesui/outinto 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
| Property | Value |
|---|---|
| Entrypoint | /ridm-api |
| User | nonroot:nonroot (distroless’s UID/GID 65532) |
| Port | 8080 (EXPOSE 8080, BIND_ADDR=0.0.0.0:8080 set in the image) |
| Signals | SIGTERM or SIGINT stops accepting connections and drains in-flight requests for up to 20 seconds |
| Configuration | Environment 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.
| Arguments | What it does | Exit status |
|---|---|---|
| (none) | Runs the server | 1 on a fatal error, 2 on a configuration error |
migrate | Applies pending migrations and exits | 0 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 stop | 0 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 generation | 0 done, 1 failure |
openapi | Prints the OpenAPI document of the admin API | 0 |
--healthcheck | Probes 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
| Endpoint | Meaning | Use as |
|---|---|---|
GET /healthz | The process is up and serving HTTP. Never touches Postgres or Valkey. 200 {"status":"ok","version":"..."} | Liveness probe |
GET /readyz | Postgres 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
-
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.shcreates both. -
Valkey 9+ or Redis 8+, standalone, Sentinel or Cluster.
-
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)" -
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
| Piece | Behaviour |
|---|---|
| Migrations | A 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 |
| Pods | Non-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 key | Mounted 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 |
| Probes | Startup 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-up | Replicas starting together take turns at bootstrap and the built-in clients under a Postgres advisory lock |
| Rollouts | Rolling 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 |
| Spreading | Soft topology spread across nodes and zones unless you set your own constraints |
| Availability | A PodDisruptionBudget (maxUnavailable: 1) whenever more than one replica runs |
| Scaling | autoscaling.enabled adds an HPA on CPU (and memory if set). Nodes are stateless; see Scaling and performance |
| Metrics | metrics.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: fipsrunsghcr.io/zerosandonesllc/ridm:<version>-fips. To pin by digest, setimage.digestto the FIPS image’s own and keep-fipsinimage.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,runAsGroupandfsGroupare set tonull, since therestricted-v2SCC 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.enabledadds a Route (edge TLS at the router, HTTP redirected) in place of the Ingress; the router forwardsX-Forwarded-For, so put its pods’ network intrustedProxies.
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
| Artifact | Where | Platforms |
|---|---|---|
| Container image | ghcr.io/zerosandonesllc/ridm:{version} | linux/amd64, linux/arm64 |
| FIPS 140-3 image | ghcr.io/zerosandonesllc/ridm:{version}-fips (FIPS 140-3) | linux/amd64, linux/arm64 |
| Helm chart | oci://ghcr.io/zerosandonesllc/charts/ridm, and ridm-{version}.tgz on the GitHub release | |
| Static binaries | ridm-{version}-linux-amd64.tar.gz, ridm-{version}-linux-arm64.tar.gz on the GitHub release | linux x86_64, aarch64 |
| SBOM | attached 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 | |
| Checksums | SHA256SUMS 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-Securityis 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
Hostheader (rIDM matches custom domains by it, or byX-Forwarded-Hostfrom a trusted proxy); - append the client address to
X-Forwarded-For(or sendForwarded: 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:
| Setting | Effect |
|---|---|
| Empty behind a proxy | Every 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 tier | Correct |
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_SECURE | Session cookie | Trusted-device cookie | Attributes |
|---|---|---|---|
true (default) | __Host-ridm_session_acme | __Host-ridm_device_acme | Secure |
false | ridm_session_acme | ridm_device_acme | none 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.
| Backend | KEY_WRAPPER | Cargo feature | Binds the generation | Authentication |
|---|---|---|---|---|
| PKCS#11 HSM (Thales Luna, Entrust nShield, YubiHSM 2, AWS CloudHSM, SoftHSM…) | pkcs11 | hsm-pkcs11 | yes (AES-GCM additional data) | the token’s user PIN |
| AWS KMS | aws-kms | kms-aws | yes (encryption context) | the AWS SDK’s chain: IRSA or EKS Pod Identity, ECS task role, instance profile, SSO, environment |
| HashiCorp Vault or OpenBao, Transit engine | vault | kms-vault | by key reference | a token, or the Kubernetes auth method with the pod’s service-account token |
| Google Cloud KMS | gcp-kms | kms-gcp | yes (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 HSM | azure-key-vault | kms-azure | by key id | workload 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_KEYalways 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
| Variable | Meaning |
|---|---|
KEY_WRAPPER | The backend new generations are wrapped by. Setting it makes MASTER_KEY optional. |
KEY_WRAPPER_PREVIOUS | Further backends, comma-separated, that still hold older generations while rows move off them (switching from one backend to another). |
PKCS#11
| Variable | Default | Meaning |
|---|---|---|
PKCS11_MODULE | required | Path of the vendor’s PKCS#11 library (.so). In the image, mount it and its dependencies into the container. |
PKCS11_TOKEN_LABEL | unset | The token, by label; else PKCS11_SLOT; else the first slot with a token. |
PKCS11_SLOT | unset | The token, by slot id. |
PKCS11_PIN / PKCS11_PIN_FILE | required | The user PIN. |
PKCS11_KEY_LABEL | ridm-master-key | The AES-256 key (CKA_LABEL) that wraps the data keys, with AES-GCM. |
PKCS11_GENERATE_KEY | false | Create 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
| Variable | Default | Meaning |
|---|---|---|
AWS_KMS_KEY_ID | required | A 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_ENDPOINT | unset | Another 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)
| Variable | Default | Meaning |
|---|---|---|
VAULT_ADDR | required | The server, e.g. https://vault.example.com:8200. |
VAULT_TRANSIT_KEY | required | The Transit key’s name. |
VAULT_TRANSIT_MOUNT | transit | Where the Transit engine is mounted. |
VAULT_TOKEN / VAULT_TOKEN_FILE | A token, unless VAULT_KUBERNETES_ROLE is set. | |
VAULT_KUBERNETES_ROLE | unset | Log in through the Kubernetes auth method as this role, with the pod’s service-account token. Works the same on OpenShift. |
VAULT_KUBERNETES_MOUNT | kubernetes | Where that auth method is mounted. |
VAULT_KUBERNETES_TOKEN_FILE | /var/run/secrets/kubernetes.io/serviceaccount/token | The service-account token to present (a projected token with Vault’s audience, if you use one). |
VAULT_NAMESPACE | unset | Vault Enterprise / HCP namespace. |
VAULT_CACERT | unset | PEM 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
| Variable | Default | Meaning |
|---|---|---|
GCP_KMS_KEY | required | projects/P/locations/L/keyRings/R/cryptoKeys/K, a symmetric encrypt/decrypt key. |
GOOGLE_APPLICATION_CREDENTIALS | unset | A 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_HOST | metadata.google.internal | The metadata server’s host. |
GCP_KMS_ENDPOINT | https://cloudkms.googleapis.com | Another 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
| Variable | Default | Meaning |
|---|---|---|
AZURE_KEY_VAULT_URL | required | https://<name>.vault.azure.net or https://<name>.managedhsm.azure.net. |
AZURE_KEY_VAULT_KEY | required | The key’s name. |
AZURE_KEY_VAULT_KEY_VERSION | the current version | Pin a version. Each generation records the version it used either way. |
AZURE_KEY_VAULT_ALGORITHM | RSA-OAEP-256 | RSA-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_ID | unset | The application (or a user-assigned managed identity’s client id). |
AZURE_FEDERATED_TOKEN_FILE | unset | Workload identity: the federated token the AKS webhook (or your own projection) provides, re-read on every call. |
AZURE_CLIENT_SECRET / AZURE_CLIENT_SECRET_FILE | unset | A client secret, when there is no federated token. |
AZURE_AUTHORITY_HOST | https://login.microsoftonline.com | Entra ID’s host (sovereign clouds). |
IDENTITY_ENDPOINT, IDENTITY_HEADER | unset | App 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
- Add the backend to every node, keeping
MASTER_KEY(andMASTER_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. - 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). - Check with
ridm master-key statusthat no row is left under the environment’s generations. - Remove
MASTER_KEY(andMASTER_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
| Data | Where |
|---|---|
| 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 tokens | the 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 generations | the home database |
| Leader locks, cache-invalidation messages, the deployment-wide per-address rate limit, the mapping from an opaque token’s hash to its tenant | the 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:
- 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; - 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;
- re-verifies the copied audit chain;
- copies the tenant’s Valkey keys with their remaining lifetimes, when the two sides use different Valkeys (sessions survive the move);
- points the registry at the target, which ends the outage;
- deletes the source’s copy, rows and keys, and records
tenant.movedin 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:
/readyzreports each region underchecks.regionsbut 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 onchecks.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 migratemigrates every database and fails if one cannot be reached; run it again once the region is back. A node starting withMIGRATE_ON_STARTwhile 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
503there. - Connections: each node opens
DB_POOL_MAXconnections 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 it | cargo build, or the ghcr.io/zerosandonesllc/ridm:<version> image | --features fips, or the ghcr.io/zerosandonesllc/ridm:<version>-fips image |
| Cryptographic module | aws-lc-rs (AWS-LC) | aws-lc-rs on the AWS-LC FIPS module, for everything: neither build links OpenSSL |
| New password hashes | argon2id | PBKDF2-HMAC-SHA512 |
| Secrets at rest | XChaCha20-Poly1305, AES-256-GCM from the next release (reads both) | AES-256-GCM |
| TLS | TLS 1.2 and 1.3 with every rustls suite and group, including ChaCha20 and X25519 | TLS 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 mode | yes | no, unless FIPS_ALLOW_NON_FIPS_HOST=true (development and CI only) |
| Platforms | image (amd64, arm64) and static musl binaries | image 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,clangandperl. The AWS-LC FIPS module is compiled from source byaws-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, runfips-mode-setup --enableand 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_enabledis1), 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=trueEvery 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
ridmcommand line and everyridm-apisubcommand, 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-fullwithsslcert/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’sdatabase.tlsandmigrations.database.tlsmount the certificates and build the URLs (Kubernetes). -
Valkey: likewise, authenticate by client certificate rather than a password:
rediss://withREDIS_TLS_CA_FILE,REDIS_TLS_CERT_FILEandREDIS_TLS_KEY_FILE, and Valkey’stls-auth-clients-user CNmapping 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
| Variable | Default | Meaning |
|---|---|---|
PBKDF2_ITERATIONS | 210000 | PBKDF2-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_TRANSITION | false | Allows the one-time legacy operations described below. Turn it off when the move is done. |
FIPS_ALLOW_NON_FIPS_HOST | false | Lets 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.
- 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.
- Switch to the
-fipsimage withFIPS_TRANSITION=true. It writes new secrets with AES-256-GCM. - Re-encrypt secrets at rest with
ridm-api rotate-master-key(orridm 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 --statusshows what’s left inlegacy_cipher_rows. - 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(alsobcrypt,md5, …); a user’s detail shows its format as well. Reset those users’ passwords whenever you decide. - Turn
FIPS_TRANSITIONoff. 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
| Feature | In the FIPS build | Issue |
|---|---|---|
| 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 keys | Available 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 numbers | Uses 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 rest | AES-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 |
| Passwords | PBKDF2-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-in | The 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 KMS | Uses 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 Vault | REST over the FIPS TLS provider. The key itself is protected by the service’s own validation. | — |
| PKCS#11 HSM | The HSM performs the key operations, so its own FIPS validation is the one that counts. | — |
| SAML and OIDC | SHA-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
| Service | Supported | Tested |
|---|---|---|
| Postgres | 16 or later | 18.6 (CI, compose) |
| Valkey | Valkey 9; Redis-compatible servers with GETDEL and EXPIRE … NX/GT (Redis 7.0+) should work but are not tested | 9.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:
| Role | Default name (compose) | Privileges | Used by |
|---|---|---|---|
| migrator | ridm_migrator | Owns the schema: CONNECT, CREATE, TEMP on the database, ALL on schema public | ridm-api migrate |
| app | ridm_app | CONNECT, TEMP; USAGE on the schema; SELECT, INSERT, UPDATE, DELETE on tables, USAGE, SELECT on sequences, EXECUTE on functions | the 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.
| Setting | Default | Meaning |
|---|---|---|
DB_POOL_MIN | 2 | Connections kept open per pool |
DB_POOL_MAX | 20 | Upper bound per pool; must be at least DB_POOL_MIN |
REDIS_POOL_MAX | 32 | Valkey 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:
| Form | Topology |
|---|---|
redis://host:6379 or redis://host:6379/2 | One server (the path picks the database number) |
rediss://host:6380 | One server over TLS |
redis+cluster://host1:7000,host2:7001 | A 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/mymaster | Sentinel-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_FILEandREDIS_TLS_KEY_FILE: a client certificate and its key, both PEM. Valkey can then authenticate the connection by the certificate instead of a password: withtls-auth-clients yesit accepts only certificates from itstls-ca-cert-file, and withtls-auth-clients-user CNit 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 -@adminwith
REDIS_URL=rediss://valkey.internal:6380(no credentials) and a certificate whose subject isCN=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 sessions | A durable record of each session (sso_sessions) |
| Authorization codes, PAR requests, device codes and user codes | Refresh 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 state | Users, 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 expires | DCR initial access tokens (hashed) |
| Rate-limit counters | Audit 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_accesskeep 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. LowerDB_POOL_MAXas 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.
| Job | Every | Lock time-to-live | What it does |
|---|---|---|---|
key_rotation | 1 h | 10 min | Rotates and retires signing keys per each tenant’s key policy |
audit_retention | 24 h | 30 min | Creates upcoming audit partitions, purges audit rows past the tenant’s retention |
saml_metadata_refresh | 1 h | 1 h | Re-reads the metadata URL of SAML identity providers last read more than a day ago |
ldap_sync | 5 min | 1 h | Syncs the LDAP directories whose sync interval has passed (each directory also holds its own lock while it syncs) |
user_purge | 24 h | 30 min | Hard-deletes soft-deleted users past the tenant’s retention, visiting only tenants that have some, 500 users per transaction |
webhook_delivery | 30 s | 2 min | Requeues deliveries a stopped node left in sending, then retries those whose backoff has elapsed, four tenants at a time |
message_delivery | 30 s | 2 min | Requeues messages a stopped node left in sending, then sends queued and retrying email and SMS, four tenants at a time |
cleanup | 1 h | 10 min | Deletes 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:
- 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);
- Valkey, shared by all nodes, with longer lifetimes (minutes);
- 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.
| Run | Load | Thresholds | Where it runs |
|---|---|---|---|
smoke (SMOKE=1) | 20 VUs on /token, 2 on the documents, 30 s | errors below 1 %, token p95 below 500 ms, documents p95 below 200 ms | every pull request (load-smoke job, debug build) |
| baseline | 200 VUs, 2 min (VUS, DURATION) | errors below 1 %, token p99 below 50 ms, documents p99 below 10 ms | the 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
| Endpoint | Checks | Response |
|---|---|---|
GET /healthz | Nothing beyond the process answering HTTP | Always 200 {"status":"ok","version":"0.4.1"} while the server runs |
GET /readyz | SELECT 1 on the primary Postgres pool and PING to Valkey, in parallel, and the depth of the in-process event queues | 200 {"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.
| Variable | Default | Values |
|---|---|---|
LOG_FORMAT | json | json for production; pretty (or text) for a terminal |
RUST_LOG | info | A 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:
| Log | Level | Meaning |
|---|---|---|
job failed (field job) | error | A background job pass failed |
readiness: database check failed, readiness: cache check failed | warn | /readyz is returning 503 |
cache invalidation listener disconnected | warn | A node lost its pub/sub subscription; it will reconnect |
audit: could not record event | error | An audit row was not written |
audit: creating partitions failed | error | The 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 retry | warn | The export sink’s receiver refused or could not be reached; the rows are sent again after a backoff |
audit chain does not verify | error | The scheduled check found a tenant’s audit chain broken (see the scheduled check) |
webhook delivery dead-lettered | warn | A webhook exhausted its retries |
slow statement warnings from sqlx | warn | A 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.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
ridm_http_requests_total | counter | method, route, status | Every request. route is the matched pattern (/t/{slug}/token), or unmatched, never the raw path |
ridm_http_request_duration_seconds | histogram | method, route | Request latency |
ridm_token_requests_total | counter | grant, outcome | /token requests; outcome is issued or the OAuth error code |
ridm_logins_total | counter | method, outcome | First-factor sign-ins. Password failures carry the reason (invalid_credentials, locked, disabled, …); successes carry the first authentication method |
ridm_sessions_created_total | counter | Browser sessions opened | |
ridm_rate_limit_rejections_total | counter | Requests refused by a rate limit | |
ridm_ip_rule_rejections_total | counter | scope (tenant, client) | Requests refused by an IP rule |
ridm_webhook_deliveries_total | counter | outcome (delivered, retry, dead) | Webhook delivery attempts |
ridm_webhook_deliveries_pending | gauge | Deliveries waiting, refreshed by the delivery job | |
ridm_messages_queued | gauge | Email and SMS waiting, refreshed by the delivery job | |
ridm_job_runs_total | counter | job, outcome (ok, error) | Background job passes |
ridm_job_duration_seconds | histogram | job | Background job pass duration |
ridm_cleanup_rows_total | counter | table | Rows deleted by the cleanup job |
ridm_audit_events_total | counter | Audit rows written | |
ridm_audit_queue_depth | gauge | Events waiting for the audit writer on this node | |
ridm_webhook_dispatch_queue_depth | gauge | Events waiting for the webhook dispatcher on this node | |
ridm_event_queue_depth | gauge | subscriber (audit, webhooks) | Events waiting in an in-process consumer’s queue, read at scrape time (so a stuck consumer still shows) |
ridm_event_queue_capacity | gauge | subscriber | That queue’s bound (EVENT_QUEUE_CAPACITY); /readyz fails at 80% of it |
ridm_event_queue_dropped_total | counter | subscriber | Events dropped because the queue was full |
ridm_audit_parked_events | gauge | Audit 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_total | counter | Audit events dropped because too many were held for unavailable chains | |
ridm_audit_sink_rows_total | counter | Audit rows shipped to the sink | |
ridm_audit_sink_failures_total | counter | Sink deliveries that failed (they are retried) | |
ridm_audit_sink_lag_rows | gauge | Audit rows recorded but not yet shipped, over every chain | |
ridm_audit_chain_breaks_total | counter | Audit chains the scheduled check found newly broken | |
ridm_audit_chains_broken | gauge | Audit 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_totalacross nodes. A node that skipped a pass because another held the lock records anokpass too; alert onoutcome="error"rather than on the absence of runs. ridm_messages_queuedandridm_webhook_deliveries_pendingare 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:
| Attribute | Value |
|---|---|
| span name | METHOD /route/{template}, e.g. POST /t/{slug}/token; unmatched for a path no route matched |
otel.kind | server |
http.request.method | The method |
http.route | The matched route template |
http.response.status_code | The 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
| What | Back up | Why |
|---|---|---|
| The Postgres database | Yes, continuously or at least daily | Everything 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 it | Everything of the tenants placed in that region; the home database only registers them. See Data residency |
| The master key, every generation still in use | Yes, once per generation, apart from the database backups | Without it the database’s secrets cannot be read (below) |
| Configuration and the other secrets | Yes, with the rest of your deployment configuration | Environment variables, database and Valkey passwords, METRICS_TOKEN, SMTP password, TLS certificates, proxy configuration |
| The Postgres roles | Recorded, not dumped | pg_dump covers one database; roles belong to the server. Keep the SQL that creates them (Postgres and Valkey) |
| Valkey | No | Sessions, flows in progress, caches and counters. Losing them signs users out and nothing more lasting; see If Valkey is lost |
| The release you run | Its version or image digest | Restore 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_generationsnames it. - Record the generation with each backup.
ridm-api rotate-master-key --status(orridm master-key status) prints how many rows each generation holds; normally that is the currentMASTER_KEY_VERSIONalone.
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
ridmin 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
BYPASSRLSrole. The policy admits a transaction that setsapp.bypass_rls, so pass it as a connection option and tellpg_dumpto 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
/readyzanswers200on every node.ridm-api rotate-master-key --statusshows 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’sseqnumbers, 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’sid. - 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-Deliveryis 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:
-
Generate a new master key (
openssl rand -hex 32) and give it aMASTER_KEY_VERSIONhigher 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. -
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; -
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.
-
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 newclient_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.
- Identity providers: set the client secret again, in the console or with
-
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.
-
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
requiredMFA 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
- Read the release notes, back to the release you run.
- 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.
- Verify the release you are about to run (Releases and verification), and pin it by version or, better, by image digest.
- 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
- Install the new binaries next to the old ones.
- Run
ridm-api migrateonce, withDATABASE_URLset to the migrator role. It takes an advisory lock, so a second run started by mistake waits and then finds nothing to do. - Restart the nodes one at a time with the new
ridm-api, waiting for/readyzon each before the next. The server finishes in-flight requests for up to 20 seconds afterSIGTERM.
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
/readyzanswers200on every node, and/healthzreports the newversion.- The start-up log has no
database migrations are pendingwarning. - 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=20270301120000The warning stays until you upgrade again. Its
ridm-api migraterefuses 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 asMASTER_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_rotationjob 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_URLis 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_CERTandTLS_KEY, and plain http redirects to https. TLS and reverse proxies -
COOKIE_SECUREistrue(the default). Sign-in then works only over https, which is the point. TLS and reverse proxies -
TRUSTED_PROXIESlists 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_URLunset), orui/outon 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_ENABLEDis off (the default), so Swagger UI is not served at/docs./openapi.jsonis served either way; it describes the API and holds no data. Server configuration -
/.well-known/security.txtnames your security contact (SECURITY_CONTACT, or your own document inSECURITY_TXT_FILE); it answers404until you set one. Server configuration -
/metricsis protected:METRICS_TOKENis 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=trueis 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 hadINSERTon the audit log; an app role created later needs the grant by hand. Postgres and Valkey - Pool sizes fit
max_connectionsacross 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-changewas used). RemoveBOOTSTRAP_ADMIN_EMAILandBOOTSTRAP_ADMIN_PASSWORDfrom the environment afterwards (or emptysecrets/bootstrap_admin_passwordin the production compose stack). Production with docker-compose, Administrator access - Administrators use a second factor. MFA policy
- Email works:
SMTP_HOST,SMTP_FROM(required onceSMTP_HOSTis set),SMTP_SECURITY(starttlsby 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) andRATE_LIMIT_IP_PER_MINUTEsuits your traffic; tenant ceilings are reviewed. Never carryRATE_LIMITS=falseover 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_DAYSand 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.jsif 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 moves | How | Where it is described |
|---|---|---|
| Configuration: settings, profile schema, resource servers and permissions, scopes, clients, roles, groups, claim mappers, templates, webhooks, IP rules, identity providers | The tenant configuration document (ridm.tenant/1), applied with ridm tenant import or POST /admin/tenants/{slug}/import | Configuration as code, Tenant configuration document |
| Users, their role and group memberships, and their password hashes | Bulk 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 directory | SCIM 2.0 | SCIM provisioning |
| Users who keep signing in through the old system for a while | Identity brokering, with the old system as an upstream OpenID Connect provider | Identity brokering |
What carries over and what does not
| Item | Carries over? | Notes |
|---|---|---|
| Client IDs | Yes | A 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 URIs | Yes, if exact | rIDM 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 secrets | No | A 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 URL | No | See Issuer, endpoints and keys |
| Signing keys | No | The tenant has its own keys; no key material is imported |
User IDs (sub) | No | Imported users get new ids. See Subject identifiers |
| Password hashes | Where the format is supported | Verified in the original format at the user’s first sign-in, then replaced with argon2id. See Passwords |
| TOTP secrets, passkeys, recovery codes | No | There is no import path for second factors; users enrol again |
| Sessions, refresh tokens, consents | No | Users sign in again; consent screens are shown again unless the client has require_consent: false |
| Linked social or enterprise accounts | Not directly | Links are re-established at the user’s next brokered sign-in; see Brokered users |
| Audit history | No | Keep 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_urifrom discovery) and check the newiss. 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’siss. Withridm-auththat is oneValidatorper issuer. - A custom domain lets you keep a hostname that users recognise (for example moving
login.example.comfrom 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:
- 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. - Put the old id in each import row’s
attributes. - Publish it. Listing
id_token,userinfoandaccess_tokenin the attribute’svisible_inemits 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
- Create the tenant:
ridm tenant create acme --name "Acme"(needs amasterowner). - 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. - 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. - Set the secrets the document cannot carry: identity provider client secrets, SMTP, SMS and CAPTCHA provider settings.
- 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. - Test with a known account: password sign-in, the upgraded hash, second-factor enrolment, a brokered sign-in, and the claims your APIs read.
- Deploy the new client secrets, point clients at the new issuer, and teach APIs the new issuer and JWKS.
- 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
| Keycloak | rIDM | Notes |
|---|---|---|
| Realm | Tenant | Issuer changes from https://kc.example.com/realms/acme to https://id.example.com/t/acme (or a custom domain) |
| Client (OpenID Connect) | Client | client_id and redirect URIs carry over; secrets do not |
| Bearer-only client, or a client that only represents an API | Resource server | Its clientId becomes the resource server identifier, so the aud your API checks can stay the same |
| Client scope | Scope | A 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 role | Role (no client) | |
| Client role | Role with client set to the client’s client_id | Referenced as client_id/name in composites and group roles |
| Client role on an API client | Permission on that resource server, granted to roles | rIDM separates “what the user may do in this API” (permissions, in the permissions claim) from role names |
| Composite role | Role composites | |
| Group, subgroups, group attributes | Group with path, attributes | Sibling names are unique; the same name may appear under different parents |
| Group role mappings | Group roles | |
| User attributes, user profile (declarative) | Profile schema and user attributes | Undeclared attributes are refused unless allow_undeclared is on |
| Protocol mapper | Claim mapper | See 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 writable | Keycloak’s UNSYNCED mode has no equivalent; users are always linked to the directory |
| User federation (Kerberos), Kerberos authenticator | Kerberos provider (kerberos): SPNEGO with the service’s keytab, users matched by name or looked up in an LDAP provider | AES tickets only (no RC4, no RFC 8009 types); no credential delegation |
| Required actions | See Required actions | |
| Authentication flows | Tenant settings: auth, mfa, mfa_methods, lockout, captcha, registration | rIDM’s flow is fixed; its steps are switched on and off, not rearranged |
| Password policy | settings.password | See Realm settings |
| Brute-force detection | settings.lockout | |
| Events, admin events | Audit log and webhooks | |
| Themes, email templates | settings.branding, message templates | Templates are rewritten, not converted |
| Service account of a client | service_account: true on the client | |
| Token exchange | Token exchange (RFC 8693), per-client allowed_audiences | |
| Offline access, offline sessions | The offline_access scope | A 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 supported | rIDM’s authorization model is roles granting permissions on resource servers; there is no policy engine or UMA |
| Custom SPIs (authenticators, mappers, storage) | Not supported | Rebuild the behaviour with claim mappers, webhooks or in the application |
| SAML clients | SAML service providers | Registered 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 tonative, which allows custom URL schemes and any loopback port. webOriginsof+(“the origins of the redirect URIs”) and*are dropped; list the origins a browser client calls the token endpoint from incors_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-jwtauthentication has no equivalent; the script maps it toclient_secret_basic.client-jwtbecomesprivate_key_jwtwith the client’sjwks.url; a client with an inline key needs itsjwksadded 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
| Keycloak | rIDM (tenant.settings) |
|---|---|
Password policy length(n), maxLength(n) | password.min_length, password.max_length |
upperCase, lowerCase, digits, specialChars | password.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, notEmail | Always on: a password may not contain the username or the email’s local part (when four characters or longer) |
hashAlgorithm, hashIterations | Deployment-wide argon2id parameters, ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST (Server configuration) |
| SSO Session Idle / Max | session.idle_timeout_secs, session.absolute_timeout_secs |
| Access Token Lifespan | session.access_token_ttl_secs (clients and resource servers can override) |
| User registration, Verify email | registration.enabled, registration.require_email_verification |
| Terms and conditions (required action) | registration.require_terms, registration.terms_url |
| Brute force detection: max login failures, wait increment | lockout.max_failures, lockout.lock_minutes (a fixed lock, not an increasing one) |
| Internationalization, supported locales | locale |
| OTP policy | Fixed: TOTP, SHA-1, six digits, 30-second steps |
See Tenants and tenant settings for every setting.
Required actions
| Keycloak required action | rIDM |
|---|---|
UPDATE_PASSWORD | must_change_password: true on the import row (the user script below sets it) |
CONFIGURE_TOTP | MFA policy required (tenant-wide) or required_for_roles; there is no per-user flag |
UPDATE_PROFILE | Mark profile attributes required: a user missing one is asked for it at sign-in |
TERMS_AND_CONDITIONS | registration.require_terms: every user who has not accepted, imported users included, is asked at sign-in |
VERIFY_EMAIL | No equivalent at sign-in. Imported users keep the email_verified they had |
webauthn-register | Passkey 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 mapper | rIDM 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 login | rIDM link_policy |
|---|---|
| Automatically link an existing account with a verified, matching email | verified_email |
| Review profile / confirm link, then link after re-authentication | explicit (the user links from the account console) |
| Always create a new account | always_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 algorithm | Keycloak default | rIDM format | Conversion |
|---|---|---|---|
pbkdf2-sha256 | 600 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-sha512 | 210 000 iterations, 512-bit key | $pbkdf2-sha512$<iterations>$<salt>$<hash> | Copy |
argon2 | argon2id, 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) | none | Not supported; those users need a password reset |
| a custom hash provider | Not 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_idand every custom attribute) in the tenant’s profile schema, or turn onallow_undeclared; an undeclared attribute fails its row.given_nameandfamily_nameare among the claims theprofilescope releases.keycloak_idis there to map the oldsub(Keycloak’ssubis the user’sid); declare iteditable_by: noneso 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
clientRolesare 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 (
otpandwebauthncredentials 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
| Auth0 | rIDM | Notes |
|---|---|---|
| Tenant | Tenant | Issuer 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 Page | Client spa | |
| Application: Regular Web | Client web | |
| Application: Native | Client native | Custom URL schemes and any loopback port allowed |
| Application: Machine to Machine | Client machine | See Machine-to-machine applications |
| Device authorization flow on an application | Client device, or the device grant on another type | |
| API | Resource server | The 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 permissions | Roles with permissions (<API identifier>#<permission>) | |
| User role assignments | roles on each import row | Not in the user export; see Role assignments |
user_metadata, app_metadata | User attributes, declared in the profile schema with editable_by: user, admin or none | none values are set only by bulk import and SCIM |
| API setting “Allow Offline Access” | Resource server allow_offline_access | Without it, offline_access is dropped from grants for that API and refresh tokens end with the user’s session |
| Database connection | Local users with passwords | |
| Social connections | Identity 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, ADFS | SAML identity provider (saml) | |
| Enterprise: AD/LDAP | LDAP 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 users | rIDM 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 equivalent | rIDM cannot check a password against another system at sign-in |
| Organizations | Organizations | Members, verified domains with auto-join, invitations, per-organization roles and administrators; the org_id claim |
| Rules, Actions | Claim mappers, where they only add claims | See Rules and Actions |
| MFA (Guardian): one-time password, SMS, email, WebAuthn | Second factors: TOTP, SMS and email codes, passkeys; policy in settings.mfa | Push notifications are not supported. Enrolments do not carry over |
| Adaptive MFA | Risk-based authentication | New 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 throttling | settings.lockout (per user and per IP) | |
| Attack protection: breached password detection | settings.password.check_breached | Checks passwords as they are set, against a Have I Been Pwned compatible range API |
| Bot detection | settings.captcha (Turnstile or hCaptcha) | |
| Universal Login branding, email templates | settings.branding, message templates | Templates are rewritten, not converted |
| Log streams | Webhooks and the audit log | |
| Custom domain | Custom 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_nameandnamein the profile schema (or turn onallow_undeclared), otherwise every row fails with “unknown attribute”.given_name,family_nameandnameare among the claims theprofilescope releases.auth0_user_idkeeps the oldsub(Auth0’ssubis theuser_id) for applications that need to map it; declare iteditable_by: noneso that nobody can change it after the import. See Subject identifiers. - Metadata. The script leaves
user_metadataandapp_metadataout. To carry a field, add it toattributesin the script (for exampledepartment: .app_metadata.department) and declare it in the schema:editable_by: userfor what was user metadata,admin(ornone, 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 listid_token,userinfooraccess_tokenin itsvisible_in, with no claim mapper needed. - Social and enterprise users (a
user_idsuch asgoogle-oauth2|…) are imported without a password. Configure the provider in rIDM withlink_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_idsget aclient_idmade 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 isurn:ridm:adminand 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 tofalseas 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
RS256need nothing: that is rIDM’s default. rIDM does not sign withHS256; an API that used it must move to an asymmetric algorithm (RS256,RS384,RS512,ES256orEdDSA, settable per resource server assigning_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 token | A roles mapper: {"type": "roles", "claim": "https://acme.example/roles", "include_in": ["access"]}. Access tokens also carry roles by default |
| adds a fixed value | A hardcoded mapper |
| builds a string from user fields | A template mapper (Handlebars over user, tenant, client, roles, groups) |
| requires MFA for some users | settings.mfa: required_for_roles or required_for_admins |
| blocks sign-in by email domain, IP or country | registration.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-up | Webhooks 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-flow | Not 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.