Skip to main content

Keycloak Multi-Tenancy: Realms, Groups, or Organizations?

Keycloak multi-tenancy has three models, and the right one is decided by a single question: does a tenant need its own identity provider, its own login policy, or neither?

  1. Neither — use a realm group per tenant. One realm, one issuer, a group membership mapper that puts the tenant id in the token. Cheapest to build and to run.
  2. Its own identity provider, but the same login rules as everyone else — use an organization. One realm, one issuer, per-tenant IdPs and domains, a built-in organization token claim.
  3. Its own login policy — password rules, MFA requirements, token lifespans, themes, signing keys — use a realm per tenant. It is the only model where Keycloak itself enforces the boundary, and it is the most expensive by every measure below.

Most B2B SaaS lands on (2). This page builds all three on one server, shows the token each produces, and gives the numbers for what each one costs.

"Organizations" needs one disambiguation before we start, because there are two of them:

  • Keycloak native organizations — built into Keycloak since 25, supported since 26, enabled with a realm toggle. This is what the commands below exercise, because they run on a stock quay.io/keycloak/keycloak image.
  • The Phase Two organizations extension, keycloak-orgs — the implementation the native feature was modelled on. It was built for Phase Two's own cloud, open-sourced in 2022, is a superset of what native organizations do, and is the one Phase Two runs and maintains.

Everything in the decision above applies to both. The last section is about choosing between them.

Tested against

Keycloak 26.8.0, start-dev, H2 dev database, on a 2-vCPU / 8 GB Linux host. Every command, error message and token below is copied from an actual run. The official references are Managing organizations and Groups — they document what each option does. This page is about which to pick and what breaks. The organizations commands target the native feature; the Phase Two extension is not in the stock image and is compared, not run, at the end.

Start a server​

docker run -d --name kc -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.8.0 start-dev

Everything below runs kcadm.sh inside that container, through one helper:

kc() { docker exec -i kc /opt/keycloak/bin/kcadm.sh "$@"; }

kc config credentials --server http://localhost:8080 \
--realm master --user admin --password admin

What each model costs​

Three identical containers, 100 tenants each, every tenant with one user. The realm run also creates one client per realm, because a realm with no client cannot issue a token — that is the honest shape of the model, not an extra. Heap is measured after a forced GC.

Realm per tenantOrganizationRealm group
Create 100 tenants46.2 s11.5 s9.9 s
Create one object, in isolation0.529 s0.025 s0.011 s
Heap over a 70 MB baseline+24 MB+10 MB+7 MB
Restart to ready (empty server: 9.7 s)12.8 s10.1 s9.9 s
List all 100 through the admin API, warm81–118 ms13–19 ms118–193 ms

The ratio that matters is the second row. A realm takes 21× longer to create than an organization, because creating a realm generates a fresh key set — an RS256 signing key, an RSA-OAEP encryption key, an HS512 HMAC key and an AES key. An organization creates a row.

Half a second of RSA keygen per tenant is invisible when you onboard by hand and very visible when you onboard from a signup form, or when you restore 2,000 tenants into a fresh cluster. These are single-node dev-mode numbers on a small host; treat the ratios as the finding and re-measure the absolutes on your own hardware.

Model 1: a realm per tenant​

for t in globex initech; do
kc create realms -s realm=tenant-$t -s enabled=true
kc create clients -r tenant-$t -s clientId=app -s publicClient=true \
-s 'redirectUris=["http://localhost/cb"]'
done

Keycloak generates a full key set for each one. That is where the half-second goes:

kc get keys -r tenant-globex --fields 'keys(algorithm,use,status)'
{
"keys" : [ {
"status" : "ACTIVE", "algorithm" : "RSA-OAEP", "use" : "ENC"
}, {
"status" : "ACTIVE", "algorithm" : "RS256", "use" : "SIG"
}, {
"status" : "ACTIVE", "algorithm" : "AES", "use" : "ENC"
}, {
"status" : "ACTIVE", "algorithm" : "HS512", "use" : "SIG"
} ]
}

Each realm is also its own issuer with its own JWKS:

for r in tenant-globex tenant-initech; do
curl -s "http://localhost:8080/realms/$r/.well-known/openid-configuration" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
done
http://localhost:8080/realms/tenant-globex
http://localhost:8080/realms/tenant-globex/protocol/openid-connect/certs
http://localhost:8080/realms/tenant-initech
http://localhost:8080/realms/tenant-initech/protocol/openid-connect/certs

This is the only model where Keycloak enforces the boundary. A user in tenant-globex cannot authenticate against tenant-initech's client at all; there is no token to validate and no claim for your application to forget to check. In exchange you give up everything that is realm-scoped: a user cannot belong to two tenants without two accounts, and cross-tenant reporting means iterating realms.

Your resource server pays for it too. One audience, one issuer and one cached JWKS becomes N of each, resolved per request from the token's iss — see validating Keycloak tokens in any backend for what that validation has to do.

Model 2: a group per tenant​

kc create realms -s realm=saas -s enabled=true
kc create clients -r saas -s clientId=app -s publicClient=true \
-s directAccessGrantsEnabled=true -s 'redirectUris=["http://localhost/cb"]'

GID=$(kc create groups -r saas -s name=globex -i)
ALICE=$(kc create users -r saas -s username=alice -s email=alice@globex.com \
-s emailVerified=true -s firstName=Alice -s lastName=Chen -s enabled=true -i)
kc set-password -r saas --username alice --new-password pw
kc update "users/$ALICE/groups/$GID" -r saas -n

Group membership is not in the token by default, and there is no built-in groups client scope to turn on. Decoding the access token straight after the commands above:

groups claim present: False
claims: ['acr', 'allowed-origins', 'aud', 'azp', 'email', 'email_verified', 'exp',
'family_name', 'given_name', 'iat', 'iss', 'jti', 'name', 'preferred_username',
'realm_access', 'resource_access', 'scope', 'sid', 'sub', 'typ']

You add the mapper yourself:

CID=$(kc get clients -r saas -q clientId=app --fields id --format csv --noquotes)
kc create "clients/$CID/protocol-mappers/models" -r saas \
-s name=tenant -s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s 'config."claim.name"=tenant' -s 'config."full.path"=false' \
-s 'config."access.token.claim"=true' -s 'config."id.token.claim"=true'
"tenant": ["globex"]

That claim is the whole mechanism. Keycloak will happily issue a token to any user in the realm for any client in the realm; isolation is entirely your application's job. Groups are the right answer when tenants are a label on an otherwise uniform population — everyone logs in the same way, and your API filters on a claim.

Model 3: organizations (Keycloak native)​

Native organizations are a realm-level toggle, not a build-time feature flag:

kc update realms/saas -s organizationsEnabled=true
kc get realms/saas --fields realm,organizationsEnabled
{ "realm" : "saas", "organizationsEnabled" : true }

Forget the toggle and the endpoint does not exist rather than returning a useful error:

Resource not found for url: http://localhost:8080/admin/realms/saas/organizations

Create two tenants, each owning an email domain:

kc create organizations -r saas -s name=globex -s alias=globex -s enabled=true \
-s 'domains=[{"name":"globex.com"}]'
kc create organizations -r saas -s name=initech -s alias=initech -s enabled=true \
-s 'domains=[{"name":"initech.com"}]'

A domain belongs to exactly one organization per realm:

kc create organizations -r saas -s name=umbrella -s alias=umbrella -s enabled=true \
-s 'domains=[{"name":"globex.com"}]'
Domain globex.com is already linked to organization globex in realm saas

Adding a member takes the user id as a bare JSON string — not an object, not a field:

GLOBEX=$(kc get organizations -r saas -q search=globex -q exact=true \
--fields id --format csv --noquotes)
echo "\"$ALICE\"" | kc create "organizations/$GLOBEX/members" -r saas -f -
Created new member with id 'e10a6ac5-8689-4e68-924f-55446ecf956e'
kc get "organizations/$GLOBEX/members" -r saas --fields username,membershipType
[ { "username" : "alice", "membershipType" : "UNMANAGED" } ]

UNMANAGED means the account belongs to the realm and survives the organization being deleted. Accounts federated in through an identity provider linked with the Managed membership type are deleted with the organization — the official section on managed and unmanaged members is worth reading before you onboard anyone through an IdP.

The organization claim​

Unlike groups, the mapper already exists: organization is a built-in optional client scope on every client, carrying an Organization Membership mapper. The client has to ask for it.

alice, no scope requested
scope: email profile
organization: null

alice, scope=organization
scope: email profile organization
organization: ["globex"]

By default you get aliases and nothing else. The ids, domains and organization attributes the reference material shows are off until you switch them on, and the config keys are camelCase — unlike every other mapper key in Keycloak, and not named anywhere in the admin guide:

SID=$(kc get client-scopes -r saas --fields id,name --format csv --noquotes \
| grep ',organization$' | cut -d, -f1)
MID=$(kc get "client-scopes/$SID/protocol-mappers/models" -r saas \
--fields id,name --format csv --noquotes | grep ',organization$' | cut -d, -f1)

kc update "organizations/$GLOBEX" -r saas -s 'attributes.plan=["enterprise"]'
kc update "client-scopes/$SID/protocol-mappers/models/$MID" -r saas \
-s 'config."addOrganizationId"=true' \
-s 'config."addOrganizationDomain"=true' \
-s 'config."addOrganizationAttributes"=true'
"organization": {
"globex": {
"plan": ["enterprise"],
"id": "7d0cf958-b6fa-48d9-abba-11810e80efda",
"domain": "globex.com"
}
}

addOrganizationId, addOrganizationDomain, addOrganizationAttributes. The snake-cased guesses (add.organization.id) are accepted silently and do nothing, which is how you lose an afternoon. Enabling any of them also flips jsonType.label from String to JSON and changes the claim from an array to an object — a breaking change to anything parsing it.

The organization scope is not an access gate​

This is the one to take away. Add a second user who joins nothing:

kc create users -r saas -s username=dan -s email=dan@initech.com -s emailVerified=true \
-s firstName=Dan -s lastName=Reyes -s enabled=true
kc set-password -r saas --username dan --new-password pw

Dan is a member of no organization. He asks for globex anyway:

dan, scope=organization:globex
scope: email profile
organization: null

alice, scope=organization:globex
scope: organization:globex email profile
organization: {"globex": {"plan": ["enterprise"], "id": "7d0cf958-…", "domain": "globex.com"}}

Dan gets HTTP 200 and a valid access token. The scope is silently dropped from the granted scope list and no claim is issued. The request is not rejected, there is no error, and nothing in the log distinguishes it from a normal login. Reproduced identically on 26.8.0 through the browser authorization-code flow and through direct grant.

An alias that does not exist is rejected, which makes the asymmetry easy to miss in testing — typos fail loudly, missing memberships fail silently:

alice, scope=organization:nope
{"error": "invalid_scope", "error_description": "Invalid scopes: organization:nope"}

So: never treat the presence of organization:<alias> in your authorization request as proof of anything. Read the organization claim out of the validated token and compare it to the tenant the request is for. Organizations tell your application which tenant a user belongs to; they do not stop the user from asking about another one.

Four more things that will bite you​

  • Turning organizations on changes the login page for the whole realm. It switches from the one-step login.ftl to identity-first login-username.ftl — username, submit, then password on a second screen. Every user in the realm sees it, including the ones in no organization. It is a realm toggle with a user-visible side effect, so do not flip it in production on a Friday.
  • Organization groups cannot be used in authorization policies. Organizations have their own group tree, isolated per organization — orgA and orgB can both own /Engineering without colliding. The admin guide is explicit that a group-based policy in Keycloak Authorization Services accepts realm groups only and errors on an organization group. If you use policies, your tenant boundary has to be a realm group or a claim check.
  • There is no per-organization login theme. The full representation of an organization is name, alias, enabled, domains, attributes — no theme, no password policy, no token lifespan. Everything about how a user authenticates is still realm-wide. Per-tenant branding and per-tenant login policy are what a realm still buys you that nothing else does.
  • Non-imported LDAP users cannot be organization members. Membership is stored in a local group that is never synchronised outward, so an LDAP federation provider with import mode disabled cannot contribute members at all. Enable import mode before you promise an LDAP-backed customer their own organization.

Choosing​

Realm per tenantGroup per tenantOrganization (native)
Tenant's own IdPyesnoyes
Tenant's own password policy, MFA, token lifespansyesnono
Tenant's own login themeyesnono
One user in two tenantsno, two accountsyesyes
Tenant id in the token out of the boxn/a — separate issuerno, add a mapperyes
Keycloak refuses cross-tenant loginsyesnono
Works in authorization-services policiesyesyesno
Admin delegationrealm admin rolesfine-grained admin permissionsmanage-organizations
Cost to create one0.53 s0.01 s0.03 s

Read it top-down and stop at the first row you cannot live without. In practice:

  • Internal apps, departments, a label on one population — groups.
  • B2B SaaS where customers bring their own SSO — organizations. This is the common case, and it is what the feature was built for. Native organizations if membership, domains and a token claim are all you need; the Phase Two extension as soon as a customer's own IT team has to manage anything — their SSO connection, their members, their roles — without a ticket to you.
  • Tenants that are separately regulated, separately branded, or contractually isolated — a realm each, and budget for the key generation and the per-issuer validation.
  • Dozens of tenants today and thousands next year — do not start with realms. Migrating from realms to organizations means re-creating every account under a new issuer; migrating from groups to organizations is a membership backfill.

Native organizations vs the Phase Two extension​

Keycloak did not invent the organizations model in 25. Phase Two's keycloak-orgs extension has implemented single-realm multi-tenancy — organizations, memberships, domains, per-organization IdPs and a membership claim — as first-class Keycloak entities since before Keycloak had a word for them, open source since 2022, and the Keycloak team used it as the reference when they built the native feature. What shipped in 25 and 26 is a copy of that core. Every concept you met in Model 3 — the alias, the domain uniqueness rule, the membership mapper, the identity-first login — has a direct equivalent in the extension, where it had already been running in production for several years. Native has been slow to add anything beyond that core. The two are still not interchangeable, so you should pick one deliberately:

Keycloak native organizationsPhase Two organizations extension
Organizations, members, domains, linked IdPs, token claimyesyes
Domain-based IdP routing at loginyesyes, plus DNS-verified domain ownership
Organization roles — default and custom, per organization, mapped into the tokennoyes
Invitations — invite a non-user by email, accept on registrationnoyes
Admin Portal — your customer's IT team manages their own members, roles, invitations and SSOnoyes
IdP Wizard — guided self-service SAML, OIDC and LDAP setup for a tenant adminnoyes
Per-organization SCIM endpoint and credentialsno, realm-level onlyyes (experimental)
One IdP shared by many organizationsnoyes
"Active organization" switching for multi-org usersnoyes
Organization-scoped admin eventsnoyes
Ships in the stock Keycloak imageyesno — quay.io/phasetwo/phasetwo-keycloak or add the jar
Admin API/admin/realms/{realm}/organizations/realms/{realm}/orgs
Supported sinceKeycloak 26 (October 2024)open source since 2022, several hundred deployments

Read the table as a superset: the extension does what native does, then keeps going into the Customer IAM and enterprise-SaaS work that follows — the part where a customer's own administrator needs to set up SSO on a Tuesday afternoon without filing a ticket with you. That is the feature set the native implementation has not tracked, and it is the one most B2B SaaS teams discover they need in the quarter after launch.

Practical consequences of picking one:

  • The APIs and claims differ. The extension's REST API lives under /realms/{realm}/orgs, and its token mappers are its own (organization, organization roles, organization attributes). Code written against the native organizations admin endpoint and the organization claim shape above does not run against the extension unchanged, and vice versa.
  • Do not run both in one realm. Phase Two does not enable native organizations in its hosted product, and a realm with both enabled has two unrelated notions of "organization" and two login-flow modifications fighting over the same users. Pick one per deployment.
  • The extension is not a flip of a toggle on the stock image. You run Phase Two's Keycloak image, or build the extension into your own. It has tracked Keycloak releases since 17, but it is a dependency to own.
  • The migration direction matters. Going from native to the extension is a data move of the same shape — organizations, domains, memberships — into a richer model. Going the other way drops roles, invitations and everything the table marks extension-only.

If you are evaluating organizations for a product that sells to businesses, start from the extension's documentation rather than from Model 3 above, and treat Model 3 as the floor that both implementations share.

Also out of scope here: routing a user to their tenant's IdP by email domain, which both implementations do with domain routing and auto-redirect, and which deserves its own page.

Next steps​