Three easy-to-miss boundaries in Keycloak extensions: selection, enrollment and packaging.
An authentication method can generate a code, send it, and validate it correctly—and still be missing when a user clicks “Try Another Way.”
That was the problem behind one of my contributions to Keycloak 2FA Email Authenticator. Email OTP could work when it was the only authentication step, but disappeared when other alternatives were available.
Keycloak is an identity and access management server. Its authenticator SPI (Service Provider Interface) lets extensions add login steps, while “Try Another Way” lets users choose another available authentication method.
The explanation was outside the code that generated or validated the OTP. It was in how Keycloak classified the authenticator and selected the methods available to a user.
My contributions to that project and its sibling, Keycloak 2FA Messaging Authenticator, have covered several parts of that larger system: authentication flows, enrollment, templates, packaging, documentation, and release automation.
Three problems stand out because each exposed a different boundary that was easy to overlook.
1. Working and being selectable are different things
The email extension supported an option called skipSetup. Its intention was straightforward: allow a user with an email address to use email OTP without first enrolling.
However, changing whether an authenticator considered a user configured did not make it appear under “Try Another Way.”
In Keycloak 26.7.3’s `AuthenticationSelectionResolver`, executions whose authenticators implement CredentialValidator are indexed by credential type. For an identified user, the resolver matches those executions against credential types from two sources: stored credentials and configured types reported by user storage. Non-credential executions are collected separately.
In this email-extension scenario, the user had neither a stored email OTP credential nor a matching type reported by user storage, so the method was excluded. configuredFor() is not consulted when building that list. Changing it through skipSetup therefore could not make email OTP selectable for an unenrolled user.
When email OTP was the only step, Keycloak could run it directly without constructing that alternatives list. With other alternatives present, the missing stored credential kept it out of the choices. The OTP implementation could be correct in both configurations; the path through the framework was different.
Two behaviors needed two authenticators
A configuration flag could not change the authenticator’s Java type. My fix in PR #160, merged and included in email extension v26.7.0, therefore separated the two behaviors:
| Authenticator | Intended behavior |
|---|---|
EmailAuthenticatorForm | Email OTP for users who have enrolled. |
NoEnrollmentEmailAuthenticatorForm | Email OTP without prior enrollment, for users with an email address. |
Both use the shared OTP logic in AbstractEmailAuthenticatorForm. The new authenticator does not implement CredentialValidator, so it does not enter the same credential-based selection path.
This separation made the distinction explicit without duplicating code generation, delivery, and validation.
A user without an email address exposes two distinct cases. When configured as an alternative, the no-enrollment authenticator can appear in the list, but selecting it produces a credential setup required error. When configured as a required step, it blocks that user rather than silently letting the login proceed without the OTP requirement.
Existing configurations were part of the fix
Removing skipSetup also meant dealing with realms already using it.
The migration switches the relevant email OTP executions to the new authenticator while preserving their configuration. Because the old setting applied across a realm, the migration had to consider all affected executions, including ones that did not carry the flag themselves.
Conditional email OTP executions could not be converted in the same way, so the migration logs a warning for them. It also removes the obsolete setting so subsequent startups do not repeat the migration.
The tests covered that migration, but the real Keycloak upgrade scenario mattered too: reproduce the missing alternative, upgrade against the same database, confirm the option appears, complete the login, and restart again to check the migration does not repeat.
The lesson I take from this is to trace how the framework reaches an extension. Reading the extension’s methods alone can miss the decision that prevents those methods from being called at all.
2. Enrollment has a second run
In the messaging extension, another problem appeared when a user repeated setup.
The existing implementation created a new messaging credential each time. That produced two different outcomes:
- Reusing the same number could trigger a duplicate-label exception and an internal error page.
- Using a different number could leave the old credential behind, displaying a contact that no longer matched where codes were sent.
The first enrollment could succeed while the account’s later lifecycle was broken.
My proposed fix in PR #10 treats repeated setup as updating the existing messaging configuration. It maintains one messaging credential for this extension: update it when present, create it otherwise, and delete extra credentials of MessagingAuthenticatorCredentialModel.TYPE_ID before updating the label. That cleanup is limited to this extension’s credential type; it does not delete passwords, TOTP credentials, or other authenticator types.
The ordering matters. The contact attribute is saved after the credential is saved, so a failure at the credential step does not first change the delivery destination. A duplicate exception is still handled as a fallback rather than becoming a generic server error.
This is a decision specific to the extension’s current model. A system designed to support multiple independent messaging credentials would need different behavior.
Error paths are also rendering paths
A related issue involved the setup form’s channel-specific label. The template checked:
<#if isTelegram??>That expression checks whether the variable exists. It does not check whether its value is true.
Because the application supplied isTelegram for both Telegram and phone-based channels, the Telegram branch was selected even when the value was false.
The proposed change in PR #12 checks the boolean value, defaulting to false when the variable is missing (! supplies that default in FreeMarker):
<#if isTelegram!false>But fixing that expression was only part of the work. Error paths created fresh form providers without consistently supplying the channel attributes. A Telegram user could submit an invalid value and receive a form with the wrong label on the next render.
The proposed fix centralizes form preparation so the initial render and error renders receive the same channel information. It also uses telephone inputs for phone channels while keeping Telegram chat IDs as text with a numeric input hint.
These two messaging PRs are still open as of October 19, 2026. They are proposed changes, not functionality I can describe as already released.
Together, they illustrate a useful review habit: test setup twice, and test the screen after an error. The first successful submission is only one part of enrollment.
3. A passing build can hide a broken provider
The messaging extension also had a failure that initially pointed in the wrong direction:
java.util.ServiceConfigurationError:
...AwsSnsMessageSender
Unable to get public no-arg constructorThe constructor existed.
The released artifact was a plain JAR, and the AWS SDK classes it needed were missing at runtime. During reflective constructor lookup, resolving the provider’s referenced AWS types could throw NoClassDefFoundError. OpenJDK 21’s `ServiceLoader.getConstructor()` catches the underlying throwable and wraps it in ServiceConfigurationError with that constructor message. The message said where loading failed, not that the constructor was missing.
There was a wider consequence. The sender registry iterated through registered providers before returning one. A failure loading the AWS provider could therefore prevent other messaging providers from being selected too.
In PR #4, I addressed both the packaging problem and that failure propagation. The PR is merged and listed in the messaging extension’s GitHub release v26.0.2-KC26.6.1.
The build now packages the required AWS SDK dependencies into the provider JAR. Service descriptors under META-INF/services are merged, because discovery depends on registrations from both the extension and the SDK. Keycloak’s own classes remain outside the bundle.
The registry catches ServiceConfigurationError and LinkageError around each iteration’s hasNext() and next() calls, logs the failed provider load, and attempts to continue. Recovery after a ServiceConfigurationError is best-effort by design, but it covers the failures the tests reproduce. Delivery paths also handle relevant failures so users receive a delivery error rather than an uncaught server error.
The test classpath was hiding the problem
Maven’s test classpath already contained the AWS SDK. A test could instantiate the sender successfully even though the JAR users installed did not contain those dependencies.
That meant the regression needed to be tested after packaging.
Two integration tests addressed different parts of the problem:
| Test | What it verifies |
|---|---|
PackagingIT | The built JAR contains required classes and service descriptors, while excluding classes that should remain server-provided. |
ServiceLoaderIT | The packaged providers can actually be discovered and instantiated through an isolated classloader with the expected server-provided libraries. |
The second test also builds an SNS client without making a network request. That exercises dependency loading beyond simply checking that a class exists in the archive.
Removing the shading configuration caused the tests to fail with the missing-dependency behavior they were intended to detect.
That is the distinction I want from a regression test: it should fail when the original defect returns, even if the ordinary development environment supplies enough dependencies to hide it.
The same blind spot in CI
The email extension’s release pipeline had a similar gap: the environment that ran on every PR was not the one that mattered. Its default build passed, but publication failed for older supported Keycloak versions because test sources called a newer API signature.
My merged email PR #164, included in v26.7.2, corrected the assertions and added a CI build against the oldest published Keycloak version. My messaging PR #11 brought the same earlier check to the sibling project. It is merged, but was not included in a GitHub release when I checked on October 19, 2026.
Testing the oldest version does not replace validating every supported version. It moves an important compatibility failure from publication time into PR review, when it is easier to act on. Like the packaged-JAR tests, it challenges an assumption that the default development environment would otherwise leave untested.
What I now look for in an authentication extension
These contributions changed the questions I ask when reviewing this kind of integration:
- Selection: before looking at code generation and validation, how does the method become available to a user at all?
- Enrollment: before considering setup complete, what happens when the user repeats it, changes their contact, or submits invalid input?
- Packaging: before trusting a successful build, what is inside the artifact that will actually be installed, and against which versions was it built?
Each question follows a boundary crossed by the real login experience: framework selection, account state, form rendering, provider loading, and deployment.
The missing “Try Another Way” option is still the clearest example for me. The email code could be generated and checked correctly. The user simply could not choose the method that would run that code.
Understanding that gap made the fix possible—and gave me a better way to investigate the next one.
This article is part of Engineering in Practice, where I share implementation decisions and lessons from projects I work on. Thanks to Mesut Pişkin and the contributors to Keycloak 2FA Email Authenticator and Keycloak 2FA Messaging Authenticator for maintaining these projects and reviewing contributions. PR and GitHub release status was checked on October 19, 2026.
AI-assisted. The fixes, debugging and lessons are from my own contributions. I used AI to help draft and edit the text and to cross-check the linked sources, then checked the final version against my pull requests.
