Accounts (Tenants)
An Account is a customer tenant. Its isolation boundary is a Keycloak realm — not a naming convention.
The tenant boundary is a realm
RealmSSO originally provisioned every customer Account into a single shared Keycloak realm, distinguished only by naming: a client called org-<slug> and identity providers called saml-<slug>-<id>. A naming convention is not an authorization boundary. This was demonstrated rather than argued: a user brokered through one account's identity provider obtained a valid access and refresh token for a different account's client — one with no connections of its own — and the token carried nothing identifying the tenant, so the receiving application had no way to detect the mismatch. That account's login page also listed every other account's identity providers as sign-in buttons, disclosing the full customer list.
Three attempts to build a boundary inside one shared realm were tried against a live Keycloak 26 and each was disproved: a per-client authentication flow with an unconditional deny step never re-runs on the brokered login path, so it caught direct logins but not brokered ones; a post-broker-login flow does run there, but it is bound per identity provider with no awareness of which client is requesting a token, so it can only assert facts the attacker already satisfies; and enabling native Keycloak Organizations as the boundary still produced a token naming one tenant (organization claim) while minted for another tenant's client (azp). Upstream Keycloak confirms this isn't currently solvable within a single realm.
The decision: one realm per account
Every RealmSSO account gets its own Keycloak realm. The Account.keycloakRealm column is the source of truth for where an account's clients and identity providers actually live. A NULL value means nothing has been provisioned yet, and the realm name is then derived from the account's slug. Accounts that already had Keycloak objects before this model existed are pinned to the legacy shared realm and migrated later through an explicit, id-preserving per-account script — never silently.
The realm is the only Keycloak construct that scopes all three attack legs at once, each verified live against a real deployment:
| Attack leg | Cross-realm result |
|---|---|
Brokered login (/realms/{r}/broker/{alias}/login) | HTTP 400 — identity-provider aliases are realm-scoped |
Client authorize (?client_id=org-<other>) | HTTP 400 "Client not found" — client ids are realm-scoped |
| SSO cookie replay | Cookie is set Path=/realms/{r}/ — the browser will not send it cross-realm |
It also removes several adjacent problems in one move: the customer-list disclosure on the login page, account takeover by email collision (each realm has its own user store), account deletion becoming a single DELETE /admin/realms/{name} instead of an object-by-object teardown, and a group-provisioning prefix-collision bug.
Where Keycloak Organizations still fits
Native Keycloak Organizations is not discarded — it's redeployed for the job it's actually good at. A realm represents exactly one customer. Where that customer has more than one email domain, the domains are modelled inside their own realm using Keycloak Organizations, not by handing the customer additional realms or building a bespoke routing table. Once Organizations is no longer asked to carry a security boundary it cannot carry, it becomes a solid domain-management and home-realm-discovery feature.
Two different things called an organization
Account, and one Account owns one Keycloak realm — inside which Keycloak Organizations may model that one customer's email domains.Accepted cost: create-realm privilege
Provisioning an account's realm requires the create-realm role in Keycloak's master realm, not merely rights scoped to one realm — a materially larger ask of a customer's security team. This is incompatible with a bring-your-own-Keycloak deployment that disables realm creation entirely; that trade-off has been accepted deliberately rather than worked around.
Domain claims are proven against DNS
An account claims an email domain, and the claim is not authoritative until DNS proves it. POST /api/v1/accounts/:id/domain/claim records the domain and issues a TXT challenge; POST /api/v1/accounts/:id/domain/verify checks that challenge against real DNS and records the result. Setting domain through PATCH /api/v1/accounts/:id takes the same path, so a new domain string never inherits an old one's verified status.
GET /api/v1/sso/resolve (see SSO Connections) routes login by domain, and it matches only a verified domain. A claimed-but-unverified domain is treated exactly like a domain nobody has claimed: the same 200, the same ssoAvailable: false. Cross-account uniqueness is enforced case-insensitively by a database index, so two accounts cannot hold the same domain, and a collision surfaces as 409.
What's still open
A lab spike against Keycloak 26.7 found that Organizations have no per-domain identity-provider scoping at all — every IdP linked to an Organization is offered across all of that organization's domains — and recorded that as a permanent fact of 26.7 rather than a gap awaiting configuration. RealmSSO does this routing itself, in GET /api/v1/sso/resolve, which stays load-bearing indefinitely rather than as a stopgap. What no production code does is create a Keycloak Organization. The lab script is the only caller, it runs against a throwaway lab Keycloak, and it exercises no production code path.
Realms aren't free — each carries its own signing keys, caches, and login/admin surface — so very large tenant counts are a scaling question to validate rather than assume away. See Scaling Guide.