Skip to content

Phone & Email OTP Authenticator SPI

The keycloak-phone-email-auth SPI provides a second-factor authentication step that challenges users with a one-time password delivered via either SMS (phone OTP) or email OTP. It is implemented as a Keycloak Authenticator extension and is wired into the browser authentication flow as a conditional MFA step.


Overview


SPI Metadata

PropertyValue
Provider IDmicrotec-phone-email-otp
Java classcom.microtec.keycloak.authenticator.PhoneEmailOtpAuthenticator
Factory classPhoneEmailOtpAuthenticatorFactory
JARkeycloak-phone-email-auth-{version}.jar
Deployed to/opt/keycloak/providers/

Flow Configuration

The authenticator is added as a CONDITIONAL sub-flow in the browser authentication flow after successful username/password validation:

Adding to the Flow (Admin Console)

  1. Go to Realm Settings → Authentication → Flows
  2. Select Browser flow → Copy (create editable copy)
  3. Under the forms execution group → Add Step
  4. Select Microtec Phone/Email OTP
  5. Set requirement to CONDITIONAL
  6. Add condition: User Role Condition → role erp-mfa-required

Both realms (microtec and businessowner) use the same flow configuration.


Authenticator Configuration

When adding the execution step, configure the following provider settings in the admin console:

Config KeyDefaultDescription
otpLength6Number of OTP digits
otpExpirySeconds300OTP validity window (5 minutes)
preferredChannelphonephone or email
fallbackToEmailtrueFall back to email if no phone attribute set
maxAttempts3Lock account after N consecutive failures
smsProvidertwilioSMS backend: twilio or nexmo

Channel Selection Logic

java
// PhoneEmailOtpAuthenticator.java — channel selection
private OtpChannel resolveChannel(UserModel user, AuthenticatorConfigModel config) {
    String preferred = config.getConfig().getOrDefault("preferredChannel", "phone");
    boolean fallback = Boolean.parseBoolean(
        config.getConfig().getOrDefault("fallbackToEmail", "true")
    );

    if ("phone".equals(preferred)) {
        String phone = user.getFirstAttribute("phoneNumber");
        if (phone != null && !phone.isBlank()) {
            return OtpChannel.PHONE;
        }
        if (fallback) {
            return OtpChannel.EMAIL;
        }
        throw new AuthenticationFlowException(
            "User has no phone number and email fallback is disabled",
            AuthenticationFlowError.INVALID_USER
        );
    }
    return OtpChannel.EMAIL;
}

OTP Generation and Storage

OTPs are generated using SecureRandom and stored in the Keycloak authentication session (short-lived, in-memory, not persisted to the database):

java
// OTP generation
String otp = String.format("%0" + otpLength + "d",
    new SecureRandom().nextInt((int) Math.pow(10, otpLength))
);

// Store in auth session with expiry
AuthenticationSessionModel session = context.getAuthenticationSession();
session.setAuthNote("OTP_CODE", otp);
session.setAuthNote("OTP_EXPIRY",
    String.valueOf(System.currentTimeMillis() + (expirySeconds * 1000L))
);
session.setAuthNote("OTP_CHANNEL", channel.name());

Session-Only Storage

OTPs are never written to the Keycloak database. They live only in the in-memory authentication session and expire automatically when the session times out (default: 30 minutes).


SMS Integration (Twilio)

java
// TwilioSmsProvider.java
public void sendOtp(String toPhoneNumber, String otp) {
    Message.creator(
        new PhoneNumber(toPhoneNumber),
        new PhoneNumber(twilioFromNumber),
        "Your Microtec ERP verification code is: " + otp +
        "\nThis code expires in 5 minutes."
    ).create();
}

Environment variables required on the Keycloak container:

VariableKey Vault Secret
TWILIO_ACCOUNT_SIDTwilio--AccountSid
TWILIO_AUTH_TOKENTwilio--AuthToken
TWILIO_FROM_NUMBERTwilio--FromNumber

Email Integration (SendGrid)

java
// SendGridEmailProvider.java
public void sendOtp(String toEmail, String otp) {
    Mail mail = new Mail();
    mail.setFrom(new Email("noreply@onlinemicrotec.com.sa", "Microtec ERP"));
    mail.setSubject("Your verification code");
    mail.addContent(new Content("text/plain",
        "Your verification code is: " + otp +
        "\nThis code expires in 5 minutes.\n" +
        "If you did not request this code, please ignore this email."
    ));
    mail.addPersonalization(new Personalization() {{ addTo(new Email(toEmail)); }});
    sendGrid.api(new Request() {{ method = Method.POST; endpoint = "mail/send"; body = mail.build(); }});
}

Environment variables required:

VariableKey Vault Secret
SENDGRID_API_KEYNotification--SendGrid--ApiKey
SENDGRID_FROM_EMAILNotification--SendGrid--FromEmail

OTP Validation

java
@Override
public void action(AuthenticationFlowContext context) {
    String submitted = context.getHttpRequest()
        .getDecodedFormParameters()
        .getFirst("otp");

    AuthenticationSessionModel session = context.getAuthenticationSession();
    String stored  = session.getAuthNote("OTP_CODE");
    String expiry  = session.getAuthNote("OTP_EXPIRY");

    if (stored == null || expiry == null) {
        context.failure(AuthenticationFlowError.INTERNAL_ERROR);
        return;
    }

    if (System.currentTimeMillis() > Long.parseLong(expiry)) {
        context.failure(AuthenticationFlowError.EXPIRED_CODE);
        context.getEvent().error(Errors.EXPIRED_CODE);
        return;
    }

    if (!MessageDigest.isEqual(stored.getBytes(), submitted.getBytes())) {
        context.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
        context.getEvent().error(Errors.INVALID_CODE);
        return;
    }

    context.success();
}

Timing-Safe Comparison

MessageDigest.isEqual() is used instead of String.equals() to prevent timing attacks. Never compare OTP codes with == or .equals().


Rate Limiting

The authenticator tracks consecutive failures in the Keycloak user's attributes:

AttributeDescription
otp_fail_countNumber of consecutive failed OTP attempts
otp_locked_untilEpoch milliseconds until lockout expires

After maxAttempts failures, the user is locked out for 15 minutes. Administrators can clear the lockout by deleting these user attributes in the Keycloak admin console.


Troubleshooting

SymptomLikely CauseResolution
OTP SMS not receivedTwilio credentials missingCheck TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN container env vars
OTP email not receivedSendGrid key missing or domain not verifiedCheck SENDGRID_API_KEY, verify sender domain in SendGrid console
"Code expired" immediatelyServer clock skewSync Keycloak container clock; check NTP configuration
Authenticator not appearing in flowJAR not deployedVerify JAR exists in /opt/keycloak/providers/, restart Keycloak
Phone attribute missingUser not provisioned with phoneEnsure phoneNumber user attribute is set during user creation

Internal Documentation — Microtec Platform Team