Appearance
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
| Property | Value |
|---|---|
| Provider ID | microtec-phone-email-otp |
| Java class | com.microtec.keycloak.authenticator.PhoneEmailOtpAuthenticator |
| Factory class | PhoneEmailOtpAuthenticatorFactory |
| JAR | keycloak-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)
- Go to Realm Settings → Authentication → Flows
- Select Browser flow → Copy (create editable copy)
- Under the
formsexecution group → Add Step - Select Microtec Phone/Email OTP
- Set requirement to CONDITIONAL
- 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 Key | Default | Description |
|---|---|---|
otpLength | 6 | Number of OTP digits |
otpExpirySeconds | 300 | OTP validity window (5 minutes) |
preferredChannel | phone | phone or email |
fallbackToEmail | true | Fall back to email if no phone attribute set |
maxAttempts | 3 | Lock account after N consecutive failures |
smsProvider | twilio | SMS 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:
| Variable | Key Vault Secret |
|---|---|
TWILIO_ACCOUNT_SID | Twilio--AccountSid |
TWILIO_AUTH_TOKEN | Twilio--AuthToken |
TWILIO_FROM_NUMBER | Twilio--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:
| Variable | Key Vault Secret |
|---|---|
SENDGRID_API_KEY | Notification--SendGrid--ApiKey |
SENDGRID_FROM_EMAIL | Notification--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:
| Attribute | Description |
|---|---|
otp_fail_count | Number of consecutive failed OTP attempts |
otp_locked_until | Epoch 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
| Symptom | Likely Cause | Resolution |
|---|---|---|
| OTP SMS not received | Twilio credentials missing | Check TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN container env vars |
| OTP email not received | SendGrid key missing or domain not verified | Check SENDGRID_API_KEY, verify sender domain in SendGrid console |
| "Code expired" immediately | Server clock skew | Sync Keycloak container clock; check NTP configuration |
| Authenticator not appearing in flow | JAR not deployed | Verify JAR exists in /opt/keycloak/providers/, restart Keycloak |
| Phone attribute missing | User not provisioned with phone | Ensure phoneNumber user attribute is set during user creation |
Related Documentation
- Keycloak Overview — Full SPI list and deployment
- Realm Configuration — Authentication flow priority
- Deployment — JAR deployment process
- Seeding SPI — How MFA roles are provisioned