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.
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.
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.
{
"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.
# 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Each node caches the hottest read-mostly objects for about fifteen seconds; a write evicts them on every node through a pub/sub channel.
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.
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.
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.
at+jwt)RFC 9068ridm command lineridm.tenant/1implicit flowhybrid flowpassword grantplain PKCE
Discovery advertisesresponse_types_supported: ["code"], and the password never travels to an application. Every endpoint is listed in the reference.
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.
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.
kidtyp is at+jwt, so an ID token cannot be spent as an access tokeniss, aud, exp and nbfProtect a Rust API with ridm-auth is the full walkthrough, and examples/axum-api is the runnable version.
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.
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.
$ cd deploy/production
$ cp .env.example .env
$ ./init.sh # writes ./secrets once
$ docker compose up -dA Deployment, Service, optional Ingress, autoscaler and disruption budget. Migrations run in a Job before every upgrade.
$ helm install ridm \
oci://ghcr.io/zerosandonesllc/charts/ridm \
--version 0.4.1 -f values.yamlMulti-arch, amd64 and arm64, with an SBOM attached. Deploy by digest: tags move, a digest names one image.
ghcr.io/zerosandonesllc/ridm:0.4.1ridm-api with the UI compiled in, and the ridm CLI, statically linked against musl. Any Linux, no libc or OpenSSL needed.
ridm-0.4.1-linux-amd64.tar.gz
ridm-0.4.1-linux-arm64.tar.gzEvery 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.
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
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.
| Aspect | rIDM | Keycloak | Auth0 |
|---|---|---|---|
| Runs as | One container, Postgres and Valkey, on your infrastructure | A Java (Quarkus) server and a database, on your infrastructure | A hosted service |
| License | MIT | Apache 2.0 | Commercial |
| Isolation unit | Tenant, at {PUBLIC_URL}/t/{slug} | Realm, at /realms/{name} | Tenant, at {tenant}.{region}.auth0.com |
| Consoles | Admin and account consoles in the server image | Admin and account consoles | Hosted dashboard |
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.
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.