Version 0.4.1 puts the ridm-auth crate on crates.io; 0.4.0 restyled the consoles with four tenant-selectable skins. Open source, licensed MIT.

An identity server you run yourself, written in Rust.

rIDM is an open-source OpenID Connect and SAML 2.0 provider for many tenants at once, with MFA, passkeys, brokering to the providers and directories your users already have, and an admin console and account console in the same image. One container, Postgres and Valkey.

Until 1.0 a minor version may change compatibility, and the changelog says how.

access token at+jwt
{
  "typ": "at+jwt",
  "kid": "acme-2026-09"
}
{
  "iss": "https://id.example.com/t/acme",
  "sub": "0d6e4a9c-1f2b-4c3d-9e8f-7a6b5c4d3e2f",
  "aud": "invoices",
  "scope": "invoices:read",
  "exp": 1790300400
}

Signed with the tenant's own key. A relying party of another tenant never fetched it, so the token is not valid there.

Quick start from a clone of the repository
# encrypts secrets at rest; keep it safe
$ export MASTER_KEY=$(openssl rand -hex 32)
$ docker compose -f deploy/docker-compose.yml --profile dev up -d
$ curl http://localhost:8080/readyz

The dev profile seeds a first administrator in the master tenant and adds Mailpit to catch outbound mail. The admin console is at localhost:8080/console/. Run rIDM locally walks through it, and deployment covers production.

https://id.example.com/t/acme

Every tenant is its own issuer. Its own signing keys, users, clients, policies, branding and administrators. A token from one tenant is never valid in another, because the iss claim differs and its key is not in the set the relying party fetched.

Isolated in Postgres, not just in code. Every tenant-scoped table has a forced row level security policy bound per transaction, and foreign keys carry the tenant id. A query that forgets its WHERE returns nothing from other tenants.

Slugs, or your own domain. staging and production side by side, one tenant per customer, or a custom domain so users keep the login hostname they know. Tenants and issuers explains the model.

Two consoles, in the same image

The admin console for the people who run tenants, the account console for the people who sign in, and the sign-in pages themselves, all served by ridm-api on its own origin. Every tenant gets its own colors, logo and links.

The admin console: signing in, then the tenants, users, clients, signing keys and playground pages
The admin console: tenants, users, groups, roles, clients, SAML, signing keys, audit log and an OAuth playground, per tenant.
A tenant's sign-in page in its own colors, with password, passkey and magic-link options
A tenant's sign-in page in its own branding: password, passkey or a sign-in link by email.
The account console's security page: password, two-step verification, trusted devices and active sessions
The account console: users change passwords, enrol second steps, and see and end their own sessions.

Built to be run, not rented

rIDM is for operators who want an identity provider on their own infrastructure, and for developers who want standard OAuth 2.0 and OpenID Connect to integrate against, without a vendor SDK.

Multi-tenant from the first migration

Tenants are not a plugin. Every tenant-scoped table leads its indexes with tenant_id, and Postgres itself hides other tenants' rows from each transaction.

Standards, and only the safe ones

Authorization code with PKCE, PAR, DPoP, mutual TLS, CIBA, FAPI 2.0 and more. No implicit flow, no hybrid flow, no password grant, no plain PKCE: a misconfigured client cannot fall back to a weaker one.

One image

The container compiles the sign-in pages, the admin console and the account console into the server and serves them on its own origin. Nothing else to host; the same export can also go on a CDN.

Configuration as code

A tenant exports to one JSON document and imports idempotently, with a diff first. Keep it in version control and apply it to any deployment. How it works.

MFA, passkeys and adaptive sign-in

TOTP, email and SMS codes, WebAuthn passkeys as the first factor or the second, recovery codes, policy per tenant and per role, step-up per client, and risk scoring on new devices, new countries, impossible travel and velocity.

Keys where you want them

Per-tenant signing keys with rotation. The master key that encrypts secrets at rest can live in the environment, a PKCS#11 HSM, AWS KMS, HashiCorp Vault or OpenBao, Google Cloud KMS or Azure Key Vault. Key custody.

Data residency

One deployment, regional databases. A tenant created in eu keeps its users, credentials, sessions, keys and audit log in the eu database and nowhere else. Data residency.

Built for scale

Stateless nodes, cache-first reads with invalidation over Valkey pub/sub, short-lived JWTs, Valkey Sentinel or Cluster, Postgres read replicas, Prometheus metrics and OpenTelemetry traces. Scaling.

Three moving parts

A deployment is ridm-api, Postgres and Valkey. Nodes keep nothing another node needs, so any node serves any request and there are no sticky sessions. Add nodes behind any proxy or load balancer.

rIDM deployment architecture Browsers, relying parties and SCIM clients reach a reverse proxy over TLS. Behind it, any number of stateless ridm-api nodes, each serving the embedded UI, share one Postgres database, where each tenant's rows are isolated by row level security, and one Valkey for sessions, flows, codes, rate limits and cache invalidation. browsers, relying parties, SCIM clients https reverse proxy or load balancer nginx, Caddy, Traefik, a cloud LB. TLS ends here. ridm-api stateless; serves the sign-in pages and both consoles itself ridm-api background jobs take a Valkey lock; no node is special ridm-api any number of nodes; the same image and config Postgres one database; row level security per transaction /t/acme keys, users, clients, policies, audit log /t/staging keys, users, clients, policies, audit log /t/eu-bank or its own regional database Valkey standalone, Sentinel or Cluster browser sessions login flows and codes one-time codes, rate-limit counters access-token denylist cache invalidation, job locks

Nothing sticky

A session, a login flow half-way through, an authorization code or a rate-limit count lives in Valkey or Postgres, never on a node.

Cache first

Each node caches the hottest read-mostly objects for about fifteen seconds; a write evicts them on every node through a pub/sub channel.

Configured by environment

The same image runs everywhere. PUBLIC_URL fixes every issuer, and one master key, from the environment or a KMS, encrypts what is at rest.

Observable

Prometheus metrics, OpenTelemetry traces, structured logs, and a per-tenant SHA-256 hash chain over the audit log that ridm audit verify checks. Deployment overview.

The standards, by number

Integrate with any OpenID Connect or JWT library. Discovery is published per tenant, and every endpoint behaves the way the specification says it should: the OpenID Foundation conformance suite runs its configuration, basic OP and logout certification plans against every pull request.

OAuth 2.0 and OpenID Connect 16

  • Authorization code with PKCE (S256 only)RFC 7636
  • Pushed authorization requestsRFC 9126
  • JWT-secured requests and responsesRFC 9101, JARM
  • JWT access tokens (at+jwt)RFC 9068
  • Refresh token rotation with reuse detectionRFC 9700
  • Client credentials and service accountsRFC 6749
  • Device authorizationRFC 8628
  • Token exchangeRFC 8693
  • DPoP sender-constrained tokensRFC 9449
  • Mutual TLS and certificate-bound tokensRFC 8705
  • Introspection and revocationRFC 7662, 7009
  • Dynamic client registrationRFC 7591
  • Backchannel sign-inCIBA Core 1.0
  • FAPI 2.0 Security Profile, per clientOpenID FAPI
  • RP-initiated, back-channel and front-channel logoutOpenID Connect
  • Discovery and WebFingerOIDC, RFC 7033

Federation and directories 8

  • SAML 2.0 identity provider: both browser bindings, single logoutOASIS
  • SAML 2.0 upstream identity providersOASIS
  • Upstream OpenID Connect and OAuth 2.0 providersOIDC
  • Presets for Google, Microsoft, GitHub, Apple, GitLab
  • LDAP and Active DirectoryRFC 4511
  • Kerberos desktop sign-in (SPNEGO)RFC 4559
  • PasskeysWebAuthn
  • TOTP, email and SMS codes, recovery codesRFC 6238

Provisioning and administration 5

  • SCIM 2.0 provisioningRFC 7643, 7644
  • Admin API with an OpenAPI documentOpenAPI 3
  • The ridm command line
  • Tenant configuration documentridm.tenant/1
  • Webhooks and a hash-chained audit log

Sign-in, in the box 8

  • Password, magic link, email and SMS codes, passkeys
  • Self-registration, invitations, password reset, consent
  • Organizations within a tenant, with their own admins
  • Risk-based step-up and blocking
  • Rate limits, IP rules, CAPTCHA, breached-password check
  • Localized pages and messages, per-tenant branding
  • Custom domains per tenant
  • Impersonation with an audit trail
Left out on purpose

implicit flowhybrid flowpassword grantplain PKCE

Discovery advertises response_types_supported: ["code"], and the password never travels to an application. Every endpoint is listed in the reference.

Protect a Rust API in a few lines

The ridm-auth crate validates rIDM access tokens in your own axum service. Any other language uses an ordinary OIDC or JWT library against the tenant's discovery document.

src/main.rsridm-auth
use ridm_auth::Validator;
use ridm_auth::axum::{Guard, RidmClaims, guard};

// One validator for the whole process: it holds the key-set cache.
let validator: Arc<Validator> = Validator::builder(&issuer)
    .audience(&audience)
    .discover()
    .await?
    .shared();

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 app = Router::new()
    .route("/whoami", get(whoami)) // any valid token
    .merge(read)
    .with_state(validator);

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

One Validator per issuer holds the key-set cache; a Guard protects a route subtree and refuses anything short of what it asks for. Every refusal is an RFC 6750 response with a WWW-Authenticate challenge, and your handlers write none of it.

Checked on every request

  • the signature, against the issuer's JWKS, refetched when a token names an unknown kid
  • JOSE typ is at+jwt, so an ID token cannot be spent as an access token
  • iss, aud, exp and nbf
  • a DPoP-bound token is refused rather than downgraded
  • the scopes, permissions and roles the guard asked for
401no token, or one that is expired, mis-signed or minted for another API
403a valid token without the permission the route requires
200the verified claims are handed to your handler

Protect a Rust API with ridm-auth is the full walkthrough, and examples/axum-api is the runnable version.

Deploy it anywhere a container runs

Bare metal, docker-compose, Kubernetes or any cloud. No provider-specific dependency in the default build; email over SMTP or an HTTP API, and secrets encrypted with a key you hold.

docker-compose

One host for real use: behind Caddy, nginx or Traefik with TLS, Postgres and Valkey on a network with no route out, every secret in a file.

deploy/production
$ cd deploy/production
$ cp .env.example .env
$ ./init.sh   # writes ./secrets once
$ docker compose up -d

docker-compose, production

Helm

A Deployment, Service, optional Ingress, autoscaler and disruption budget. Migrations run in a Job before every upgrade.

oci://ghcr.io/zerosandonesllc/charts/ridm
$ helm install ridm \
    oci://ghcr.io/zerosandonesllc/charts/ridm \
    --version 0.4.1 -f values.yaml

Kubernetes (Helm)

Container image

Multi-arch, amd64 and arm64, with an SBOM attached. Deploy by digest: tags move, a digest names one image.

ghcr.io
ghcr.io/zerosandonesllc/ridm:0.4.1

Container image

Static binaries

ridm-api with the UI compiled in, and the ridm CLI, statically linked against musl. Any Linux, no libc or OpenSSL needed.

GitHub release assets
ridm-0.4.1-linux-amd64.tar.gz
ridm-0.4.1-linux-arm64.tar.gz

Releases

Signed, so you can check what you run

Every release is built from a v* tag by the release workflow and signed with Sigstore keyless signing. Verification names that workflow and GitHub's token issuer, so a check that fails means the artifact was not built there, or was changed since.

Releases and verification

verify the 0.4.1 imagecosign
IDENTITY='^https://github.com/ZerosAndOnesLLC/rIDM/\.github/workflows/release\.yml@refs/tags/v'
cosign verify ghcr.io/zerosandonesllc/ridm:0.4.1 \
  --certificate-identity-regexp "$IDENTITY" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Coming from Keycloak or Auth0

Both map onto rIDM closely. The guide gives the concept mapping for each and jq scripts that turn their exports into a tenant document and bulk-import rows, password hashes included.

AspectrIDMKeycloakAuth0
Runs asOne container, Postgres and Valkey, on your infrastructureA Java (Quarkus) server and a database, on your infrastructureA hosted service
LicenseMITApache 2.0Commercial
Isolation unitTenant, at {PUBLIC_URL}/t/{slug}Realm, at /realms/{name}Tenant, at {tenant}.{region}.auth0.com
ConsolesAdmin and account consoles in the server imageAdmin and account consolesHosted dashboard

From Keycloak

A realm becomes a tenant. Clients keep their client_id and redirect URIs; a bearer-only client becomes a resource server, so the aud your API checks can stay the same.

  • Realm roles, client roles, groups and client scopes have direct counterparts
  • Users import in bulk with their password hashes, verified in the original format at first sign-in and then re-hashed with argon2id
  • Secrets, signing keys and sessions do not carry over; users sign in once more

Migrate from Keycloak

From Auth0

An Auth0 tenant becomes an rIDM tenant. Applications become clients of the matching type, and an API becomes a resource server with its identifier unchanged.

  • API permissions become permissions and scopes on the resource server
  • The user export, with hashes, converts to bulk-import rows
  • Rules and Actions have no direct equivalent; the guide says what replaces each common one

Migrate from Auth0

Run it on a laptop this afternoon.