Keycloak done right: Active Directory, MFA and a client factory for your teams

Contents

What this is about

In most companies, Active Directory is the source of truth for identities, while modern applications expect OpenID Connect or SAML. Keycloak bridges that gap: it federates the existing AD over LDAPS, becomes the central identity provider for every application and adds MFA on top, without touching AD itself.

It gets interesting when you onboard not three applications but three hundred. Then success no longer depends on the LDAP configuration, but on how teams get their OIDC clients quickly and safely, without opening a ticket with the IAM team for every single one. This guide covers both: the AD integration the way I build it for production, and a client factory through which teams manage their own clients. All paths refer to Keycloak 26.

Key takeaways

  • One central realm with AD integration, MFA and a role model, built properly once and used by every application.
  • Resilient: LDAPS against at least two domain controllers, with nested groups resolved by the DC itself.
  • MFA without changing AD: OTP, security keys or passkeys, controlled through AD groups.
  • Self-service instead of tickets: teams describe their OIDC clients in Git, and a pipeline creates them with Terraform.
  • Guardrails in the realm: client policies enforce PKCE, allowed redirect domains and lean tokens, no matter who creates the client.

Architecture at a glance

Architecture: web apps, legacy apps and APIs talk OIDC, SAML or OAuth 2.0 to Keycloak 26. Keycloak connects to Active Directory with two domain controllers over LDAPS on port 636, and an optional IGA system such as Saviynt or OpenText manages the lifecycle in AD.
Keycloak as the identity provider in front of Active Directory. Applications only see OIDC and SAML, AD only sees LDAPS.
  • Applications talk only OIDC or SAML to Keycloak. LDAP and AD stay invisible to them.
  • Keycloak authenticates, runs the authentication flows including MFA and issues tokens with the right claims.
  • Active Directory remains the user directory. Keycloak verifies passwords with an LDAP bind directly against the DC and never imports them. An IGA system such as Saviynt, SailPoint or OpenText Identity Manager can own the identity lifecycle.
Sequence diagram: the browser opens the application, the application redirects to Keycloak, the user enters username and password, Keycloak looks up the user with the service account and verifies the password with a bind against the DC, then MFA follows and the tokens are issued.
One login in detail: steps 4 and 5 run against AD, everything else happens in Keycloak.

Connecting Active Directory: five decisions that matter

The LDAP provider is configured in an hour. Whether it runs reliably for years depends on these five points.

1. LDAPS against at least two domain controllers

User federation → Add LDAP providers

Vendor:               Active Directory
Connection URL:       ldaps://dc1.example.com:636 ldaps://dc2.example.com:636
Use Truststore SPI:   Always
Connection pooling:   On
Connection timeout:   3000
Bind DN:              CN=svc-keycloak,OU=Service Accounts,DC=example,DC=com
Bind credentials:     ${vault.ldap_credential}
  • Separate DCs with spaces only. Keycloak tries them from left to right, and the Connection timeout applies per unreachable DC. At 3000 ms, failover stays quick.
  • Truststore: put the AD root CA as PEM into conf/truststores or add it via KC_TRUSTSTORE_PATHS. The FQDN in the connection URL must be in the SAN of the DC certificates.
  • Test first: whatever works with ldapsearch from the Keycloak host works in Keycloak too.
LDAPTLS_CACERT=/etc/pki/ad-root-ca.pem ldapsearch -LLL -H ldaps://dc1.example.com:636 \
  -D 'CN=svc-keycloak,OU=Service Accounts,DC=example,DC=com' -W \
  -b 'OU=Users,DC=example,DC=com' '(sAMAccountName=jdoe)' mail memberOf

2. Vendor, edit mode and username before the first save

Vendor Active Directory is the most important click of the whole setup. It sets the AD defaults (objectGUID, object classes) and creates the matching mappers, above all MSAD account controls. Because the mappers are derived from vendor, edit mode and username attribute, these three values belong in the first save:

Edit modeWhenPassword changes
READ_ONLYAn IGA or an existing self-service portal owns the lifecycle.outside Keycloak
WRITABLEThe Keycloak account console becomes the self-service.over LDAPS straight to AD

As the Username LDAP attribute, use sAMAccountName for logins like jdoe and userPrincipalName for email-style logins.

3. Cut the scope with an LDAP filter

Instead of importing half the directory, a filter fetches exactly the users who should have access, including nested groups:

(&(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=APP-Keycloak-Users,OU=Groups,DC=example,DC=com))

1.2.840.113556.1.4.1941 is LDAP_MATCHING_RULE_IN_CHAIN: the DC resolves transitive membership on the server side. Add Search scope: Subtree and Pagination: On, because AD returns at most 1000 entries per request.

4. Sync and cache

Periodic changed users sync:   3600       # hourly
Periodic full sync:            86400      # nightly
Cache policy:                  MAX_LIFESPAN
Max lifespan:                  180000     # 3 minutes

The nightly full sync has a concrete reason: when a user is added to a group in AD, the group object changes, not the user object. Anyone who newly falls into the scope through the memberOf filter therefore appears with the full sync or at first login. A three-minute cache keeps the load on the DCs low while changes still show up within a predictable time.

5. Reliably lock disabled accounts

The MSAD account controls mapper translates userAccountControl into Keycloak behaviour: an account disabled in AD is disabled in Keycloak. With Always Read Enabled Value From LDAP: On, this also applies to logins without a password bind, such as passkeys. In WRITABLE, an expired AD password leads straight to the required action Update Password.

Groups and roles from AD

A group-ldap-mapper imports the relevant AD groups into /ad:

LDAP Groups DN:                 OU=Groups,DC=example,DC=com
LDAP Filter:                    (cn=APP-*)
Preserve Group Inheritance:     Off
User Groups Retrieve Strategy:  LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY
Groups Path:                    /ad
Mode:                           READ_ONLY

The recursive strategy resolves nested groups directly on the DC. Preserve Group Inheritance: Off keeps groups with multiple parents from breaking the sync, and the LDAP Filter keeps thousands of irrelevant groups out.

The result is a clean role model: the AD group APP-Admins appears as /ad/APP-Admins, and the role app-admin is attached to that group. Membership is maintained in AD and its existing approval processes, permissions in Keycloak. In the token, roles end up in realm_access.roles, and groups via a groups client scope with a group membership mapper:

{
  "preferred_username": "jdoe",
  "groups": ["APP-Admins", "APP-Users"],
  "realm_access": { "roles": ["app-admin"] }
}

MFA, controlled through AD groups

This is where Keycloak in front of AD adds the most value: one MFA layer for every application, OIDC or SAML, without MFA infrastructure in AD.

  1. Authentication → browser → Action → Duplicate as browser-mfa.
  2. In the forms subflow, add a subflow MFA enforcement (Conditional) with Condition - User Role on the role mfa-required, Condition - sub-flow executed on the 2FA subflow with not-executed, and below them OTP Form as Required. The second condition keeps users who already have OTP from being asked twice.
  3. Import the AD group APP-MFA-Required with the group mapper and bind it to mfa-required.
  4. Action → Bind flow → Browser flow.

The MFA rollout now follows AD group maintenance, department by department, without a single click in Keycloak. Add passkeys (Webauthn Register Passwordless) and recovery codes as a fallback in the same flow. Set brute force detection below the AD lockout threshold, so password spraying through the login page cannot lock AD accounts.

Client factory: teams manage their own OIDC clients

A central realm only pays off when new applications can connect quickly. The classic way is a ticket to the IAM team, manual setup in the admin console and a client secret sent over chat. That does not scale, and it is the opposite of secure.

The answer is a client factory. The realm with AD integration, MFA and role model is built once, centrally, and teams manage their OIDC clients in it themselves, through pull requests and within firm guardrails.

Client factory: a team describes its client in client.yaml, after review in a pull request a pipeline creates the client in the central realm with terraform apply. The client secret goes to the secret store, from which the team's app reads it. Client policies and admin permissions apply in the realm.
The client factory: self-service for teams, guardrails and an audit trail for the IAM team.

Teams in the admin console: their own clients only

With fine-grained admin permissions v2 (Keycloak 26.2 and later, Realm settings → Admin Permissions), team admins see and manage exactly their own clients in the admin console. This comes from the factory as well: the resource keycloak_openid_client_admin_permissions grants each team the scopes view and manage on its clients, bound to the team’s AD group.

The AD integration as code, too

What applies to the clients applies to the realm itself: the Terraform provider keycloak/keycloak describes the complete AD integration declaratively, and terraform plan shows drift immediately.

resource "keycloak_ldap_user_federation" "ad" {
  name           = "active-directory"
  realm_id       = data.keycloak_realm.corp.id
  vendor         = "AD"
  edit_mode      = "READ_ONLY"
  import_enabled = true

  connection_url     = "ldaps://dc1.example.com:636 ldaps://dc2.example.com:636"
  use_truststore_spi = "ALWAYS"
  connection_timeout = "3s"

  bind_dn                    = "CN=svc-keycloak,OU=Service Accounts,DC=example,DC=com"
  bind_credential_wo         = var.ad_bind_password
  bind_credential_wo_version = 1

  users_dn                  = "OU=Users,DC=example,DC=com"
  username_ldap_attribute   = "sAMAccountName"
  uuid_ldap_attribute       = "objectGUID"
  user_object_classes       = ["person", "organizationalPerson", "user"]
  custom_user_search_filter = "(&(objectCategory=person)(memberOf:1.2.840.113556.1.4.1941:=CN=APP-Keycloak-Users,OU=Groups,DC=example,DC=com))"
  search_scope              = "SUBTREE"
  pagination                = true

  full_sync_period    = 86400
  changed_sync_period = 3600

  cache {
    policy       = "MAX_LIFESPAN"
    max_lifespan = "3m"
  }
}

bind_credential_wo is a write-only argument (Terraform 1.11 and later): the bind password never ends up in the state. vendor = "AD" creates the same default mappers as the UI, including MSAD account controls.

Troubleshooting: the five most common errors

SymptomCauseFix
PKIX path building failedAD CA missing from the truststoreCA into conf/truststores or KC_TRUSTSTORE_PATHS, restart
No subject alternative DNS name matching …connection URL uses an IP or aliasuse the FQDN from the DC certificate
error code 49 … data 52ewrong bind DN or passwordverify with ldapsearch
sync returns at most 1000 usersPagination is offPagination: On
nested groups missingnon-recursive strategy in the group mapperLOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY

For deeper analysis, --log-level="INFO,org.keycloak.storage.ldap:trace" logs every LDAP query.

Production checklist

  • LDAPS with at least two DCs, AD CA in the truststore, bind credential in the vault
  • Vendor, edit mode and username attribute set in the first save
  • User scope via LDAP filter, Pagination: On, hourly and nightly sync
  • Recursive group mapper with filter and Groups Path, roles bound to groups
  • MFA flow bound, recovery codes enabled, brute force below the AD lockout threshold
  • Client factory with pull request review, guardrails via client policies and secrets straight into the secret store
  • Complete configuration in Git via Terraform, plus a local emergency admin independent of AD

Conclusion

Keycloak in front of Active Directory quickly delivers what AD alone never will: OIDC and SAML for every application, MFA without changes to AD and a role model built on existing AD processes. The client factory turns that into a platform: teams connect in minutes, the IAM team keeps the guardrails, and every change is traceable in Git.


Planning a Keycloak rollout, or want to move an existing identity platform to self-service? Get in touch: david@picillo.de