The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-26 · run 2026-08-26-starttls-1389

StartTLS on 1389: works when trusted, and fails *silently* (unlike LDAPS)

Database
PostgreSQL 16, single node, container
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM
Condition
Real LDAP federation — / Run D untested rows: *StartTLS on 1389*, hostname-verification failures, expired certificates

Why this run existed

Run D (2026-08-26-runD-ldaps-starttls) covered implicit LDAPS on 1636 and left three rows unchecked: StartTLS on 1389, hostname-verification failures, and expired certificates. This run closes all three, and the first one changes what Run D concluded about the failure signature.

Environment

FieldValue
Keycloak26.7.1 (official image)
Directorybitnamilegacy/openldap:2.6, LDAP_ENABLE_TLS=yes — TLS on 1636 (LDAPS) and StartTLS on 1389
Federation configseed-realm.sh --ldap --ldap-starttlsconnectionUrl=ldap://ldap:1389, startTls=true, editMode=READ_ONLY
Certificatesprivate CA (lab-ldap-ca); server cert CN=ldap, SAN DNS:ldap, DNS:localhost, IP:127.0.0.1 — plus ad-hoc variants for the failure legs
TrustKC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores (bind-mounted dir); trusted = ca.crt present
DatabasePostgreSQL 16, single node, container
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM
JVM heap-Xms1g -Xmx4g
Lab changeseed-realm.sh gained --ldap-starttls (sets startTls:["true"] on the LDAP component)

Result — four legs, one sync each

Full sync via POST /admin/realms/lab/user-storage/{id}/sync?action=triggerFullSync.

LegTrustCertSync API responseHTTPLog cause
StartTLS trustedCA trustedSAN matches ldap{"added":3, ...} 3 imported200
StartTLS untrustedempty truststoreSAN matches{"added":0, ...} 0 imported200SunCertPathBuilderException: unable to find valid certification path
StartTLS hostname mismatchCA trustedSAN DNS:wronghost{"added":0, ...} 0 imported200SSLPeerUnverifiedException: hostname of the server 'ldap' does not match the hostname in the server's certificate
StartTLS expired certCA trustednotAfter = issue time{"added":0, ...} 0 imported200CertificateExpiredException: NotAfter: … + CertPathValidatorException: validity check failed

Findings

1. StartTLS failure is silent; LDAPS failure is loud

Run D established that an untrusted certificate on LDAPS (1636) fails with {"errorMessage":"SocketReset"} and HTTP 400 — loud, and the word "certificate" never appears. On StartTLS the equivalent failures all return HTTP 200 with a body that looks like a successful, empty sync:

{"ignored":false,"added":0,"updated":0,"removed":0,"failed":0,
 "status":"0 imported users, 0 updated users"}

The real cause is only in the Keycloak log, as a full stack trace. An operator (or monitoring integration) that checks the HTTP code — or only greps for SocketReset/errorMessage — sees a successful sync that imported nothing, and reaches for "is the directory empty? is the DN wrong?" instead of "is the certificate trusted?". The same misdirection Run D warned about for LDAPS is worse on StartTLS, because there is no error at all at the API level.

The one correct signal in the response is the count: added:0 where you expect users. The lesson for the runbook is check the imported count, not the status code — on StartTLS, "0 imported" is a failure signal, not a success.

2. Keycloak enforces hostname verification on LDAP TLS

A trusted CA with a cert whose SAN is DNS:wronghost fails with SSLPeerUnverifiedException / No subject alternative DNS name matching ldap found. Keycloak does not default to "trust everything, skip hostname" on LDAP connections — the HostnameChecker runs, and a SAN that does not match the connection hostname is a hard failure (silent at the API, loud in the log). This means a directory cert issued for the wrong name — or a wildcard that does not cover the host — breaks federation exactly like an untrusted CA.

3. StartTLS itself works and survives, matching LDAPS

With the CA trusted and the SAN matching, StartTLS on 1389 imports all three users — no functional difference from implicit LDAPS. The mode is not the problem; the failure signature is.

Verification

ClaimPrimary sourceChecked
StartTLS on 1389 works when trustedsync returned added:3, HTTP 200
startTls=true is actually negotiated (not plaintext fallback)untrusted leg failed at the TLS handshake (StartTlsResponseImpl.negotiate → PKIX)
Untrusted CA under StartTLS is silentHTTP 200, added:0, SunCertPathBuilderException in log
Hostname mismatch is a hard failureHTTP 200, added:0, SSLPeerUnverifiedException in log
Expired cert is silentHTTP 200, added:0, CertificateExpiredException in log
Contrast with LDAPS (loud)Run D D1: SocketReset, HTTP 400
Revoked certificates (CRL/OCSP)not tested — the lab CA has no revocation list
editMode=WRITABLE / UNSYNCED over StartTLSnot tested — still READ_ONLY

← Back to the Ledger