🏢 Single Organization per Session
Keycloak’s organization feature lets a user be a member of several organizations at once, and by default a token can carry all of them. This page describes how to bind every session to exactly one organization, so that:
- the user picks an organization at login and cannot skip the choice,
- no request can ask for two organizations or for all of them,
- no request can quietly ask for none,
- a token or assertion never names more than one organization.
Three providers work together:
| Provider | Type | Purpose |
|---|---|---|
kommons-orgs-scope-enforcer | Client policy executor | Rejects requests whose scope does not resolve to exactly one organization |
kommons-orgs-scope-injector | Authenticator | Adds the organization scope to OpenID Connect requests that did not ask for it |
kommons-orgs-select-organization | Required action | Makes the user choose an organization for protocols without a scope parameter (SAML) |
You do not necessarily need all three. See Which pieces do I need? below.
🧭 Why more than a client policy is needed
It helps to know how Keycloak decides which organization a session belongs to, because the moving parts sit in two different places.
The login side reads the raw scope request parameter. The organization selection screen is only shown when the request asked for the bare organization scope, the user is a member of more than one enabled organization, and no organization has been picked yet. Even the cookie authenticator consults the requested scope: when an organization scope is present it steps aside so the organization can be resolved before the session is reused, and when it is absent single sign-on completes without ever touching the organization flow.
The token side reads the granted client scopes. The oidc-organization-membership-mapper resolves organizations from the scopes attached to the client session.
That split has a consequence worth spelling out, because it is the most common wrong turn:
⚠️ Making the
organizationclient scope a default scope does not force the selection. Default scopes are visible to the token mappers but not to the login side, which only ever looks at thescoperequest parameter. A user with a single membership then still gets a correct token, while a user with several memberships gets no organization claim at all and is never asked to choose. The failure is silent and only hits multi-organization users, so it easily survives testing.Measured on Keycloak 26.7.0 with the scope assigned as default and a user in two organizations:
scope=openid→ noorganizationclaim,scope=openid organization:*→ both organizations, andscope=openid organization→ HTTP 500. The same explicit request succeeds when the scope is assigned as optional, so keep the organization scope optional and let the injector add it to the request.
The scope injector exists precisely because rewriting the requested scope is the one change that both sides observe.
🚦 Client Policy Executor: kommons-orgs-scope-enforcer
Rejects a request when its scope parameter does not resolve to exactly one organization.
Rules
| Requested scope | Result |
|---|---|
organization | ✅ accepted — resolves to the user’s only membership, or to the one they select |
organization:acme | ✅ accepted (unless the selection mode forbids it, see below) |
organization:acme organization:globex | ❌ rejected — more than one organization |
organization:* | ❌ rejected — all organizations |
| (no organization scope) | ❌ rejected, unless “Require an organization scope” is switched off |
Rejections are returned as invalid_scope with HTTP 400.
Where it applies
The executor runs on every event that carries a client supplied scope and can widen the organizations in a token: authorization requests, pushed authorization requests, CIBA backchannel authentication, device authorization, token refresh, token exchange, and the resource owner password credentials grant.
It deliberately does not run on the client credentials grant: that grant has no user and therefore no organization membership to constrain.
An empty scope on a refresh is treated as “reuse what was granted before”, not as “no organization requested”, so a normal refresh is never rejected. An explicit scope on a refresh is still checked.
Configuration
| Option | Key | Type | Default |
|---|---|---|---|
| Require an organization scope | kommons.orgs.require.scope | Boolean | true |
| How the organization may be selected | kommons.orgs.selection.mode | any-single | user-selected | any-single |
any-single— the client may either request the bareorganizationscope and let the user pick, or name exactly one organization withorganization:<alias>.user-selected— only the bareorganizationscope is accepted. The client can never pin an organization, so the choice always belongs to the user. Use this when clients are not trusted to choose the tenant.
The organization scope is not recognized by name but by its protocol mapper: any client scope carrying the oidc-organization-membership-mapper counts, which is the same rule Keycloak applies internally. If you renamed the scope, or defined several, they are all recognized.
Setup
- Realm settings → Client policies → Profiles → Create client profile, for example
single-organization - Add the executor Organization Scope Enforcer and configure it
- Policies → Create client policy, add the condition any-client and attach the profile
Or import it with your realm:
{
"clientProfiles": {
"profiles": [
{
"name": "single-organization",
"executors": [
{
"executor": "kommons-orgs-scope-enforcer",
"configuration": {
"kommons.orgs.require.scope": true,
"kommons.orgs.selection.mode": "any-single"
}
}
]
}
]
},
"clientPolicies": {
"policies": [
{
"name": "single-organization-for-all-clients",
"enabled": true,
"conditions": [{ "condition": "any-client", "configuration": {} }],
"profiles": ["single-organization"]
}
]
}
}
💉 Authenticator: kommons-orgs-scope-injector
Adds the organization scope to an OpenID Connect authorization request that did not ask for it, so that the selection screen is triggered and the organization ends up in the token — without having to change any client.
Use it when you cannot make every application request the organization scope. With the injector in place you can leave the executor’s “Require an organization scope” enabled and still never reject a legacy client, because by the time the request is evaluated the scope is there.
Behaviour
- Runs only for the
openid-connectprotocol. SAML has noscopeparameter; use the required action instead. - Does nothing when the request already contains an organization scope, so a client that asks for
organization:acmekeeps its choice. - Never authenticates anybody. It rewrites the requested scope and reports
attempted, letting the flow continue. - Failures are logged and swallowed: a problem here can never block a login, the request simply behaves as before.
Configuration
| Option | Key | Type | Default |
|---|---|---|---|
| Organization scope name | kommons.orgs.scope.name | String | (empty) |
Leave it empty to use the organization client scope assigned to the client, which works whenever a client has exactly one. Set it explicitly if a client has several organization client scopes assigned, otherwise nothing is injected and a warning is logged.
Setup
- Authentication → Flows, duplicate the browser flow (built-in flows cannot be edited)
- Add step → Organization Scope Injector
- Move it to the top of the flow, above Cookie
- Set the requirement to Alternative
- Bind the flow: Action → Bind flow → Browser flow
⚠️ The step must be
Alternativeand must sit beforeCookie. The injector must run before the cookie authenticator decides whether to complete single sign-on. It is also the reasonRequiredis not offered as a requirement: a required execution disables every alternative of its parent flow, which would break cookie based single sign-on entirely.
The organization client scope still has to be assigned to the client, as optional or default. The injector adds the scope to the request, it does not grant a scope the client is not allowed to use.
🧑💼 Required Action: kommons-orgs-select-organization
Makes the user pick exactly one organization for login protocols that have no scope parameter, above all SAML.
Keycloak’s own selection screen is driven by the organization scope and is therefore unreachable for a SAML client. Required actions on the other hand are evaluated on every authentication regardless of protocol, including a login that reuses an existing single sign-on session, which makes this the one hook that cannot be bypassed.
Behaviour
- Skips OpenID Connect entirely — that is covered by Keycloak’s organization authenticator together with the injector. Without this guard the user would be asked twice.
- Skips when an organization has already been selected for the client session.
- Selects silently when the user is a member of exactly one enabled organization.
- Shows the selection screen when the user is a member of several, reusing Keycloak’s own
select-organization.ftlfrom the base login theme, so no custom theme is needed. - Validates the submitted alias against the user’s enabled memberships before accepting it.
The chosen organization is written to the kc.org client note — the very note Keycloak’s browser flow uses. It reaches the authenticated client session through Keycloak’s regular note transfer, which is protocol independent.
Configuration
| Option | Key | Type | Default |
|---|---|---|---|
| Bind the organization to the SSO session | kommons.orgs.bind.to.sso.session | Boolean | false |
See Binding the organization to the SSO session below.
Setup
Authentication → Required actions, then set Select Organization to Enabled.
Leave Set as default action off. The action is triggered by its own evaluation on every login, not by being assigned to users.
Reading the selection in a SAML mapper
Keycloak’s built-in SAML organization mapper (saml-organization-membership-mapper) writes every organization the user belongs to as a multi-valued attribute. It ignores the selection completely. To emit a single organization you need your own SAMLAttributeStatementMapper:
@Override
public void transformAttributeStatement(AttributeStatementType statement, ProtocolMapperModel model,
KeycloakSession session, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
String organizationId = clientSession.getNote(OrganizationModel.ORGANIZATION_ATTRIBUTE);
if (organizationId == null) {
return; // fail closed: no selection means no organization attribute
}
// resolve the organization, re-check isEnabled() and isMember(user), then add a single attribute value
}
Two things to get right:
- Fail closed on a missing note. Do not fall back to “all memberships”, or the one path that bypassed the selection silently produces the multi-organization assertion you are trying to prevent.
- Do not read
session.getContext().getOrganization(). It is only populated when Keycloak’s organization authenticator actually ran, which is not the case on the single sign-on path nor when this required action made the choice.
⚠️ Remove the built-in SAML organization mapper from those clients. If both are attached, the assertion contains your single value and the full list.
🧩 Which pieces do I need?
| Situation | Enforcer | Injector | Required action |
|---|---|---|---|
OIDC only, all clients request organization | ✅ | — | — |
| OIDC only, clients cannot be changed | ✅ | ✅ | — |
| SAML clients involved | ✅ (no effect on SAML) | ✅ for the OIDC clients | ✅ |
The enforcer is the only piece that rejects anything, and it has no effect on SAML: SAML has no scope parameter, so there is nothing for it to validate.
To bind the organization to the whole single sign-on session rather than to each client session, all three are needed regardless of protocol — the required action holds the switch and records the binding, the injector applies it before Keycloak asks. See Binding the organization to the SSO session.
🔗 Binding the organization to the SSO session
By default Keycloak stores the selected organization in a client note, so the choice lives on the client session. Every token still names exactly one organization, but two clients in one single sign-on session can end up on two different organizations, and a user who is a member of several is asked again for every new client.
Switching Bind the organization to the SSO session on changes that: the first selection is additionally recorded on the user session, and every later authentication in that session adopts it instead of asking again.
| Off (default) | On | |
|---|---|---|
| Scope of a choice | one client session | the whole SSO session |
| Two clients, one session | may differ | always identical |
| Repeat prompt per client | yes | no |
| Switching organization | new authorization request | requires a new session, i.e. a logout |
How it works
- The required action records the selection as a user session note once it has been made, for every protocol. It does this whether the choice came from its own screen (SAML) or from Keycloak’s organization authenticator (OIDC).
- The injector authenticator adopts an existing binding at the very start of the browser flow, before Keycloak’s organization handling runs, which is what suppresses the repeat prompt.
- A binding is re-validated on every use. If the organization was deleted, disabled, or the user’s membership was revoked, it is discarded and the user selects again.
⚠️ For OpenID Connect this needs the injector authenticator in the browser flow, ahead of the cookie authenticator. Without it the binding is still enforced — the required action applies it afterwards — but the user is asked first and the answer is discarded, which is confusing. A warning is logged when that happens.
Because the switch lives on the required action, the required action has to be Enabled even in an OIDC-only setup, where it otherwise does nothing but record and apply the binding.
⚠️ Limitations
Switching organizations requires a logout when the binding is on. With the binding off, a new authorization request re-prompts a multi-organization user, which is the switching mechanism and needs no logout. With it on, the whole point is that later requests adopt the recorded organization, so switching means ending the session.
Disabled organizations may appear on the selection screen. The reused select-organization.ftl lists the user’s memberships without filtering by enabled state. This matches Keycloak’s own behaviour. A disabled organization is rejected on submit.
The client credentials grant is out of scope. Service accounts have no organization membership, so nothing is enforced there.
🧪 Verifying the setup
Rejection rules can be checked without a browser using the resource owner password credentials grant:
# rejected: two organizations
curl -d grant_type=password -d client_id=my-client -d username=alice -d password=... \
-d 'scope=openid organization:acme organization:globex' \
https://<host>/realms/<realm>/protocol/openid-connect/token
# => 400 {"error":"invalid_scope", ...}
# rejected: all organizations
... -d 'scope=openid organization:*'
# accepted
... -d 'scope=openid organization:acme'
The selection screen needs a browser login with a user who is a member of more than one enabled organization. With the injector active, a client that requests no scope at all should still land on the organization selection.
The resulting claim is best inspected in Clients → your client → Client scopes → Evaluate.