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.

ClavionX MFA Policy screen with SMS OTP enabled

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.


Keycloak's MFA method picker after adding custom Email OTP and SMS OTP authenticators.
Keycloak’s MFA method picker after adding custom Email OTP and SMS OTP authenticators.

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=totp

and 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:

  1. Collect phone number
  2. Send OTP
  3. Verify OTP
  4. 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/services

Then 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=error

No explanation.

The underlying issue:

RequiredActionProvider.initiatedActionSupport()

defaults to

NOT_SUPPORTED

Unless 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.


SMS OTP validation failure showing inline error handling after the authenticator was completed.
SMS OTP validation failure showing inline error handling after the authenticator was completed.

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
    ↓
Save

or equivalently,

POST /v1/tenants/{id}/mfa-methods
ClavionX MFA Policy screen with SMS OTP enabled alongside Authenticator app and Email OTP.
The actual ClavionX admin screen — SMS OTP enabled as an allowed factor, no code involved.

Everything else already exists:

  • enrollment
  • OTP generation
  • resend handling
  • rate limiting
  • auditing
  • challenge screens
  • gateway abstraction

No Java implementation is required.


ClavionX end-user factor picker showing Authenticator App, Email OTP with a masked address, and SMS OTP.
The end-user side of the same feature — the enrolled factor list already masks the destination address.

Side by side

DimensionKeycloakClavionX
TOTPBuilt inBuilt in
Email OTPCustom providerEnable
SMS OTPCustom providerEnable
Gateway selectionJava implementationConfiguration
DeploymentBuild + JAR + RestartConfiguration only
Upgrade impactCustom providers remain part of deploymentPlatform 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.