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
| Field | Value |
|---|---|
| Keycloak | 26.7.1 (official image) |
| Directory | bitnamilegacy/openldap:2.6, LDAP_ENABLE_TLS=yes — TLS on 1636 (LDAPS) and StartTLS on 1389 |
| Federation config | seed-realm.sh --ldap --ldap-starttls → connectionUrl=ldap://ldap:1389, startTls=true, editMode=READ_ONLY |
| Certificates | private 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 |
| Trust | KC_TRUSTSTORE_PATHS=/opt/keycloak/conf/truststores (bind-mounted dir); trusted = ca.crt present |
| Database | PostgreSQL 16, single node, container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM |
| JVM heap | -Xms1g -Xmx4g |
| Lab change | seed-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.
| Leg | Trust | Cert | Sync API response | HTTP | Log cause |
|---|---|---|---|---|---|
| StartTLS trusted | CA trusted | SAN matches ldap | {"added":3, ...} 3 imported | 200 | — |
| StartTLS untrusted | empty truststore | SAN matches | {"added":0, ...} 0 imported | 200 | SunCertPathBuilderException: unable to find valid certification path |
| StartTLS hostname mismatch | CA trusted | SAN DNS:wronghost | {"added":0, ...} 0 imported | 200 | SSLPeerUnverifiedException: hostname of the server 'ldap' does not match the hostname in the server's certificate |
| StartTLS expired cert | CA trusted | notAfter = issue time | {"added":0, ...} 0 imported | 200 | CertificateExpiredException: 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
| Claim | Primary source | Checked |
|---|---|---|
| StartTLS on 1389 works when trusted | sync 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 silent | HTTP 200, added:0, SunCertPathBuilderException in log | ☑ |
| Hostname mismatch is a hard failure | HTTP 200, added:0, SSLPeerUnverifiedException in log | ☑ |
| Expired cert is silent | HTTP 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 StartTLS | not tested — still READ_ONLY | ☐ |