Pointing Keycloak at FreeIPA takes ten minutes. Making it behave when a user is disabled, a replica dies or a password expires takes a handful of settings the Keycloak FreeIPA defaults get wrong. In our lab, a disabled FreeIPA user kept a working Keycloak session. A powered-off first replica turned pooled LDAP connections (the default) into HTTP 500 errors. An expired FreeIPA password still logged in.
This guide connects Keycloak to FreeIPA through LDAP user federation over LDAPS: a read-only system account for the bind, the FreeIPA CA in the Keycloak truststore, FreeIPA groups (nested ones included) mapped into Keycloak groups, and failover across two FreeIPA replicas. Optional sections add password changes from Keycloak back into FreeIPA and Kerberos desktop SSO with SPNEGO. It builds on the Keycloak on Docker guide and on an existing FreeIPA domain, such as one built with the FreeIPA server install guide. Everything below was tested in October 2026 with Keycloak 26.8.0 on Ubuntu 26.04 against two FreeIPA 4.13.4 servers on Rocky Linux 10.2, then replayed from scratch with Keycloak on Ubuntu 24.04.
How Keycloak and FreeIPA fit together
Keycloak doesn’t copy passwords out of FreeIPA. It looks users up over LDAPS, imports a local copy of their profile into its own database, and checks every password by binding to FreeIPA as that user. FreeIPA stays the source of truth.

The connection URL lists both FreeIPA servers. Keycloak always tries the first one and moves to the second only when the first can’t be reached, so the order matters. The Kerberos path on the right is optional: a workstation that already holds a FreeIPA ticket signs in without typing a password.
What you need
The core path needs two things that already work, plus network access between them. Each optional section lists its own extras.
| Section | Needed for | Extra things you need |
|---|---|---|
| Core path (bind account to group mapping) | Everyone | Keycloak from the Docker guide, a FreeIPA domain, TCP 636 from Keycloak to every FreeIPA server |
| Password changes through Keycloak | Users who reset passwords in Keycloak | ipa sysaccount-policy (same versions as the core path), a shell on every FreeIPA server |
| Kerberos desktop SSO | Domain-joined workstations | An HTTP service principal in FreeIPA, browsers configured for Negotiate |
On the Keycloak side, this is the stack from the Docker guide: two Keycloak nodes, PostgreSQL and Nginx under /opt/keycloak, a realm called example and a permanent admin called kcadmin. If yours differs, change the variables below. The provider settings are realm configuration, so they also apply to a Keycloak Operator cluster on Kubernetes. There, the CA file, and for Kerberos the keytab and krb5.conf, reach the pods through the Keycloak custom resource instead of Compose volumes. Still choosing an identity server? The Keycloak, authentik, Zitadel and Authelia comparison covers that. On the FreeIPA side, the commands below need the system account plugin: FreeIPA 4.13 or newer (Rocky Linux 10 and RHEL 10 ship it), or RHEL 9.8 IdM, which has it backported. Older releases can still create a system account as a plain LDAP entry under cn=sysaccounts,cn=etc with ldapmodify as Directory Manager, which this guide doesn’t cover. Two servers are not required, but with one server there is no failover to test. The FreeIPA replica guide adds the second one.
ipa-server-install does not open any firewall ports itself. If you followed the install guide, the freeipa-4 firewalld service (which includes 636) is already open. If the port check further down fails, open it on every FreeIPA server with sudo firewall-cmd --permanent --add-service=freeipa-4 && sudo firewall-cmd --reload. Keycloak connects to FreeIPA by host name, so both FreeIPA names must resolve on the Keycloak host. LDAPS checks the certificate against the name strictly, and an IP address in the connection URL fails, as the error section shows.
Create the bind account and groups in FreeIPA
Run this part on any FreeIPA server, as a user with an admin ticket:
kinit admin
Keycloak only maps groups whose names start with sso-, which keeps the built-in FreeIPA groups such as admins and ipausers out of the realm. Create the two groups this guide uses and put a user in them. alice stands for any existing FreeIPA user, so use one of yours. The FreeIPA users and groups CLI guide covers creating them:
ipa group-add sso-users --desc="Users who sign in through Keycloak"
ipa group-add sso-admins --desc="Administrators of Keycloak-protected apps"
ipa group-add-member sso-users --users=alice
ipa group-add-member sso-admins --users=alice
Groups inside groups work too. A user in a devs group that is itself a member of sso-users lands in sso-users in Keycloak, because the FreeIPA memberOf attribute already lists indirect memberships.
Keycloak needs an account to search the directory. Do not use admin, and do not bind anonymously, because an anonymous bind cannot read nsAccountLock, memberOf or ipaUniqueID. A FreeIPA system account is the right tool. It lives under cn=sysaccounts,cn=etc, is not a POSIX user, cannot log in to hosts, and can read users and groups:
ipa sysaccount-add keycloak --desc="Keycloak LDAP federation" --random
FreeIPA prints a random password once. Copy it now, because nothing shows it again:
-------------------------------
Added system account "keycloak"
-------------------------------
Privileged: False
System account ID: keycloak
Description: Keycloak LDAP federation
Random password: <printed once, copy it now>
Account disabled: False
Trust the FreeIPA CA in Keycloak
The FreeIPA LDAPS certificate is signed by the FreeIPA CA, which no Java truststore knows. Keycloak 26 loads any certificate file it finds in /opt/keycloak/conf/truststores, so the job is to put the CA there. Set the variables first, on the Keycloak host. IPA_SERVERS lists your FreeIPA servers in the order Keycloak should try them, and IPA_BASE_DN is your FreeIPA domain written as dc= parts:
export IPA_SERVERS="ipa01.example.com ipa02.example.com"
export IPA_BASE_DN="dc=example,dc=com"
export REALM="example"
These variables, like KCADM, LDAP_ID and the passwords further down, live only in this shell. After you reconnect, run the export block and the kcadm.sh login block again, and look up LDAP_ID as the optional sections show. If you do not know the base DN, ipa env basedn on a FreeIPA server prints it.
Every FreeIPA server publishes the CA certificate over HTTP. Download it from the first server and print its fingerprint:
sudo curl -fsSo /opt/keycloak/freeipa-ca.crt "http://${IPA_SERVERS%% *}/ipa/config/ca.crt"
openssl x509 -in /opt/keycloak/freeipa-ca.crt -noout -subject -enddate -fingerprint -sha256
The subject names your FreeIPA realm:
subject=O = EXAMPLE.COM, CN = Certificate Authority
notAfter=Oct 10 10:04:51 2046 GMT
sha256 Fingerprint=7B:6F:FD:13:87:10:76:F1:BC:81:E6:7D:10:64:8D:27:F7:41:F0:A9:1D:E8:BD:EB:07:31:94:1D:6B:89:D5:18
The download is plain HTTP, so compare the fingerprint with the one on the FreeIPA server before trusting it:
openssl x509 -in /etc/ipa/ca.crt -noout -fingerprint -sha256
The two values must be identical:
sha256 Fingerprint=7B:6F:FD:13:87:10:76:F1:BC:81:E6:7D:10:64:8D:27:F7:41:F0:A9:1D:E8:BD:EB:07:31:94:1D:6B:89:D5:18
Now mount the file into both Keycloak containers. Open the Compose file:
sudo vim /opt/keycloak/compose.yaml
Add a volumes entry to the shared x-keycloak block, at the same indentation as mem_limit, so both nodes get it:
volumes:
- ./freeipa-ca.crt:/opt/keycloak/conf/truststores/freeipa-ca.crt:ro
If the FreeIPA names resolve only through /etc/hosts on the Docker host, the containers cannot see them. Docker gives containers the DNS servers of the host, not its hosts file. Add the addresses to the same block in that case:
extra_hosts:
- "ipa01.example.com:10.0.1.11"
- "ipa02.example.com:10.0.1.12"
Recreate the containers. The last line confirms Keycloak found the CA:
cd /opt/keycloak
sudo docker compose up -d --wait
sudo docker compose logs keycloak1 | grep -m1 "truststore files"
Keycloak logs the file it picked up:
keycloak1-1 | 2026-10-10 10:19:01,753 INFO [org.keycloak.truststore.TruststoreBuilder] (main) Found the following truststore files in the truststore paths [/opt/keycloak/bin/../conf/truststores/freeipa-ca.crt]
Check that both containers reach port 636 on every FreeIPA server before going further:
for c in keycloak1 keycloak2; do for h in ${IPA_SERVERS}; do sudo docker compose exec -T "${c}" bash -c "echo > /dev/tcp/${h}/636" && echo "${c} -> ${h}:636 reachable"; done; done
Each server should report as reachable:
keycloak1 -> ipa01.example.com:636 reachable
keycloak1 -> ipa02.example.com:636 reachable
keycloak2 -> ipa01.example.com:636 reachable
keycloak2 -> ipa02.example.com:636 reachable
Add the FreeIPA user federation provider
The provider is created with kcadm.sh inside the container, the same way the Docker guide manages the realm. Recreating the containers wiped the stored kcadm.sh login, so log in again with the kcadmin password from that guide. The read line prompts for it, so this block also works in a fresh shell:
read -rsp "Password of kcadmin: " KC_ADMIN_PASS; echo
KCADM="sudo docker compose -f /opt/keycloak/compose.yaml exec -T keycloak1 /opt/keycloak/bin/kcadm.sh"
$KCADM config credentials --server http://localhost:8080 --realm master --user kcadmin --password "${KC_ADMIN_PASS}"
Read the bind password into a variable without echoing it to the screen or into your shell history. It reaches Keycloak through a pipe, so it never appears on a command line. The kcadmin password above does: $KCADM runs through sudo, which logs the full command line, so it lands in the system journal. Run these steps on a host where only admins read the logs, or add your user to the docker group and drop sudo from KCADM:
read -rsp "Password of uid=keycloak: " LDAP_BIND_PW; echo
The provider configuration is long, so it goes in as JSON built with jq (install it with sudo apt install -y jq if it is missing). Every value is deliberate, and the LDAP section of the Keycloak server admin guide describes each option:
| Setting | Value | Why |
|---|---|---|
| vendor | rhds | FreeIPA runs 389 Directory Server, which Keycloak calls Red Hat Directory Server |
| editMode | READ_ONLY | FreeIPA owns users. Keycloak never writes to the directory |
| uuidLDAPAttribute | ipaUniqueID | Identical on every replica. nsUniqueId and entryUUID are not readable by the system account |
| customUserSearchFilter | (!(nsAccountLock=TRUE)) | Disabled FreeIPA users stop resolving, which kills their Keycloak sessions |
| connectionPooling | false | Pooled connections to a dead replica returned HTTP 500 in our failover tests |
| connectionTimeout / readTimeout | 1000 / 5000 ms | A dead first server costs 2 seconds per login instead of 6 |
| cachePolicy | NO_CACHE | Group changes in FreeIPA apply at the next login instead of after the next sync |
| changedSyncPeriod | 300 s | Picks up email changes and anything else the cache misses |
| removeInvalidUsersEnabled | false | Disabled or deleted FreeIPA users stay in Keycloak as disabled instead of being deleted and re-created |
Create the provider:
LDAP_URLS=$(printf 'ldaps://%s ' ${IPA_SERVERS}); LDAP_URLS="${LDAP_URLS% }"
REALM_ID=$($KCADM get "realms/${REALM}" --fields id --format csv --noquotes)
jq -n --arg parent "${REALM_ID}" --arg urls "${LDAP_URLS}" --arg base "${IPA_BASE_DN}" --arg pw "${LDAP_BIND_PW}" '{
name: "freeipa", providerId: "ldap", providerType: "org.keycloak.storage.UserStorageProvider", parentId: $parent,
config: {
enabled: ["true"], vendor: ["rhds"], editMode: ["READ_ONLY"], importEnabled: ["true"], syncRegistrations: ["false"],
connectionUrl: [$urls], useTruststoreSpi: ["always"], startTls: ["false"],
connectionTimeout: ["1000"], readTimeout: ["5000"], connectionPooling: ["false"], pagination: ["true"],
authType: ["simple"], bindDn: ["uid=keycloak,cn=sysaccounts,cn=etc," + $base], bindCredential: [$pw],
usersDn: ["cn=users,cn=accounts," + $base], searchScope: ["1"],
usernameLDAPAttribute: ["uid"], rdnLDAPAttribute: ["uid"], uuidLDAPAttribute: ["ipaUniqueID"],
krbPrincipalAttribute: ["krbPrincipalName"],
userObjectClasses: ["inetOrgPerson, organizationalPerson"],
customUserSearchFilter: ["(!(nsAccountLock=TRUE))"], removeInvalidUsersEnabled: ["false"],
trustEmail: ["true"], validatePasswordPolicy: ["false"], cachePolicy: ["NO_CACHE"],
fullSyncPeriod: ["86400"], changedSyncPeriod: ["300"], batchSizeForSync: ["1000"]
}}' | $KCADM create components -r "${REALM}" -f -
LDAP_ID=$($KCADM get components -r "${REALM}" -q name=freeipa --fields id --format csv --noquotes)
echo "LDAP provider: ${LDAP_ID}"
Keycloak prints the new component ID and the script keeps it in LDAP_ID:
Created new component with id 'VH6SmjmXSFO6rf4UV_Cx_A'
LDAP provider: VH6SmjmXSFO6rf4UV_Cx_A

The provider creates its default attribute mappers on its own. One of them is wrong for FreeIPA. With uid as the RDN attribute, the first name mapper reads cn. In FreeIPA that holds the full name, so a user called Carol Njeri gets the first name “Carol Njeri”. Point it at givenName:
FIRST_ID=$($KCADM get components -r "${REALM}" -q parent="${LDAP_ID}" -q name="first name" --fields id --format csv --noquotes)
$KCADM update "components/${FIRST_ID}" -r "${REALM}" -s 'config."ldap.attribute"=["givenName"]'
Map FreeIPA groups into Keycloak
One group mapper does the job. It reads the memberOf attribute of each user instead of searching every group, which is faster and includes nested groups for free. Keep it to one mapper per provider. Several group mappers on the same provider are a known Keycloak bug, tracked in issue 45256.
jq -n --arg parent "${LDAP_ID}" --arg base "${IPA_BASE_DN}" '{
name: "freeipa-groups", providerId: "group-ldap-mapper", providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", parentId: $parent,
config: {
"groups.dn": ["cn=groups,cn=accounts," + $base], "groups.ldap.filter": ["(cn=sso-*)"],
"group.name.ldap.attribute": ["cn"], "group.object.classes": ["groupOfNames"],
"membership.ldap.attribute": ["member"], "membership.attribute.type": ["DN"],
"membership.user.ldap.attribute": ["uid"], "memberof.ldap.attribute": ["memberOf"],
"user.roles.retrieve.strategy": ["GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE"],
"preserve.group.inheritance": ["false"], "ignore.missing.groups": ["false"],
"drop.non.existing.groups.during.sync": ["false"], mode: ["READ_ONLY"]
}}' | $KCADM create components -r "${REALM}" -f -
GROUPS_ID=$($KCADM get components -r "${REALM}" -q name=freeipa-groups --fields id --format csv --noquotes)
Pull the groups and then all users into Keycloak. The first command creates the Keycloak groups, the second imports every user that the filter allows:
$KCADM create "user-storage/${LDAP_ID}/mappers/${GROUPS_ID}/sync?direction=fedToKeycloak" -r "${REALM}" -o
$KCADM create "user-storage/${LDAP_ID}/sync?action=triggerFullSync" -r "${REALM}" -o
Both report what they did:
{
"ignored" : false,
"added" : 2,
"updated" : 0,
"removed" : 0,
"failed" : 0,
"status" : "2 imported groups, 0 updated groups, 0 removed groups"
}
{
"ignored" : false,
"added" : 3,
"updated" : 0,
"removed" : 0,
"failed" : 0,
"status" : "3 imported users, 0 updated users"
}

Verify the Keycloak FreeIPA sync
List the imported users and check the groups of one user. First names should be plain first names now:
$KCADM get users -r "${REALM}" -q search='*' --fields username,firstName,lastName,email --format csv --noquotes
ALICE_ID=$($KCADM get users -r "${REALM}" -q username=alice -q exact=true --fields id --format csv --noquotes)
$KCADM get "users/${ALICE_ID}/groups" -r "${REALM}" --fields name --format csv --noquotes
The users carry their FreeIPA attributes, and alice is in both groups:
alice,Alice,Wanjiru,[email protected]
bob,Bob,Otieno,[email protected]
carol,Carol,Njeri,[email protected]
sso-admins
sso-users
In the admin console the same users appear under Users, each linked to the freeipa provider. Search with * to list them all.

A user who is in sso-users only through a nested group shows the group the same way as a direct member:

Apps see these groups through the groups claim that the Docker guide created. In its Grafana example, change the role expression to the FreeIPA group name, for example contains(groups[*], 'sso-admins') && 'Admin' || 'Viewer'. FreeIPA users then sign in to Grafana with their FreeIPA passwords and get their role from FreeIPA membership:

The core setup is done at this point. FreeIPA users sign in to every Keycloak client with their FreeIPA password. The next three sections apply to everyone running this for real. The two after them are optional.
Stop Keycloak from locking FreeIPA accounts
Every failed Keycloak login is a failed LDAP bind, and FreeIPA counts it. Check the global password policy:
ipa pwpolicy-show global_policy
The defaults lock an account for 10 minutes after 6 failures:
Group: global_policy
Max lifetime (days): 90
Min lifetime (hours): 1
History size: 0
Character classes: 0
Min length: 8
Max failures: 6
Failure reset interval: 60
Lockout duration: 600
Grace login limit: -1
We sent 7 wrong passwords for one user through the Keycloak token endpoint. The 7th failure came back from FreeIPA as Too many failed logins. The correct password then failed too, and kinit against that FreeIPA server returned Client's credentials have been revoked. Anyone who can reach your Keycloak login page can lock any FreeIPA user out of every system that uses FreeIPA.
Turn on brute force detection in Keycloak with a lower threshold, so Keycloak stops sending binds before FreeIPA locks the account:
$KCADM update "realms/${REALM}" -s bruteForceProtected=true -s failureFactor=4 -s waitIncrementSeconds=120 -s maxFailureWaitSeconds=900 -s maxDeltaTimeSeconds=43200
With failureFactor=4, the same test stopped at 4 failed binds in FreeIPA, and the account stayed usable everywhere else. FreeIPA counts failures from every source, so 4 from Keycloak plus 2 from SSH or kinit inside the 60 second reset window still lock the account. The 120 second wait above outlasts that window, and a lower failureFactor gives more margin if your users also log in to hosts. Keycloak shows the user the same “Invalid username or password” message in both cases, so the difference is only visible in FreeIPA. FreeIPA keeps its failure counters per server and does not replicate them. A user locked on ipa01 logged in through ipa02 without trouble during our failover test.
How fast FreeIPA changes reach Keycloak
These are the measured times between a change in FreeIPA and Keycloak acting on it. The default Keycloak cache policy is shown for comparison:
| Change in FreeIPA | DEFAULT cache policy | NO_CACHE (this guide) |
|---|---|---|
User disabled (ipa user-disable) | Next request: new logins fail, refresh tokens rejected with “User disabled” | Same |
| User deleted | Next request, same as disabled. The user stays in Keycloak, disabled | Same |
| New user | First login imports the user, no sync needed | Same |
| Added to or removed from a group | Next changed-users sync. Measured 206 s for an add and 292 s for a removal. A login does not refresh it | At once |
| Last name changed | Next changed-users sync | At once |
| Email changed | Next changed-users sync (about 6 minutes in our run) | Next changed-users sync (2 min 16 s in our run) |
The group row matters most. With the default cache, removing someone from sso-admins leaves them an admin in every app for up to one sync period. Making them log in again does not help. NO_CACHE closes that gap, and it cost almost nothing: 30 logins averaged 0.390 s with the default cache and 0.399 s with NO_CACHE. If you keep the default cache, clearing the user cache applies a removal at once: $KCADM create clear-user-cache -r "${REALM}", or in the master realm admin console, the user menu at the top right > Clear Caches > User Cache > Clear, which clears it for all realms.
The disabled-user row depends on the nsAccountLock filter. Without it, new logins of a disabled user still failed, because FreeIPA refuses the bind with Account inactivated. Their existing session kept refreshing tokens as if nothing had happened.
What a dead FreeIPA replica costs
Keycloak fails over between the servers in the connection URL, but how it fails over depends on how the first server dies. We tested both ways with ipa01 first in the URL:
| ipa01 state | Pooling on, 3 s / 10 s timeouts | Pooling on, 1 s / 5 s | Pooling off, 1 s / 5 s (this guide) |
|---|---|---|---|
| Directory service stopped | 0.23 to 0.44 s per login, 0 errors | not tested | not tested |
| VM powered off | 3.3 to 6.3 s per login, 2 of 8 logins HTTP 500 after 10 s | 2.0 to 2.3 s, 2 of 12 HTTP 500 after 10 and 20 s | 2.0 to 2.4 s, 0 of 20 failed |
| Both servers up | not tested | 0.22 s average | 0.40 s average |
A stopped service refuses the connection at once, and Keycloak moves on without any delay you would notice. A powered-off server, or one behind a dropped firewall rule, never answers, so every new connection waits for the connect timeout. The HTTP 500 errors came from pooled connections that were open to ipa01 when it died: a request picked one, waited for the read timeout and failed with StorageUnavailableException. In the 1 s / 5 s column, both failures were connections left in the pool from the earlier 10 s setting. That is why they took 10 and 20 seconds. With pooling off, every login opens fresh connections and none of them failed.
The cost of turning pooling off is about 0.18 s per login while everything is healthy. We take that over failed logins. For a planned shutdown of the first server, reorder IPA_SERVERS and update the provider first, and logins will not slow down at all.
Expired FreeIPA passwords still work in Keycloak
FreeIPA enforces password expiry in Kerberos and in its web UI, not on LDAP binds. When an admin resets a password with ipa passwd, FreeIPA marks it as expired so the user must change it at first login. Keycloak never sees that. In our test the admin-set password logged in to Keycloak straight away, and kept working.
The Keycloak Enable LDAP password policy option does not catch this either. It reacts to the LDAP password policy control, and FreeIPA does not send one. ldapwhoami -e ppolicy with the expired password returned a plain success. Have users set their own password with kinit or in the FreeIPA web UI after an admin reset, or let them change it through Keycloak with the next section.
Optional: let users change passwords through Keycloak
The core setup is read-only, so the Update password action and the account console cannot change a FreeIPA password. Turning it on takes a FreeIPA role and one policy setting on every server.
Without a role, the system account cannot write passwords, and Keycloak fails with Insufficient 'write' privilege to the 'userPassword' attribute. Give it a privilege that holds only the password-change permission. That permission excludes members of admins, so Keycloak can never reset the password of a FreeIPA admin. On a FreeIPA server:
ipa privilege-add "Keycloak password sync" --desc="Reset user passwords from Keycloak"
ipa privilege-add-permission "Keycloak password sync" --permissions="System: Change User password"
ipa role-add "Keycloak LDAP federation" --desc="Keycloak system account"
ipa role-add-privilege "Keycloak LDAP federation" --privileges="Keycloak password sync"
ipa role-add-member "Keycloak LDAP federation" --sysaccounts=keycloak
The second part is the privileged flag. A password set by anyone other than the user is marked as expired at once, and that includes a system account without the flag. In our test, a password set from Keycloak by a non-privileged account expired the second it was written. The user had to change it again at the next kinit. The flag is stored per server and is not replicated, so run this on every FreeIPA server and restart its directory service:
ipa sysaccount-policy keycloak --privileged=True
sudo systemctl restart dirsrv.target
FreeIPA reminds you that the setting is local:
ipa: WARNING: Password reset permission is local to server ipa02.example.com.
Restart the Directory Server services on it. Run the command 'ipa sysaccount-policy' against each server you want to allow or disable to reset passwords on.
Privileged: True
System account ID: keycloak
Description: Keycloak LDAP federation
Account disabled: False
Back on the Keycloak host, in a new shell, export the variables and log kcadm.sh in again as before, then look up the provider ID:
LDAP_ID=$($KCADM get components -r "${REALM}" -q name=freeipa --fields id --format csv --noquotes)
Switch the provider to writable:
$KCADM update "components/${LDAP_ID}" -r "${REALM}" -s 'config.editMode=["WRITABLE"]'
This changes less than it sounds. The attribute mappers were created while the provider was read-only, and they stay read-only, so Keycloak still never writes names or email addresses to FreeIPA. Only passwords go through. The FreeIPA minimum password lifetime still applies to them: if the user changed their own password less than an hour ago (Min lifetime in the global policy), FreeIPA rejects the write with Too soon to change password. A password that an admin reset does not count. One written by Keycloak does, so a second reset of the same user from Keycloak within the hour fails too. A user who just changed the initial password at the first kinit hits this, so test with a user whose password an admin reset, or wait an hour. Set a password from Keycloak:
read -rsp "New password for alice: " NEW_PW; echo
$KCADM set-password -r "${REALM}" --username alice --new-password "${NEW_PW}"
kcadm.sh prints nothing when the write works and Could not modify attribute for DN when it does not. The real test is a Kerberos login with the new password, on any FreeIPA-enrolled machine. An expired password would make kinit demand a new one at once:
kinit alice
klist | grep "Default principal"
kinit accepted the password without asking for a change, so FreeIPA did not mark it as expired:
Password for [email protected]:
Default principal: [email protected]

Optional: Kerberos desktop SSO with SPNEGO
Users on FreeIPA-enrolled workstations already hold a Kerberos ticket (the FreeIPA client lab shows the enrollment). Keycloak can accept that ticket instead of a password. Keycloak needs a keytab for an HTTP service principal named after the host name browsers use. Keycloak does not need to reach the FreeIPA KDC for this: in our test, blocking port 88 from the containers changed nothing, because checking a ticket only needs the keytab.
On a FreeIPA server, create the principal and its keytab. The host entry exists only to hold the service, so --force skips the DNS check. If the Keycloak host is already enrolled as a FreeIPA client under this name, the host exists, so skip the host-add line:
export KC_DOMAIN="keycloak.example.com"
ipa host-add "${KC_DOMAIN}" --force --desc="Keycloak (SPNEGO only)"
ipa service-add "HTTP/${KC_DOMAIN}"
ipa-getkeytab -s "$(hostname -f)" -p "HTTP/${KC_DOMAIN}" -k /tmp/keycloak.keytab
ipa-getkeytab generates a new key every time it runs, which invalidates any keytab created earlier for the same principal. Run it once and copy the same file to every Keycloak host. Copy /tmp/keycloak.keytab to the Keycloak host with scp, then install it readable only by the Keycloak container user (UID 1000):
sudo install -m 0400 -o 1000 -g 0 /tmp/keycloak.keytab /opt/keycloak/keycloak.keytab
sudo rm -f /tmp/keycloak.keytab
The Keycloak image ships no /etc/krb5.conf, and Java needs one to map the domain to the realm. Create it next to the Compose file:
sudo vim /opt/keycloak/krb5.conf
Add your realm and FreeIPA servers:
[libdefaults]
default_realm = EXAMPLE.COM
dns_lookup_kdc = false
rdns = false
[realms]
EXAMPLE.COM = {
kdc = ipa01.example.com
kdc = ipa02.example.com
}
[domain_realm]
.example.com = EXAMPLE.COM
example.com = EXAMPLE.COM
Extend the volumes list in the x-keycloak block of /opt/keycloak/compose.yaml, so it mounts all three files:
volumes:
- ./freeipa-ca.crt:/opt/keycloak/conf/truststores/freeipa-ca.crt:ro
- ./krb5.conf:/etc/krb5.conf:ro
- ./keycloak.keytab:/opt/keycloak/conf/keycloak.keytab:ro
Recreate the containers with the same commands as before, and log kcadm.sh in again, because recreating wipes that login. Look up the provider ID if this is a new shell:
LDAP_ID=$($KCADM get components -r "${REALM}" -q name=freeipa --fields id --format csv --noquotes)
Then set the Kerberos variables. KC_DOMAIN is the name browsers use for Keycloak, the same one as in the service principal:
export KC_DOMAIN="keycloak.example.com"
export IPA_REALM="EXAMPLE.COM"
Enable Kerberos on the provider. Keycloak turns the Kerberos step in the browser flow to ALTERNATIVE on its own:
$KCADM update "components/${LDAP_ID}" -r "${REALM}" \
-s 'config.allowKerberosAuthentication=["true"]' \
-s "config.kerberosRealm=[\"${IPA_REALM}\"]" \
-s "config.serverPrincipal=[\"HTTP/${KC_DOMAIN}@${IPA_REALM}\"]" \
-s 'config.keyTab=["/opt/keycloak/conf/keycloak.keytab"]' \
-s 'config.useKerberosForPasswordAuthentication=["false"]'
$KCADM get authentication/flows/browser/executions -r "${REALM}" --fields displayName,requirement --format csv --noquotes | head -3
The browser flow now tries a ticket before the login form:
Cookie,ALTERNATIVE
Kerberos,ALTERNATIVE
Identity Provider Redirector,ALTERNATIVE

Test it from any FreeIPA-enrolled machine with curl before touching a browser. With the same variables exported there, a valid ticket should come back as a redirect with an authorization code:
kinit alice
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" --negotiate -u : \
"https://${KC_DOMAIN}/realms/${REALM}/protocol/openid-connect/auth?client_id=account-console&response_type=code&scope=openid&redirect_uri=https%3A%2F%2F${KC_DOMAIN}%2Frealms%2F${REALM}%2Faccount%2F&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256"
Keycloak answered with a code and no password prompt:
Password for [email protected]:
302 https://keycloak.example.com/realms/example/account/?session_state=<id>&iss=https%3A%2F%2Fkeycloak.example.com%2Frealms%2Fexample&code=<code>
Without a ticket, the same request returns 401 with WWW-Authenticate: Negotiate, and a browser falls back to the login form. Browsers send tickets only to sites they trust for Negotiate. In Firefox, set network.negotiate-auth.trusted-uris to .example.com in about:config. Chrome and Edge read the AuthServerAllowlist policy.
Error messages from the lab and what fixes them
Each of these came up while building and breaking this setup. They appear in docker compose logs keycloak1 or in FreeIPA output, not in the browser, which only ever shows “Invalid username or password”.
No subject alternative names matching IP address 10.0.1.11 found
The connection URL used an IP address. The LDAPS certificate names the server by host name, and Keycloak checks it strictly. Use the FreeIPA host names in IPA_SERVERS, and add extra_hosts to the Compose file if the containers cannot resolve them.
LDAP error code 53: “Account inactivated. Contact system administrator.”
The full log line ends in [LDAP: error code 53 - Account inactivated. Contact system administrator.]. The user is disabled in FreeIPA, and the provider has no nsAccountLock filter, so Keycloak still found the user and tried to bind. Add the filter from the provider block. The login failed either way, but only the filter also ends the user’s existing sessions.
LDAP error code 53: “Too many failed logins.”
The log shows [LDAP: error code 53 - Too many failed logins.]. FreeIPA locked the account after the failure count in its password policy. ipa user-status <user> shows the failure count on each server, ipa user-unlock <user> clears the lock, and a Keycloak failureFactor below the FreeIPA maximum failures keeps it from happening again.
kinit: Client’s credentials have been revoked while getting initial credentials
The same FreeIPA lockout, seen from the Kerberos side. Failed logins through Keycloak locked the account for every FreeIPA service, not only for Keycloak.
LDAP response read timed out, timeout used: 10000 ms
A pooled connection was still pointing at a FreeIPA server that went away. Keycloak waited out the read timeout and the request ended in StorageUnavailableException: LDAP server is unavailable for provider [freeipa], an HTTP 500. Turn connection pooling off and lower the timeouts as in the provider block.
LDAP error code 50: “Insufficient ‘write’ privilege to the ‘userPassword’ attribute”
The full message is [LDAP: error code 50 - Insufficient 'write' privilege to the 'userPassword' attribute of entry 'uid=alice,cn=users,cn=accounts,dc=example,dc=com']. The provider is writable, but the system account has no permission to change passwords. Add the Keycloak LDAP federation role from the password section.
LDAP error code 19: “Too soon to change password”
Keycloak logs Could not update password for user alice. Reason: [LDAP: error code 19 - Too soon to change password], and kcadm.sh set-password prints only Could not modify attribute for DN. The user changed their own password within the minimum lifetime set in the password policy. The default is one hour. Wait it out, or have an admin reset the password with ipa passwd first, which is not subject to the minimum lifetime.
ipa: WARNING: Password reset permission is local to server ipa01.example.com.
This is a warning, not an error. The privileged flag set by ipa sysaccount-add or ipa sysaccount-policy applies only to the server that ran the command. Run ipa sysaccount-policy on every FreeIPA server and restart its directory service, or password writes that land on another server leave the password expired.