The exposure
Our Keycloak rollout stalled on two problems that were, underneath, the same kind of problem: both were about where trust and evidence actually live. We had 200,000 users in a legacy PostgreSQL table with a bespoke password-hashing scheme, and compliance required that every authentication event — successful logins, failed attempts, password changes — reach our SIEM in near real time.
The naive fix for the first problem is a big-bang migration: export the users, re-hash or force-reset every password, cut over in a maintenance window. That migration is itself the exposure. Bulk-handling 200k credential records is a concentrated moment of risk — a dump of password material in flight, a mapping bug that silently drops or duplicates accounts, and a forced-reset flow that is a textbook phishing pretext ("we've upgraded our login, please reset your password here"). The second problem is a detection gap: Keycloak's default event storage kept auth events, but not where our monitoring could see them within seconds, which means the window between a credential-stuffing burst and anyone noticing was measured in whatever our slowest batch job took.
So the real question was not "how do we migrate?" but "how do we adopt Keycloak without ever creating a bulk-credential event, and without accepting a blind spot in login telemetry?" Keycloak, the open-source identity and access management server, answers that through its Service Provider Interfaces (SPI) — the extension seam that lets you change where users are authenticated and where events are sent without forking the server.
Threat model
Being precise about what we were defending against is what kept the extension small and the review tractable.
- Bulk credential exposure during migration. Any process that reads, transforms, and re-writes 200k password hashes in one operation is a high-value target and a single point of failure. The control objective is to never have that operation exist at all.
- Account-lockout and phishing risk from forced resets. A mass password-reset email trains users to click reset links, which is exactly the behavior an attacker wants to exploit. It also risks locking out legitimate customers on a mapping error, which is an availability incident.
- Detection gap on authentication. If failed logins and password changes are not streamed to the SIEM promptly, credential-stuffing, brute force, and account-takeover attempts go unobserved until after the fact. Delayed telemetry is reduced detection capability, which is a security control, not an operational nicety.
- Extension-introduced attack surface. Writing custom Java that runs inside the identity server is itself a risk: a provider on the request path can add latency, leak connections, or — worst case — mishandle a credential. The extension must be scoped so its blast radius is understood.
The governing decision: replace a one-time, high-magnitude credential-handling event with a gradual, per-login re-hash where the legacy store stays authoritative until each user proves themselves, and close the detection gap by streaming events at the source. Both are SPI providers, and both are small.
Controls we added
Keycloak ships LDAP and Active Directory federation, but our legacy store was neither, and its default event handling did not stream where we needed. That is precisely where you stop configuring Keycloak and start extending it — deliberately, and with the smallest surface that satisfies the requirement. I cross-referenced the official developer guide against a thorough third-party walkthrough of building SPI providers →, which works each interface end to end.
There are four SPI provider types — authentication, user storage, event listener, and policy. We needed two, and deliberately no more than two.
Control 1 — federate the legacy users so no bulk migration ever happens
We implemented org.keycloak.storage.UserStorageProvider, the same User Storage SPI that Keycloak's own built-in LDAP and AD federation is written against. The two methods that carry the weight are getUserById(String id, RealmModel realm) and getUserByUsername(String username, RealmModel realm); Keycloak calls them to resolve a user, and our implementation queried the legacy table and mapped rows to Keycloak user models on demand.
The security-relevant property is that users authenticate against the old store without ever being migrated in bulk. We validated each login against the legacy hash and, only on a successful authentication, transparently re-hashed that one credential into Keycloak's format. The re-hash happens exactly when the user has just proven they hold the password — there is no batch, no dump, no reset email, and the legacy store remains authoritative for anyone who has not yet logged in. That converts one large, concentrated credential-handling risk into a stream of individually trivial ones. Baeldung's custom user providers walkthrough is a solid worked reference for the interface.
Control 2 — stream auth events to the SIEM at the source
For the detection gap we implemented org.keycloak.events.EventListenerProvider, registered through Keycloak's Events SPI. The method that matters is onEvent(Event event), invoked on every login, failed attempt, password change, and account update. Our implementation serialized each event and pushed it to the SIEM ingest endpoint. The monitoring team got the authentication telemetry they needed to detect credential-stuffing and takeover attempts promptly, in a few hundred lines rather than a platform change.
Control 3 — build and package the extension so it cannot silently misload
Both providers are Java built with Maven. The single most important build decision is scope: both Keycloak artifacts (keycloak-services, keycloak-core) use provided scope, because Keycloak supplies them at runtime. You compile against them and do not bundle them.
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>YOUR_KEYCLOAK_VERSION</version>
<scope>provided</scope>
</dependency>
Every provider builds on org.keycloak.provider.Provider (whose lifecycle includes close() for releasing resources) plus a ProviderFactory that reads configuration via init(Config.Scope config). That factory-and-config pattern is a control in its own right: it let the same JAR point at the test database in CI and the production database in prod, so the only difference between environments was reviewed configuration, not code.
Verifying the control, not just shipping it
An extension that runs inside the identity server is not trustworthy until it is demonstrated to fail safely. Our loop: mvn clean install, drop the JAR into Keycloak's providers/ directory, run a build, and confirm the factory appears in the startup log — if it is not listed, the META-INF/services registration is wrong and Keycloak silently ignores the JAR, which is a fail-silent condition worth catching in CI rather than in production.
For the user-storage provider we wrote an integration test that spun Keycloak up in a container against a seeded legacy database, then asserted that a known legacy user could authenticate and that their credential was transparently re-hashed on first success. A mapping bug caught here is a failing test; the same bug found in production is a customer who cannot log in. We never pointed a new provider at the production realm until it had authenticated a test user and emitted a test event end to end against a throwaway realm.
Residual risk / what we're still watching
Extending Keycloak solved the migration and detection problems, but it moved risk rather than eliminating it, and naming where it went is the point.
- The event listener runs in the request path. A synchronous, slow SIEM push adds latency to every login and, in the worst case, could be a denial-of-service lever against authentication itself. We made the emit asynchronous behind a bounded queue so a slow or unreachable SIEM never blocks auth — but a bounded queue also means events can be dropped under sustained backpressure, so we monitor queue saturation as a signal that we are losing telemetry.
- Version lock-step is now a standing obligation. The internal SPI is not guaranteed stable across Keycloak majors, so the provider must be rebuilt and re-tested against the exact version it runs on. A forgotten rebuild after an upgrade is a provider that compiles against the wrong internals and fails in subtle ways — we pin the version and rebuild on every upgrade.
-
Connection hygiene under load. A storage provider that does not release database connections in
close()leaks them, which is an availability failure mode. We assert cleanup in the lifecycle method and watch connection-pool metrics. - The legacy store stays authoritative longer than we would like. Gradual re-hash is safer than a big bang, but it means the old database — with its bespoke hashing — remains in the trust path until the long tail of inactive users either logs in or is aged out. We are tracking the migration curve and will set a hard cutoff date after which un-migrated accounts are disabled rather than left as indefinite legacy attack surface.
- Any future authentication or policy provider is new surface. We are evaluating a policy provider for location- and device-aware access on sensitive resources, and a step-up MFA authentication provider. Each runs inside the identity server, so each gets the same throwaway-realm-first, integration-tested, version-pinned treatment — no rubber-stamping code that sits on the credential path.
The net effect is that we adopted Keycloak without ever creating a bulk-credential event and without accepting an authentication blind spot, in roughly 300 lines of Java. The lesson we took from it is narrow and deliberate: the SPI seam is powerful precisely because it lets you avoid the riskier alternative, but every line you run inside the identity server is code on your most sensitive path, and it earns the same scrutiny as the server itself.
Sources & further reading
-
Keycloak Server Developer Guide — Service Provider Interfaces (SPI) — the provider/factory model,
META-INF/servicesregistration, and packaging. - Keycloak Server Developer Guide — User Storage SPI — the interface behind federating an external user store without migration.
- Keycloak Server Developer Guide — Events — the Event Listener SPI used to stream auth events.
- Baeldung — Using Custom User Providers with Keycloak — a hands-on Java worked example of a UserStorageProvider.
- A hands-on custom-provider writeup — a useful third-party field-notes version of the Maven setup and both providers to keep open while building.
Top comments (0)