Architecture Overview
RealmSSO is a control plane in front of Keycloak, not a replacement for it — here's how the pieces fit together.
Five codebases, one system
RealmSSO is split across five repositories. realmsso-server is a Fastify + Prisma/Postgres backend that owns the data model and talks to Keycloak's admin API; it is the only component holding database credentials or a Keycloak admin service account. realmsso-website is the Next.js 16 App Router app you're reading this in — the marketing pages and these docs, and nothing else: a static export with no API calls, no session, and no secrets. realmsso-app is the second Next.js app, carrying the vendor dashboard, the customer-facing Admin Portal, and the magic-link callback that lands sign-ins; it is the only frontend that calls realmsso-server or holds a session token.
Two more repositories carry no user-facing surface. realmsso-mcp is a Model Context Protocol server that exposes a subset of the API to agent tooling; it holds no data of its own and calls realmsso-server like any other client. realmsso-docs holds the architecture decision records that govern the other four, plus the cross-repo runbooks — the ADRs cited across these pages live there, not in the server repo.
The split is deliberate rather than incidental. The two frontends have different risk profiles — one has no credentials at all and is the publicly reachable surface anyone can load anonymously, the other holds vendor session tokens — and different release cadences, so they get different repositories, different reviewers, and different CI. The backend never renders HTML, and neither frontend touches Keycloak's admin API: every realm, client, and identity-provider change goes through realmsso-server.
The core idea is the “Keycloak-direct” model: RealmSSO configures a Keycloak realm, provisions a per-account OIDC client, and brokers each customer's identity provider into that realm. A vendor's own application then speaks standard OIDC directly to Keycloak — RealmSSO deliberately does not sit on the live login path or proxy the authorization code exchange. See SSO Connections for what that means for ACS URLs and redirect URIs.
Three hostnames, three audiences
The hostname split follows the split between the three components a browser reaches. Of those three hostnames, exactly one is live today: realmsso.com serves this marketing site, public and anonymous. The other two describe the intended topology, and neither resolves yet:
app.realmsso.comis the target host forrealmsso-app— both the vendor dashboard and the customer Admin Portal on one origin, which is why a singleCORS_ORIGINSentry will cover both (see Helm Chart Reference). That move is still in progress; treat this as where the app is going rather than something already answering.sso.realmsso.comis the host Keycloak itself is intended to serve on, and the one an end user's browser would reach. It is not yet stood up either, so read it as the planned origin rather than a URL to point anything at today. Giving each customer account its own subdomain under it is a further, separate step: that behaviour is an opt-in chart feature that ships off, gated behind a cookie-configuration verification, and stays inert until an operator deliberately enables it.
Once it is serving, Keycloak will be the only one of the three an end user's browser has to reach during a login — a direct consequence of the “Keycloak-direct” model above: the authorization request goes to Keycloak, not to RealmSSO. None of this constrains a self-hosted install. Self-hosting substitutes your own hostnames for all three, in any arrangement you like — the server assumes nothing beyond the values you set in Configuration, and the Helm chart's own hostnames are placeholders you are expected to replace.
Backend modules
src/modules/ has one directory per domain, each exporting a routes.ts registered in app.ts:
| Module | Responsibility |
|---|---|
accounts | Tenant CRUD and Keycloak realm lifecycle (create/delete a realm per account) |
connections | SAML/OIDC connection CRUD, Keycloak identity-provider provisioning, health checks |
sso | Discovery (/sso/resolve), per-account client provisioning, and building authorize URLs for vendors |
scim | SCIM 2.0 endpoints for customer directory sync (see SCIM Directory Sync) |
admin-portal | Magic-link sessions for the customer-facing admin portal |
auth | Vendor-side authentication (email/password, magic link, passkey, refresh tokens) |
webhooks | Outbound webhook configs and delivery records |
audit | Query surface over the audit log |
observability | Query surface over login/SCIM/health event data |
api-keys | Vendor API key issuance and scoping |
products | Product catalogue, per-account grants, SAML/OIDC provisioning per granted realm, and the handout read |
agent-clients | Per-account dynamic client registration for agent clients, and their deactivation |
health | /health and /readyz — registered with no prefix so both keep their fixed paths |
How the Fastify app is assembled
buildApp() in src/app.ts registers Helmet, CORS, cookies, rate limiting, JWT, and Swagger/Swagger UI (mounted at /docs on the API itself — distinct from this documentation site) before wiring routes. Every module is mounted under /api/v1/* except SCIM, which lives at /api/scim/v2 to match what Okta/Entra ID expect, and health, which stays unprefixed. A dedicated content-type parser is registered for application/scim+json, since SCIM clients send that instead of plain application/json and Fastify would otherwise reject every SCIM write with a 415.
await app.register(authRoutes, { prefix: '/api/v1/auth' });
await app.register(accountRoutes, { prefix: '/api/v1/accounts' });
await app.register(connectionRoutes, { prefix: '/api/v1/connections' });
await app.register(adminPortalRoutes, { prefix: '/api/v1/admin-portal' });
await app.register(ssoRoutes, { prefix: '/api/v1/sso' });
await app.register(scimRoutes, { prefix: '/api/scim/v2' });
await app.register(observabilityRoutes, { prefix: '/api/v1/observability' });
await app.register(auditRoutes, { prefix: '/api/v1/audit' });
await app.register(webhookRoutes, { prefix: '/api/v1/webhooks' });
await app.register(apiKeyRoutes, { prefix: '/api/v1/api-keys' });Talking to Keycloak
All Keycloak access goes through src/common/keycloak/: admin.ts wraps the Keycloak admin-client with a cached, auto-refreshing token and exposes realm, client, and identity-provider operations; realm.ts and provisioning.ts decide which realm an account's objects belong in (including adopting a pre-existing legacy footprint); hostname.ts and change-hostname.ts decide and move an account's customer-facing SSO origin, driven by the operator script src/scripts/set-org-hostname.ts rather than by a deploy. The realm-per-account model itself is covered in full in Accounts (Tenants).
Keycloak bootstrap is best-effort at startup
main.ts calls ensureRealm() when the server starts, but a failure there does not stop the process — a bring-your-own-Keycloak service account frequently lacks realm-listing or realm-creation privileges, and the target realm is often provisioned out-of-band. /health and every non-Keycloak route keep working; SSO connections, account provisioning, and SCIM fail per-request until it's resolved.