A mid-size engineering org has three identity islands: corporate staff in Microsoft Entra ID, a recently acquired business unit still on Okta, and a fleet of internal apps — an admin console, a couple of microservices, and a self-hosted Moodle learning platform — each rolling its own login. The mandate from the platform team is to put one OIDC issuer in front of everything: users authenticate against their home IdP, but every application only ever trusts a single token issuer, and a user’s group membership in Entra or Okta deterministically becomes an application role. This guide builds exactly that with Keycloak: a brokered realm, two upstream identity providers, OIDC clients with purpose-built client scopes, and a group-to-role mapping pipeline that an auditor can read. Every command below is real and runnable.
Prerequisites
- A Linux host or Kubernetes cluster for Keycloak 26.x (this guide uses the container image
quay.io/keycloak/keycloak:26.1). - An external Postgres 15+ database (do not run production Keycloak on the dev H2 store).
- A DNS name with a valid TLS certificate for the Keycloak endpoint, e.g.
https://sso.kloudvin.internal. Front it with Akamai (or any CDN/WAF) for TLS termination, anycast, and bot/credential-stuffing protection at the edge. - Admin access to a Microsoft Entra ID tenant and an Okta org (to register Keycloak as a downstream app in each).
- HashiCorp Vault reachable for storing IdP client secrets and the Keycloak DB password.
kcadm.sh(ships inside the Keycloak image),curl, andjqon your workstation.
Target topology
The shape is a hub-and-spoke around one Keycloak realm. Upstream (north): Entra ID and Okta are registered as OIDC identity providers — Keycloak is an OIDC client to each of them. Downstream (south): your applications (admin console, microservices, Moodle) are registered as OIDC clients of Keycloak — Keycloak is the issuer they trust. In the middle, the realm holds the brokering config, the client scopes that decide which claims each app receives, and the group/role model that turns “member of Platform-Admins in Entra” into “has the platform-admin realm role.” Secrets never live in Keycloak config files — they are pulled from HashiCorp Vault. Every realm change ships as an exported JSON artifact through CI (GitHub Actions or Jenkins) with a ServiceNow change gate, so the identity layer is versioned and auditable rather than click-configured.
A user’s journey: they hit an app, get redirected to Keycloak, pick their home IdP, authenticate there, return to Keycloak with an external token, and Keycloak mints its own OIDC token — enriched with the roles your apps actually understand.
1. Deploy Keycloak in production mode
Run Keycloak against Postgres with hostname and TLS pinned. Pull the DB password from Vault at boot rather than baking it into the environment. The Vault read below uses an AppRole-issued token; in Kubernetes you would use the Vault Agent injector sidecar to template the same secret to a file.
# Fetch the DB password from HashiCorp Vault (AppRole already authenticated)
export KC_DB_PASSWORD=$(vault kv get -field=password secret/kloudvin/keycloak/db)
docker run -d --name keycloak \
-p 8443:8443 \
-e KC_DB=postgres \
-e KC_DB_URL=jdbc:postgresql://pg.kloudvin.internal:5432/keycloak \
-e KC_DB_USERNAME=keycloak \
-e KC_DB_PASSWORD="$KC_DB_PASSWORD" \
-e KC_HOSTNAME=https://sso.kloudvin.internal \
-e KC_HTTPS_CERTIFICATE_FILE=/etc/x509/tls.crt \
-e KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/x509/tls.key \
-e KC_PROXY_HEADERS=xforwarded \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD="$(vault kv get -field=bootstrap_admin secret/kloudvin/keycloak/db)" \
-v /opt/keycloak/certs:/etc/x509:ro \
quay.io/keycloak/keycloak:26.1 \
start --optimized
KC_PROXY_HEADERS=xforwarded is essential when Akamai or an ingress terminates TLS in front of Keycloak — without it, Keycloak builds redirect URIs from the internal address and the OIDC flow breaks. Confirm the server is healthy:
curl -sf https://sso.kloudvin.internal/health/ready && echo "Keycloak ready"
Authenticate kcadm once; subsequent commands reuse the stored session:
/opt/keycloak/bin/kcadm.sh config credentials \
--server https://sso.kloudvin.internal \
--realm master --user admin \
--password "$(vault kv get -field=bootstrap_admin secret/kloudvin/keycloak/db)"
2. Create the realm and the role/group model
Create a dedicated realm — never use master for applications. Then define the realm roles your apps consume and the groups that brokered users will land in. Defining roles and groups first means the IdP mappers in later steps have concrete targets.
KC=/opt/keycloak/bin/kcadm.sh
# Realm
$KC create realms -s realm=kloudvin -s enabled=true \
-s 'displayName=KloudVin SSO' \
-s sslRequired=external \
-s loginWithEmailAllowed=true \
-s 'duplicateEmailsAllowed=false'
# Application roles
for role in platform-admin service-developer learner readonly; do
$KC create roles -r kloudvin -s name=$role -s "description=App role: $role"
done
# Groups, each pre-bound to a realm role
$KC create groups -r kloudvin -s name=Platform-Admins
$KC create groups -r kloudvin -s name=Service-Developers
$KC create groups -r kloudvin -s name=Learners
# Bind group -> role (this is the deterministic mapping apps rely on)
$KC add-roles -r kloudvin --gname Platform-Admins --rolename platform-admin
$KC add-roles -r kloudvin --gname Service-Developers --rolename service-developer
$KC add-roles -r kloudvin --gname Learners --rolename learner
The principle here is group → role indirection: applications are coded against stable role names (platform-admin), while upstream group names from Entra and Okta — which differ and change — only ever attach to a group. Swap an IdP and the apps never notice.
3. Broker Microsoft Entra ID as an OIDC identity provider
First, in Entra ID, register an App registration for Keycloak: set the redirect URI to https://sso.kloudvin.internal/realms/kloudvin/broker/entra/endpoint, generate a client secret, and store it in Vault. Note the Application (client) ID, the tenant ID, and add the groups optional claim (or a groups overage handling via the Graph API) so Entra emits group object IDs.
# Store the Entra client secret in Vault, then read it back for kcadm
vault kv put secret/kloudvin/keycloak/entra client_secret='<paste-from-entra>'
ENTRA_SECRET=$(vault kv get -field=client_secret secret/kloudvin/keycloak/entra)
ENTRA_TENANT=11111111-2222-3333-4444-555555555555
$KC create identity-provider/instances -r kloudvin \
-s alias=entra \
-s providerId=oidc \
-s enabled=true \
-s 'displayName=Corporate (Entra ID)' \
-s 'config.clientId=<entra-application-id>' \
-s "config.clientSecret=$ENTRA_SECRET" \
-s "config.issuer=https://login.microsoftonline.com/$ENTRA_TENANT/v2.0" \
-s "config.authorizationUrl=https://login.microsoftonline.com/$ENTRA_TENANT/oauth2/v2.0/authorize" \
-s "config.tokenUrl=https://login.microsoftonline.com/$ENTRA_TENANT/oauth2/v2.0/token" \
-s "config.jwksUrl=https://login.microsoftonline.com/$ENTRA_TENANT/discovery/v2.0/keys" \
-s 'config.defaultScope=openid profile email' \
-s 'config.clientAuthMethod=client_secret_post' \
-s 'config.syncMode=FORCE'
syncMode=FORCE re-applies attribute and group mappers on every login, so when a user’s Entra group membership changes, their Keycloak groups update on next sign-in rather than going stale.
4. Broker Okta as a second OIDC identity provider
In the Okta admin console, create an OIDC Web app, set the sign-in redirect URI to https://sso.kloudvin.internal/realms/kloudvin/broker/okta/endpoint, and add a groups claim to the ID token (filter: matches regex .* or a scoped prefix). Capture the client ID/secret and your Okta domain.
vault kv put secret/kloudvin/keycloak/okta client_secret='<paste-from-okta>'
OKTA_SECRET=$(vault kv get -field=client_secret secret/kloudvin/keycloak/okta)
OKTA_DOMAIN=kloudvin.okta.com
$KC create identity-provider/instances -r kloudvin \
-s alias=okta \
-s providerId=oidc \
-s enabled=true \
-s 'displayName=Acquired BU (Okta)' \
-s 'config.clientId=<okta-client-id>' \
-s "config.clientSecret=$OKTA_SECRET" \
-s "config.issuer=https://$OKTA_DOMAIN/oauth2/default" \
-s "config.authorizationUrl=https://$OKTA_DOMAIN/oauth2/default/v1/authorize" \
-s "config.tokenUrl=https://$OKTA_DOMAIN/oauth2/default/v1/token" \
-s "config.jwksUrl=https://$OKTA_DOMAIN/oauth2/default/v1/keys" \
-s 'config.defaultScope=openid profile email groups' \
-s 'config.clientAuthMethod=client_secret_post' \
-s 'config.syncMode=FORCE'
You now have two upstream IdPs. The Keycloak login page shows both buttons; a user picks their home org.
5. Map upstream groups to Keycloak groups (the brokering payoff)
This is where federation becomes useful. An IdP mapper of type advanced-group reads a claim from the external token and, when it matches, drops the user into a Keycloak group — which (from Step 2) carries the application role. Do this per IdP because Entra emits group object IDs while Okta emits group names.
# Find the internal id of the entra IdP (mappers attach to it by alias)
# Entra: a specific group OBJECT ID -> Platform-Admins group
$KC create identity-provider/instances/entra/mappers -r kloudvin \
-s name='entra-platform-admins' \
-s identityProviderAlias=entra \
-s identityProviderMapper=oidc-advanced-group-idp-mapper \
-s 'config."claims"=[{"key":"groups","value":"a1b2c3d4-0000-0000-0000-aaaaaaaaaaaa"}]' \
-s 'config."are.claim.values.regex"=false' \
-s 'config.syncMode=FORCE' \
-s 'config.group=/Platform-Admins'
# Okta: a group NAME -> Service-Developers group
$KC create identity-provider/instances/okta/mappers -r kloudvin \
-s name='okta-service-devs' \
-s identityProviderAlias=okta \
-s identityProviderMapper=oidc-advanced-group-idp-mapper \
-s 'config."claims"=[{"key":"groups","value":"service-developers"}]' \
-s 'config."are.claim.values.regex"=false' \
-s 'config.syncMode=FORCE' \
-s 'config.group=/Service-Developers'
Keep a mapper per (IdP, group) pair. Because the Keycloak group is bound to a realm role, a brokered Entra admin automatically receives the platform-admin role with zero per-app configuration. Maintain the mapping table — claim value to group — in version control next to the realm export; it is the authoritative answer to “why does this person have admin?”
6. Register OIDC clients with tailored client scopes
Now register the downstream apps. Each client gets only the scopes it needs. First create a reusable client scope that puts realm roles into the token as a flat roles claim, then attach it to clients that should receive roles (the admin console and microservices), but not to a client that has no business seeing them.
# A client scope that emits realm roles as a top-level "roles" array
$KC create client-scopes -r kloudvin \
-s name=app-roles \
-s protocol=openid-connect \
-s 'attributes."include.in.token.scope"=true' \
-s 'attributes."display.on.consent.screen"=false'
SCOPE_ID=$($KC get client-scopes -r kloudvin --fields id,name \
| jq -r '.[] | select(.name=="app-roles") | .id')
$KC create client-scopes/$SCOPE_ID/protocol-mappers/models -r kloudvin \
-s name=realm-roles-to-roles-claim \
-s protocol=openid-connect \
-s protocolMapper=oidc-usermodel-realm-role-mapper \
-s 'config."claim.name"=roles' \
-s 'config."jsonType.label"=String' \
-s 'config."multivalued"=true' \
-s 'config."access.token.claim"=true' \
-s 'config."id.token.claim"=true'
Register the admin console as a confidential client (server-rendered, holds a secret) and the microservice as a bearer-only resource server. Add app-roles as a default scope so roles ride in every token.
# Confidential web client: the admin console
$KC create clients -r kloudvin \
-s clientId=admin-console \
-s enabled=true \
-s publicClient=false \
-s standardFlowEnabled=true \
-s 'redirectUris=["https://admin.kloudvin.internal/*"]' \
-s 'webOrigins=["https://admin.kloudvin.internal"]' \
-s 'defaultClientScopes=["openid","profile","email","app-roles"]'
# Bearer-only resource server: a microservice that validates tokens, never logs users in
$KC create clients -r kloudvin \
-s clientId=orders-api \
-s enabled=true \
-s bearerOnly=true \
-s publicClient=false
# Public SPA pattern would instead set publicClient=true + PKCE; shown here for the admin UI if SPA:
# -s publicClient=true -s 'attributes."pkce.code.challenge.method"=S256'
Pull the admin console’s generated secret and stash it in Vault for the app to consume — it never belongs in app source or a CI variable.
ADMIN_CLIENT_ID=$($KC get clients -r kloudvin -q clientId=admin-console --fields id | jq -r '.[0].id')
SECRET=$($KC get clients/$ADMIN_CLIENT_ID/client-secret -r kloudvin | jq -r .value)
vault kv put secret/kloudvin/apps/admin-console oidc_client_secret="$SECRET"
7. Wire the Moodle client (an off-the-shelf OIDC consumer)
Self-hosted Moodle uses the OAuth2/OIDC plugin and expects the standard scopes plus an email-based account match. Register it as a confidential client and lean on Keycloak’s discovery document so Moodle auto-configures its endpoints.
$KC create clients -r kloudvin \
-s clientId=moodle \
-s enabled=true \
-s publicClient=false \
-s standardFlowEnabled=true \
-s 'redirectUris=["https://learn.kloudvin.internal/admin/oauth2callback.php"]' \
-s 'defaultClientScopes=["openid","profile","email","app-roles"]'
In Moodle’s Site administration → Server → OAuth 2 services, create a custom service and point its discovery URL at:
https://sso.kloudvin.internal/realms/kloudvin/.well-known/openid-configuration
Moodle then reads the issuer, authorization, token, JWKS, and userinfo endpoints automatically. Learners from either Entra or Okta who land in the Learners group get the learner role and a provisioned Moodle account on first login — no manual enrollment.
8. Run the realm as code (CI + change control)
Stop configuring identity by hand. Export the realm and commit the artifact; deployments re-import it. This is the version-controlled source of truth that GitHub Actions or Jenkins ships, gated by a ServiceNow change request so identity changes carry an approval trail.
# Export realm (sans secrets) for git
$KC get realms/kloudvin --format json > realm-kloudvin.json
$KC get realms/kloudvin/identity-provider/instances > idps.json
$KC get clients -r kloudvin > clients.json
A GitHub Actions job applies the realm via the Keycloak Config CLI on each merge to main:
# .github/workflows/keycloak-apply.yml (illustrative)
jobs:
apply-realm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Import realm via keycloak-config-cli
run: |
docker run --rm -v "$PWD:/config" \
-e KEYCLOAK_URL=https://sso.kloudvin.internal \
-e KEYCLOAK_USER=ci-bot \
-e KEYCLOAK_PASSWORD="${{ secrets.KC_CI_PASSWORD }}" \
-e IMPORT_FILES_LOCATIONS='/config/realm-kloudvin.json' \
adorsys/keycloak-config-cli:latest
Provision the underlying VM or Kubernetes resources with Terraform, and use Ansible to lay down the TLS certs, the systemd unit, and Postgres tuning on the host. Keep upstream IdP App registrations described in Terraform too (the azuread and okta providers) so the Entra/Okta side is reproducible, not click-ops.
9. Operability: observability, runtime security, posture
Before going live, instrument and harden.
- Datadog (or Dynatrace): scrape Keycloak’s
/metricsendpoint and ship the JSON logs. Alert onfailed loginrate spikes (credential stuffing), broker callback errors (an IdP secret expired), and JVM/DB saturation. A sudden cliff in successful brokered logins from one IdP is your earliest signal that Entra or Okta rotated a secret out from under you. - CrowdStrike Falcon: run the sensor on the Keycloak host or node pool for runtime threat detection on the single most security-critical service you operate — anything that can mint tokens deserves EDR.
- Wiz / Wiz Code: continuous cloud posture scanning of the Keycloak infra (is the admin console exposed publicly? is the DB encrypted? is TLS enforced?), and Wiz Code scans the realm/Terraform IaC in the PR for misconfigurations before they merge.
- Virtual appliances: if Keycloak sits in a DMZ, front it with a virtual firewall/WAF appliance and restrict the admin endpoint (
/admin) to an internal management CIDR so the public path only ever reaches/realms/.../protocol/....
Validation
Prove the end-to-end flow with real requests.
# 1. Discovery document resolves and lists the right issuer
curl -s https://sso.kloudvin.internal/realms/kloudvin/.well-known/openid-configuration \
| jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'
# 2. Both IdPs are registered and enabled
$KC get identity-provider/instances -r kloudvin --fields alias,enabled,providerId
# 3. Confirm roles land in the token. Use a confidential client + a test user.
TOKEN=$(curl -s -X POST \
https://sso.kloudvin.internal/realms/kloudvin/protocol/openid-connect/token \
-d grant_type=password \
-d client_id=admin-console \
-d client_secret="$(vault kv get -field=oidc_client_secret secret/kloudvin/apps/admin-console)" \
-d username=test.admin -d password='<test-pw>' \
-d scope='openid app-roles' | jq -r .access_token)
# Decode the access token payload and look for the roles claim
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{preferred_username, roles}'
A successful run prints "roles": ["platform-admin", ...]. To validate brokering specifically, open https://admin.kloudvin.internal in a browser, choose the Entra (or Okta) button, sign in upstream, and confirm you return authenticated with the expected role. Check Users → (your user) → Identity provider links in the admin UI to see the broker link recorded.
Rollback and teardown
Every object created above is reversible with kcadm delete. Tear down in reverse dependency order (clients and mappers first, then IdPs, then the realm):
# Remove a single client
CID=$($KC get clients -r kloudvin -q clientId=moodle --fields id | jq -r '.[0].id')
$KC delete clients/$CID -r kloudvin
# Remove an IdP (its mappers go with it)
$KC delete identity-provider/instances/okta -r kloudvin
# Disable an IdP instead of deleting (safer mid-incident — keeps the link history)
$KC update identity-provider/instances/entra -r kloudvin -s enabled=false
# Nuclear option: drop the whole realm (irreversible — export first!)
$KC get realms/kloudvin --format json > /tmp/realm-backup-$(date +%F).json
$KC delete realms/kloudvin
For a clean re-deploy, point keycloak-config-cli at the last good realm-kloudvin.json from git — the realm rebuilds to a known state. Because clients read their secrets from Vault, a realm rebuild that regenerates secrets only requires re-running the vault kv put step, not redeploying every app.
Common pitfalls
- Redirect URI mismatch. The broker endpoint URI registered in Entra/Okta must match
…/realms/kloudvin/broker/<alias>/endpointexactly, alias included. A trailing slash or wrong alias yields a silentredirect_urirejection at the upstream IdP. - Proxy headers unset. Without
KC_PROXY_HEADERS=xforwarded, Keycloak behind Akamai/ingress buildshttp://internal-hostredirect URIs and the flow loops. Set it. - Groups claim missing upstream. Entra needs the
groupsoptional claim configured (and group overage handled via Graph for users in >200 groups); Okta needs a groups claim added to the authorization server. No claim, no mapping — the user logs in but lands in no group. - Entra emits object IDs, not names. Your Entra mappers must match group GUIDs; copying Okta’s name-based mapper to Entra silently matches nothing.
- Forgetting
syncMode=FORCE. With the defaultIMPORT, group membership is set only at first login and never refreshes; a demoted admin keeps their role until the user is deleted. UseFORCEfor anything authorization-bearing. - First-login flow account linking. When a brokered email already exists locally, Keycloak’s default first-broker-login flow asks the user to link accounts. For workforce SSO, configure an automatic-link or trusted-email flow so users are not prompted.
Security notes
Keycloak is a token-minting service — treat it as tier-0. Keep the admin endpoint off the public internet (management CIDR only, enforced by a virtual firewall appliance); hold every IdP and client secret in HashiCorp Vault and rotate on a schedule; pin sslRequired=external so no token ever traverses plain HTTP. Scope clients tightly — bearerOnly for resource servers, PKCE for public SPAs, and attach the app-roles scope only to clients that legitimately need roles. Run CrowdStrike Falcon on the host and let Wiz continuously verify the posture (no public admin, TLS enforced, DB encrypted), with Wiz Code catching IaC drift in PRs. Enable Keycloak’s brute-force detection and feed its event log to Datadog so a credential-stuffing burst pages someone.
Cost notes
Keycloak itself is open-source — the spend is the infrastructure and the operational discipline. A two-node HA cluster (for failover) plus a small managed Postgres comfortably handles tens of thousands of users; size by concurrent logins per second, not total user count, since brokered sessions are bursty at shift change. The real savings is consolidation: retiring per-app login stacks and a redundant commercial SSO seat for the acquired Okta org, collapsing both Entra and Okta populations behind one issuer your apps integrate with once. Push realm changes through CI (GitHub Actions/Jenkins) and IaC (Terraform/Ansible) so the platform is maintained by a small team, and route every change through a ServiceNow approval to keep the audit cost — not just the compute cost — low.