Shipping SMS OTP: Keycloak vs. ClavionX
A hands-on report from adding SMS as a second factor to a live identity provider: 17 Java classes and two silent bugs on Keycloak vs. a single toggle on ClavionX.
A hands-on implementation report from adding SMS as a second authentication factor to a live identity provider—including two undocumented platform contracts that only surfaced during debugging.
Reading time: 8 minutes
The honest starting point
Keycloak’s Authenticator SPI is genuinely one of its strongest architectural decisions.
It allows platform teams to add completely new authentication methods without modifying Keycloak itself. A custom authenticator can define its own credential type, enrollment flow, login challenge, persistence model and delivery mechanism—all packaged as an external provider.
That is an impressive extension model, and it’s the reason implementing SMS OTP was possible without maintaining a fork of Keycloak.
This article isn’t a criticism of that architecture.
It’s a report of what implementing a production-ready SMS second factor actually involved, and why the same capability became a built-in platform feature in ClavionX instead.

The requirement
The objective sounded straightforward.
Allow users to authenticate using one of three second-factor methods:
- Authenticator App (TOTP)
- Email OTP
- SMS OTP
For SMS delivery, support multiple providers:
- Twilio
- AWS SNS
Operators should be able to choose the gateway without changing authentication flows.
The easy part
Keycloak already ships with Authenticator App (TOTP).
Enable:
otpPolicyType=totpand TOTP is available immediately.
No Java.
No SPI.
No deployment.
The moment the requirement changes to “Send the OTP by SMS”, however, the implementation becomes a custom provider project.
Building SMS OTP on Keycloak
Below is the exact implementation performed for this project.
1. Build shared OTP infrastructure
Before writing the authenticator itself:
- OTP generation
- Expiration
- Attempt limiting
- Resend cooldown
- Verification
had to be implemented as reusable platform code.
2. Build a delivery SPI
Create a completely new provider SPI:
- SmsSenderProvider
- Factory
- SPI registration
Then implement multiple gateway backends.
- Development logger
- Twilio REST integration
- AWS SNS
AWS SNS deliberately used a handwritten SigV4 implementation instead of introducing the AWS SDK solely for one REST endpoint.
3. Create a new credential type
Implement:
- Credential model
- Credential provider
- Provider factory
to persist enrolled phone numbers.
4. Create the login authenticator
Implement:
- SmsOtpAuthenticator
- Factory
- CredentialValidator
so Keycloak’s own authentication flow recognizes SMS OTP as a configured credential.
5. Create enrollment
A Required Action performs:
- Collect phone number
- Send OTP
- Verify OTP
- Persist credential
6. Build the UI
Author:
- FreeMarker templates
- Message bundles
- Login pages
for enrollment and authentication.
7. Register everything
Register five separate ServiceLoader files under:
META-INF/servicesThen wire the authenticator into the Browser Flow using kcadm.sh, ensuring execution order remained correct.
8. Deploy
Every iteration required:
- Maven build
- Package JAR
- Mount provider
- Restart container
Theme changes additionally required forcing container recreation because cached providers and themes weren’t always invalidated.
Implementation Summary17 Java classes32 files modified1,182 lines changedMultiple SPIsTwo undocumented platform contracts discovered during debugging
The two silent bugs
Interestingly, neither problem was caused by SMS OTP logic.
Both were platform contracts.
Bug #1
The credential successfully enrolled.
Authentication worked.
But the credential never appeared inside the Account Console.
No exception.
No warning.
No log.
The cause:
AuthenticatorFactory.getReferenceCategory()must exactly match the credential type string.
One mismatched value causes the Account Console REST layer to silently filter the credential.
Finding it required manually comparing REST responses against expectations.
Bug #2
The Account Console displayed Set up.
Clicking it produced a redirect with:
kc_action_status=errorNo explanation.
The underlying issue:
RequiredActionProvider.initiatedActionSupport()defaults to
NOT_SUPPORTEDUnless explicitly overridden, Account Console initiated actions fail.
The real cause only appeared after enabling DEBUG logging and tracing Keycloak source code.
One line fixed it.
Finding that line took considerably longer.

What this looks like in ClavionX
ClavionX deliberately approaches this differently.
Rather than asking customers to implement authentication channels, common enterprise authentication methods are treated as supported platform capabilities.
The operator workflow becomes:
Directory
↓
MFA Methods
↓
Enable SMS OTP
↓
Choose Gateway
↓
Enter Credentials
↓
Saveor equivalently,
POST /v1/tenants/{id}/mfa-methods
Everything else already exists:
- enrollment
- OTP generation
- resend handling
- rate limiting
- auditing
- challenge screens
- gateway abstraction
No Java implementation is required.

Side by side
| Dimension | Keycloak | ClavionX |
|---|---|---|
| TOTP | Built in | Built in |
| Email OTP | Custom provider | Enable |
| SMS OTP | Custom provider | Enable |
| Gateway selection | Java implementation | Configuration |
| Deployment | Build + JAR + Restart | Configuration only |
| Upgrade impact | Custom providers remain part of deployment | Platform capability |
Why ClavionX intentionally avoids SPIs
This experience reinforced one design decision behind ClavionX.
Keycloak is designed as an extensible platform.
That extensibility is valuable, and it enables sophisticated integrations that would otherwise be impossible.
The trade-off is that every custom provider becomes part of the deployed system.
Future upgrades must continue to support:
- provider compatibility
- authentication flow changes
- theme compatibility
- deployment packaging
- extension lifecycle
For organizations writing custom identity functionality, that trade-off is entirely reasonable.
For most organizations, however, the objective isn’t writing authentication software.
It’s deploying authentication software.
ClavionX therefore takes a different approach.
Capabilities that most enterprises eventually require—multiple MFA channels, delivery gateways, enrollment flows, auditing, rate limiting and operator tooling—ship as supported platform features rather than extension projects.
That decision has an operational consequence.
Upgrading ClavionX is designed to remain an operational task rather than a development project.
Existing customer configuration continues to work without rebuilding custom providers because the platform owns the implementation.
Every line of customer-owned extension code becomes part of the upgrade surface. ClavionX deliberately keeps that surface as close to zero as practical by shipping common identity capabilities as supported platform features rather than extension points.
The receipts
Implementation Environment
- Keycloak 26.7.0
- Java 21
- Custom Authenticator SPI
- Custom Credential Provider
- Required Action Provider
- Twilio REST API
- AWS SNS REST API (SigV4)
- Docker Compose
File counts and line counts are taken directly from the implementation repository.
Nothing has been estimated or rounded.
Final thoughts
This isn’t an argument that Keycloak’s extension model is flawed.
Quite the opposite.
It demonstrates how capable that model is.
It also illustrates where platform teams end up spending engineering time—not deciding whether SMS OTP is the right authentication method, but satisfying SPI contracts, packaging providers, debugging framework integration points and carrying those customizations forward into future upgrades.
ClavionX was intentionally designed around a different assumption.
If a capability is something most organizations eventually need, it should be configurable rather than programmable.