- LookupConfig: ssl-client-cert / ssl-cert-chain-prefix / certificate-chain-length now fall back to the built-in nginx provider's settings (Config scope, then KC_SPI_X509CERT_LOOKUP_NGINX_* env vars), so switching the provider to "hybrid" needs no duplicated Helm values. Explicit hybrid-scope settings still override. - Add LookupConfigTest covering inheritance, override, and defaults (14 tests total, all passing). - Add RUNBOOK.md: phase-by-phase deployment procedure sized to 30-min test windows, with verification checklists, forged-header negative test, rollback per phase, and failure triage table. - README/Dockerfile.example updated to match; stop tracking target/ build output (.gitignore added). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keycloak Hybrid X509 Certificate Lookup SPI
Custom x509cert-lookup provider that lets a single Keycloak instance authenticate
x509/CAC users arriving via two paths at once:
- F5 path (header): F5 BigIP does L7 break-and-inspect, validates the user's cert,
re-encrypts to Keycloak (through the L4-passthrough NLB), and forwards the user cert
in an HTTP header — like the built-in
nginxprovider handles today. - Direct path (TLS): TGW/brokerage traffic that bypasses the F5 performs mTLS
directly with Keycloak (which terminates TLS itself behind the passthrough NLB with
https-client-auth=request), and the cert is read straight off the handshake.
The critical addition over stock Keycloak: the header is only honored when the request provably came from the F5 (by the F5's own TLS client cert fingerprint/DN, or source IP). Without this, any client on the TGW could forge the header and impersonate any user.
Per-request decision logic:
| Cert header | Peer is trusted proxy | trust-mode |
Result |
|---|---|---|---|
| present | yes | any | header cert used |
| present | no | log |
header cert used + loud warning |
| present | no | enforce |
header ignored; TLS peer cert used if present |
| absent | yes | any | no user cert (the F5's own cert is never a user) |
| absent | no | any | TLS peer cert used if present |
No changes are needed to your realm's X509 browser authentication flow — the authenticator receives the cert the same way regardless of source.
Deploying this? Follow the step-by-step operator guide in RUNBOOK.md. This README is the reference (config options, security rationale, troubleshooting).
1. Build
mvn package # or: podman run --rm -v $PWD:/src -w /src maven:3.9-eclipse-temurin-17 mvn package
Produces target/keycloak-hybrid-x509-spi-1.0.0.jar (a few KB — contains only these
classes; all Keycloak APIs are provided). Set <keycloak.version> in pom.xml to match
your deployed version (any 24.x–26.x works; the APIs used are stable across that range).
2. Deploy (custom image)
Add to your existing Keycloak Dockerfile:
FROM quay.io/keycloak/keycloak:26.0 AS builder # match your current base/tag
COPY keycloak-hybrid-x509-spi-1.0.0.jar /opt/keycloak/providers/
# https-client-auth is a BUILD-time option in Quarkus Keycloak — it must be present
# when kc.sh build runs, not just at start.
ENV KC_HTTPS_CLIENT_AUTH=request
# ...plus whatever build options your image already sets (KC_DB, KC_FEATURES, ...)
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.0
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
CMD ["start", "--optimized"]
If your image doesn't use --optimized, dropping the jar into /opt/keycloak/providers/
and setting KC_HTTPS_CLIENT_AUTH=request at runtime is enough — Keycloak re-augments on
start. request means the client cert is OPTIONAL: connections without one (OIDC app
backchannels, admin console, the F5 before it's given a cert) proceed exactly as before.
Do NOT use required.
3. Runtime configuration (env vars on the Keycloak pod)
# Switch the lookup provider from nginx to hybrid
KC_SPI_X509CERT_LOOKUP_PROVIDER=hybrid
# Header name: NOTHING TO ADD. The hybrid provider inherits ssl-client-cert,
# ssl-cert-chain-prefix and certificate-chain-length from your existing
# KC_SPI_X509CERT_LOOKUP_NGINX_* settings in the Helm values — leave them in place.
# (Set KC_SPI_X509CERT_LOOKUP_HYBRID_SSL_CLIENT_CERT only to override; the startup
# log line "inherited from the nginx provider config" confirms what was picked up.)
# Day-one: permissive mode (headers accepted from anyone, like today, but everything logged)
KC_SPI_X509CERT_LOOKUP_HYBRID_TRUST_MODE=log
# After harvesting values from the logs (see rollout plan), add trust rules:
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_CERT_SHA256=<sha256hex>[,<sha256hex-next-rotation>]
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_CIDRS=10.x.y.0/28
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUSTED_PROXY_SUBJECT_DN=CN=f5.internal.example,OU=Infra,O=YourOrg
# ...then flip:
#KC_SPI_X509CERT_LOOKUP_HYBRID_TRUST_MODE=enforce
Keycloak 26 prefers a double-dash format (
KC_SPI_X509CERT_LOOKUP__HYBRID__TRUST_MODE); the single-dash form above works on 24–26 (26 logs a deprecation warning). Use one style consistently.
All options (prefix spi-x509cert-lookup-hybrid-):
| Option | Default | Meaning |
|---|---|---|
ssl-client-cert |
inherits nginx setting, else ssl-client-cert |
Header with the user cert (PEM, URL-encoded PEM, or base64 DER — auto-detected) |
ssl-cert-chain-prefix |
inherits nginx setting, else ssl-cert-chain |
Optional chain headers <prefix>-0..n |
certificate-chain-length |
inherits nginx setting, else 1 |
Max chain headers to read |
trust-mode |
log |
log = honor all headers, warn on untrusted; enforce = ignore headers from untrusted peers |
trusted-proxy-cert-sha256 |
— | Comma-separated SHA-256 fingerprints of the F5's client cert(s). Strongest check. List old+new during rotations. |
trusted-proxy-subject-dn |
— | |-separated exact subject DNs (RFC2253). Safe because TLS already validated the chain. Survives rotation. |
trusted-proxy-cidrs |
— | Comma-separated CIDRs/IPs for the F5's egress addresses. Weakest; requires NLB client-IP preservation. |
header-cert-enabled |
true |
Master switch for the header path |
direct-cert-enabled |
true |
Master switch for the direct-TLS path |
rebuild-chain-from-truststore |
true |
Rebuild issuer chain for header certs from the Keycloak truststore (matches nginx-provider behavior) |
truststore-chain-depth |
4 |
Max issuers appended during rebuild |
verbose |
true |
Per-request INFO logging of the decision (peer fingerprint, source IP, path taken). Set false after rollout. |
Truststore: your existing truststore (already working for header-cert validation) must
also be reachable by the TLS layer so Keycloak can request/validate browser certs on the
direct path. On KC 25+ point KC_TRUSTSTORE_PATHS at your CA bundle; on KC 24 use the
spi-truststore-file-* options you likely already have. The CAs listed there are also what
browsers use to filter the cert-picker dialog.
4. Rollout / test plan (one 30-min cycle each, in order)
Cycle 0 — no deploy, info gathering. From the current deployment grab: truststore
config, base image tag, and whether start --optimized is used. (The header name is
inherited automatically from the existing KC_SPI_X509CERT_LOOKUP_NGINX_* values — no
need to look it up.) Ask the F5 team to start on the one-pager (section 6) in parallel.
Cycle 1 — deploy in log mode (zero behavior change expected).
Jar + KC_HTTPS_CLIENT_AUTH=request + provider=hybrid + trust-mode=log; keep all
existing KC_SPI_X509CERT_LOOKUP_NGINX_* values in the Helm values file untouched.
Verify: existing F5 CAC login still works. Then grep logs for x509-hybrid:
- startup lines show the parsed config, including which nginx settings were inherited;
- each F5 login logs
remoteAddr=(→ your CIDR value) andpeer=(→no-tls-client-certuntil the F5 presents one); header cert decoded using strategy '...'(DEBUG) confirms the F5's encoding. If a TGW-side test client exists already, hit Keycloak directly: expect a browser cert prompt andusing DIRECT TLS peer certin the logs.
Cycle 2 — F5 presents its client cert. After the F5 change, each F5 login logs
peer=subject=[...] sha256=<fingerprint> — that fingerprint is your
trusted-proxy-cert-sha256 value. Confirm logins still work (request mode tolerates the
new cert automatically, provided the F5 cert's CA is in the truststore).
Cycle 3 — enforce. Set the trust rules + trust-mode=enforce. Verify: F5 login works,
direct TGW login works, and the negative test — from a TGW-side box,
curl -k https://keycloak.../realms/<realm>/account -H "ssl-client-cert: <any user PEM>" —
produces IGNORED cert header from untrusted peer in the logs and no authentication.
Cycle 4 — quiet down. verbose=false, optionally keep DEBUG off. Done.
Rollback at any cycle: set KC_SPI_X509CERT_LOOKUP_PROVIDER=nginx and restart — the
built-in provider and all its config are untouched by this deployment.
5. Security notes
- Never leave
trust-mode=loglong-term once TGW routes are open: it preserves the legacy trust-any-header behavior. The provider logs a warning at startup to this effect. - Fingerprint pinning breaks when the F5 rotates its client cert — list the next cert's
fingerprint alongside the current one before rotation, or rely on
subject-dn(which survives rotation and is chain-validated by the TLS layer inrequestmode). - Source-IP trust requires the NLB target group to have client IP preservation enabled
(targets registered by instance ID have it on by default; by IP it's configurable). The
remoteAddr=log line from Cycle 1 tells you definitively what Keycloak sees. - The F5 pre-screens users against an allowlist and (presumably) checks revocation; the direct path has no such screen. Enable OCSP/CRL revocation checking in the realm's X509 authenticator config (Authentication → your browser x509 flow → config) if it isn't already on, and confirm the cert-to-user mapping attribute is strict enough that only provisioned brokerage users resolve.
https-client-auth=requestsends a TLS CertificateRequest on every connection. Browsers without a matching cert and server-to-server OIDC clients simply continue certless; users hitting Keycloak directly get the platform cert-picker (expected CAC behavior).
6. One-pager for the F5 team
Keycloak's listener will request (not require) a TLS client certificate. We need the BigIP virtual server that fronts Keycloak to authenticate itself on its server-side (re-encrypt) SSL profile:
- Issue a client certificate for the BigIP from an internal CA (or reuse an existing device cert). Any subject is fine, e.g.
CN=bigip-keycloak-proxy,OU=Infra,O=<org>.- On the Server SSL profile used for the Keycloak pool: set this certificate/key so the BigIP presents it during the TLS handshake with the backend.
- Send us: the certificate's SHA-256 fingerprint (
openssl x509 -in cert.pem -noout -fingerprint -sha256), its exact subject DN, the issuing CA chain (PEM), and the self-IP/SNAT addresses the BigIP uses toward the Keycloak NLB.- No iRule/header changes needed — keep injecting the client cert header exactly as today. Timing note: this can be deployed before or after our Keycloak change; the Keycloak side is backward compatible either way (
https-client-auth=requestis optional-cert).
7. Troubleshooting via logs (category com.example.keycloak.x509)
| Log line | Meaning |
|---|---|
initialized. trust-mode=... |
Config as parsed at startup — check this first |
using HEADER cert from trusted proxy |
F5 path, healthy (enforced) |
honoring cert header from UNTRUSTED peer |
Log-mode: would fail in enforce — fix trust rules before flipping |
IGNORED cert header from untrusted peer |
Enforce-mode rejection: forgery attempt, or your F5 trust rule is wrong/stale |
using DIRECT TLS peer cert |
TGW path, healthy |
trusted proxy connection without usable cert header |
F5 request with no/blank header (e.g. health checks) — normal |
failed to parse cert header |
Unexpected header encoding — the logged prefix shows what arrived |
no certificate found |
Certless request (normal for non-CAC flows) |
Set KC_LOG_LEVEL=INFO,com.example.keycloak.x509:debug during rollout for
decode-strategy and chain-rebuild details.