diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md b/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md
new file mode 100644
index 00000000000..cb19624e564
--- /dev/null
+++ b/docs/docusaurus/docs/07-developers/10-tutorials/09-email-otp-for-imported-voters.md
@@ -0,0 +1,120 @@
+---
+id: email_otp_for_imported_voters
+title: Email OTP for Imported Voters
+---
+
+
+
+# Email OTP for Imported Voters
+
+This tutorial explains how to make imported voters receive a one-time password by
+email at login, without asking them to enrol anything first. It uses the
+**Auto-create OTP credential** option (`autoCreateCredentialAttribute`) of the
+`message-otp-authenticator` Keycloak extension.
+
+## Why this option exists
+
+The OTP step is normally guarded by Keycloak's **Condition - user configured**,
+which asks the authenticator whether the voter is configured for it. Historically
+that answer was `false` unless the voter had a stored `message-otp` credential.
+
+Voters imported or edited through the admin portal get their email and mobile
+attributes set, but no `message-otp` credential is ever stored for them. The
+result is that the OTP sub-flow is silently skipped: the voter signs in with the
+password alone, and no second factor is ever requested. In flows where the OTP is
+the only alternative left, the login instead fails with a generic
+`invalid_user_credentials` error.
+
+With **Auto-create OTP credential** enabled, the authenticator creates the
+`message-otp` credential during login for any non-deferred voter that already has
+an email address or a mobile number, so the OTP step runs on the voter's very
+first login.
+
+The option is **disabled by default**: enabling it is an explicit decision per
+authentication flow.
+
+## Prerequisites
+
+- Voters imported with an **email address** (or a mobile number for SMS).
+- A working email sender for the realm. In the development environment the dummy
+ sender writes the message to the Keycloak log instead of sending it.
+- Access to the Keycloak admin console for the election event realm.
+
+## Step 1: add the OTP sub-flow to the browser flow
+
+In the election event realm, open **Authentication** and select the browser flow
+used by the event (`sequent browser flow`). The OTP step lives in a
+**Conditional** sub-flow that contains **Condition - user configured** followed by
+**OTP - Message via Email/SMS**, both **Required**, placed after the username and
+password step:
+
+
+
+## Step 2: enable the option on the authenticator
+
+Open the settings (the gear icon) of the **OTP - Message via Email/SMS** step and
+set:
+
+- **Message Courier**: `EMAIL` (use `BOTH` or `SMS` if you also deliver by SMS).
+- **Auto-create OTP credential**: **On**.
+- **Use Deferred User**: **Off**. Deferred voters take their address from the
+ authentication session and never need a stored credential, so this option does
+ not apply to them.
+
+
+
+Save the dialog. No voter re-import or edit is required, and no credential
+enrolment required action needs to be assigned.
+
+## Step 3: the voter signs in
+
+The voter signs in with their username and password as usual:
+
+
+
+Because the voter has an email address configured, the OTP step now runs on this
+first login and the code is emailed to them:
+
+
+
+Entering the emailed code completes the login.
+
+In the development environment the message is written to the Keycloak log rather
+than sent, so you can read the code with:
+
+```bash
+docker logs keycloak --since 2m 2>&1 | grep -A6 "Sending dummy email"
+```
+
+## Step 4: verify the credential was created
+
+Open the voter in **Users → Credentials**. The `message-otp` credential now exists
+alongside the password, created at the moment of that first login:
+
+
+
+The credential is created once. Later logins reuse it, so the OTP step keeps
+working even if the option is turned off afterwards.
+
+## Behaviour summary
+
+| Situation | Option off | Option on |
+| --- | --- | --- |
+| Voter with email or mobile, no stored credential | OTP step skipped | Credential created, OTP sent |
+| Voter with a stored `message-otp` credential | OTP sent | OTP sent |
+| Voter with neither email nor mobile | OTP step skipped | OTP step skipped |
+| Deferred voter (**Use Deferred User** on) | Address taken from the authentication session | Unchanged, no credential stored |
+
+## Notes
+
+- The option only decides whether the voter is *offered* the OTP step. It does not
+ weaken the check itself: the emailed code is still generated, delivered, and
+ validated exactly as before.
+- A voter without an email address and without a mobile number is never considered
+ configured, so enabling the option cannot lock anybody into a step they cannot
+ complete.
+- Because the credential is created during login, turning the option on affects
+ voters gradually as they sign in, rather than in one migration.
diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png
new file mode 100644
index 00000000000..f8198afce74
Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_browser_flow.png differ
diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png
new file mode 100644
index 00000000000..926786eedf0
Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_config.png differ
diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png
new file mode 100644
index 00000000000..40121801e12
Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_user_credentials.png differ
diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png
new file mode 100644
index 00000000000..4174de86f69
Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_login.png differ
diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png
new file mode 100644
index 00000000000..cb095d932ab
Binary files /dev/null and b/docs/docusaurus/docs/07-developers/10-tutorials/assets/otp_auto_credential_voter_otp.png differ
diff --git a/packages/keycloak-extensions/message-otp-authenticator/pom.xml b/packages/keycloak-extensions/message-otp-authenticator/pom.xml
index 6f6d2682974..cafe326e280 100644
--- a/packages/keycloak-extensions/message-otp-authenticator/pom.xml
+++ b/packages/keycloak-extensions/message-otp-authenticator/pom.xml
@@ -89,6 +89,18 @@ SPDX-License-Identifier: AGPL-3.0-only
10.9.2
compile
+
+ org.junit.jupiter
+ junit-jupiter
+ 6.0.3
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.20.0
+ test
+
diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java
index 8d0aa39bf92..110c2152342 100644
--- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java
+++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticator.java
@@ -367,33 +367,53 @@ public boolean requiresUser() {
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
log.info("configuredFor() called");
- MessageOTPCredentialProvider provider = getCredentialProvider(session);
- if (provider == null || !provider.isConfiguredFor(realm, user, getType(session))) {
- return false;
- }
-
Optional config = Utils.getConfig(realm);
+ Map configMap =
+ MessageOTPAuthenticatorFactory.getConfigMap(config.orElse(null));
+ boolean deferredUser = "true".equals(configMap.get(Utils.DEFERRED_USER_ATTRIBUTE));
- // If no configuration is found, fall back to default behavior
- if (!config.isPresent() && user != null) {
- return user.getFirstAttribute(MOBILE_NUMBER_FIELD) != null;
- }
- boolean deferredUser =
- "true".equals(config.get().getConfig().get(Utils.DEFERRED_USER_ATTRIBUTE));
String mobileNumber = null;
String emailAddress = null;
-
if (deferredUser) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
- String mobileNumberAttribute = config.get().getConfig().get(Utils.TEL_USER_ATTRIBUTE);
+ String mobileNumberAttribute = configMap.get(Utils.TEL_USER_ATTRIBUTE);
mobileNumber = authSession.getAuthNote(mobileNumberAttribute);
emailAddress = authSession.getAuthNote("email");
} else if (user != null) {
- mobileNumber = Utils.getMobile(config.get(), user);
- emailAddress = user.getEmail();
+ mobileNumber = Utils.getMobile(config.orElse(null), user);
+ emailAddress = config.isPresent() ? user.getEmail() : null;
+ }
+ boolean hasOtpAddress = mobileNumber != null || emailAddress != null;
+
+ // In deferred mode the OTP address comes from the auth session notes (set during
+ // deferred registration/login), not from a stored credential, so a
+ // MessageOTPCredential must not be required. Otherwise, deferred users (which
+ // never get a credential created for them) would silently be filtered out of the
+ // authentication selection list, failing the flow with `invalid_user_credentials`.
+ if (!deferredUser) {
+ if (user == null) {
+ return false;
+ }
+ MessageOTPCredentialProvider provider = getCredentialProvider(session);
+ if (provider == null) {
+ return false;
+ }
+ if (!provider.isConfiguredFor(realm, user, getType(session))) {
+ // If enabled, automatically create the message-otp credential for users
+ // that have a mobile phone number or email address configured, so that
+ // imported/edited voters don't need a credential enrollment required action.
+ boolean autoCreateCredential =
+ "true".equals(configMap.get(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE));
+ if (!autoCreateCredential || !hasOtpAddress) {
+ return false;
+ }
+ log.info("Auto-creating MessageOTPCredential for user with configured mobile/email");
+ provider.createCredential(
+ realm, user, MessageOTPCredentialModel.create(/* isSetup= */ true));
+ }
}
- return mobileNumber != null || emailAddress != null;
+ return hasOtpAddress;
}
@Override
diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java
index 59ca84617ad..f807d79567c 100644
--- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java
+++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorFactory.java
@@ -144,6 +144,14 @@ public List getConfigProperties() {
"If enabled, there won't be a need to have a valid user when using this authenticator",
ProviderConfigProperty.BOOLEAN_TYPE,
"false"),
+ new ProviderConfigProperty(
+ Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE,
+ "Auto-create OTP credential",
+ "If enabled, the message-otp credential is automatically created during login for"
+ + " non-deferred users that have a mobile phone number or email address configured,"
+ + " so that no credential enrollment required action is needed.",
+ ProviderConfigProperty.BOOLEAN_TYPE,
+ "false"),
new ProviderConfigProperty(
Utils.OTL_RESTORED_AUTH_NOTES_ATTRIBUTE,
"Comma Separated Names of the Auth Notes to Restore",
diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java
index 0b2ce4344ba..1fb087dad5f 100644
--- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java
+++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/Utils.java
@@ -85,6 +85,7 @@ public class Utils {
public final String TEL_USER_ATTRIBUTE = "telUserAttribute";
public final String MESSAGE_COURIER_ATTRIBUTE = "messageCourierAttribute";
public final String DEFERRED_USER_ATTRIBUTE = "deferredUserAttribute";
+ public final String AUTO_CREATE_CREDENTIAL_ATTRIBUTE = "autoCreateCredentialAttribute";
public final String OTL_RESTORED_AUTH_NOTES_ATTRIBUTE = "otlRestoredAuthNotesAttribute";
public final String SEND_CODE_SMS_I18N_KEY = "messageOtp.sendCode.sms.text";
diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java
new file mode 100644
index 00000000000..9d06a2257b6
--- /dev/null
+++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/MessageOTPAuthenticatorTest.java
@@ -0,0 +1,219 @@
+// SPDX-FileCopyrightText: 2026 Sequent Tech
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+package sequent.keycloak.authenticator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.keycloak.credential.CredentialModel;
+import org.keycloak.models.AuthenticationExecutionModel;
+import org.keycloak.models.AuthenticationFlowModel;
+import org.keycloak.models.AuthenticatorConfigModel;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.SubjectCredentialManager;
+import org.keycloak.models.UserModel;
+import org.keycloak.provider.ProviderConfigProperty;
+import org.keycloak.sessions.AuthenticationSessionModel;
+import org.mockito.ArgumentCaptor;
+import sequent.keycloak.authenticator.credential.MessageOTPCredentialModel;
+
+class MessageOTPAuthenticatorTest {
+
+ private static final String FLOW_ID = "flow-id";
+ private static final String CONFIG_ID = "config-id";
+ private static final String PHONE_ATTRIBUTE = MessageOTPAuthenticator.MOBILE_NUMBER_FIELD;
+ private static final String PHONE_NUMBER = "+34600000000";
+ private static final String EMAIL = "voter@example.com";
+
+ private final MessageOTPAuthenticator authenticator = new MessageOTPAuthenticator();
+ private final KeycloakSession session = mock(KeycloakSession.class);
+
+ private Map baseConfig() {
+ Map config = new HashMap<>();
+ config.put(Utils.TEL_USER_ATTRIBUTE, PHONE_ATTRIBUTE);
+ return config;
+ }
+
+ /**
+ * Builds a realm mock whose authentication flows contain a single message-otp-authenticator
+ * execution with the given config, so that {@code Utils.getConfig(realm)} finds it.
+ */
+ private RealmModel mockRealm(Map configMap) {
+ RealmModel realm = mock(RealmModel.class);
+ AuthenticationFlowModel flow = new AuthenticationFlowModel();
+ flow.setId(FLOW_ID);
+ AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
+ execution.setAuthenticator(MessageOTPAuthenticatorFactory.PROVIDER_ID);
+ execution.setAuthenticatorConfig(CONFIG_ID);
+ AuthenticatorConfigModel config = new AuthenticatorConfigModel();
+ config.setId(CONFIG_ID);
+ config.setConfig(configMap);
+ when(realm.getAuthenticationFlowsStream()).thenAnswer(invocation -> Stream.of(flow));
+ when(realm.getAuthenticationExecutionsStream(FLOW_ID))
+ .thenAnswer(invocation -> Stream.of(execution));
+ when(realm.getAuthenticatorConfigById(CONFIG_ID)).thenReturn(config);
+ return realm;
+ }
+
+ private UserModel mockUser(
+ String mobileNumber,
+ String email,
+ boolean hasCredential,
+ SubjectCredentialManager credentialManager) {
+ UserModel user = mock(UserModel.class);
+ when(user.getFirstAttribute(PHONE_ATTRIBUTE)).thenReturn(mobileNumber);
+ when(user.getEmail()).thenReturn(email);
+ when(user.credentialManager()).thenReturn(credentialManager);
+ when(credentialManager.getStoredCredentialsByTypeStream(MessageOTPCredentialModel.TYPE))
+ .thenAnswer(
+ invocation ->
+ hasCredential ? Stream.of(new CredentialModel()) : Stream.empty());
+ when(credentialManager.createStoredCredential(any(CredentialModel.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ return user;
+ }
+
+ @Test
+ void autoCreateDisabledByDefaultDoesNotCreateCredentialAndReturnsFalse() {
+ RealmModel realm = mockRealm(baseConfig());
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(PHONE_NUMBER, EMAIL, /* hasCredential= */ false, credentialManager);
+
+ assertFalse(authenticator.configuredFor(session, realm, user));
+ verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class));
+ }
+
+ @Test
+ void autoCreateEnabledCreatesCredentialForUserWithMobile() {
+ Map config = baseConfig();
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(PHONE_NUMBER, null, /* hasCredential= */ false, credentialManager);
+
+ assertTrue(authenticator.configuredFor(session, realm, user));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CredentialModel.class);
+ verify(credentialManager).createStoredCredential(captor.capture());
+ assertEquals(MessageOTPCredentialModel.TYPE, captor.getValue().getType());
+ }
+
+ @Test
+ void autoCreateEnabledCreatesCredentialForUserWithEmailOnly() {
+ Map config = baseConfig();
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(null, EMAIL, /* hasCredential= */ false, credentialManager);
+
+ assertTrue(authenticator.configuredFor(session, realm, user));
+ verify(credentialManager).createStoredCredential(any(CredentialModel.class));
+ }
+
+ @Test
+ void autoCreateEnabledWithoutMobileOrEmailReturnsFalse() {
+ Map config = baseConfig();
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(null, null, /* hasCredential= */ false, credentialManager);
+
+ assertFalse(authenticator.configuredFor(session, realm, user));
+ verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class));
+ }
+
+ @Test
+ void existingCredentialReturnsTrueWithoutCreatingAnother() {
+ Map config = baseConfig();
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(PHONE_NUMBER, EMAIL, /* hasCredential= */ true, credentialManager);
+
+ assertTrue(authenticator.configuredFor(session, realm, user));
+ verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class));
+ }
+
+ @Test
+ void nullUserReturnsFalseWhenNotDeferred() {
+ Map config = baseConfig();
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+
+ assertFalse(authenticator.configuredFor(session, realm, null));
+ }
+
+ /**
+ * Deferred mode relies on auth notes and must never require nor create a stored credential,
+ * regardless of the auto-create credential setting.
+ */
+ private void assertDeferredUserWorksWithAutoCreateSetting(String autoCreateValue) {
+ Map config = baseConfig();
+ config.put(Utils.DEFERRED_USER_ATTRIBUTE, "true");
+ config.put(Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE, autoCreateValue);
+ RealmModel realm = mockRealm(config);
+
+ KeycloakContext context = mock(KeycloakContext.class);
+ AuthenticationSessionModel authSession = mock(AuthenticationSessionModel.class);
+ when(session.getContext()).thenReturn(context);
+ when(context.getAuthenticationSession()).thenReturn(authSession);
+ when(authSession.getAuthNote(PHONE_ATTRIBUTE)).thenReturn(PHONE_NUMBER);
+
+ SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class);
+ UserModel user = mockUser(null, null, /* hasCredential= */ false, credentialManager);
+
+ assertTrue(authenticator.configuredFor(session, realm, user));
+ verify(credentialManager, never()).createStoredCredential(any(CredentialModel.class));
+ }
+
+ @Test
+ void deferredUserWorksWithAutoCreateEnabledAndNeverCreatesCredential() {
+ assertDeferredUserWorksWithAutoCreateSetting("true");
+ }
+
+ @Test
+ void deferredUserWorksWithAutoCreateDisabledAndNeverCreatesCredential() {
+ assertDeferredUserWorksWithAutoCreateSetting("false");
+ }
+
+ @Test
+ void deferredUserWithoutAuthNotesReturnsFalse() {
+ Map config = baseConfig();
+ config.put(Utils.DEFERRED_USER_ATTRIBUTE, "true");
+ RealmModel realm = mockRealm(config);
+
+ KeycloakContext context = mock(KeycloakContext.class);
+ AuthenticationSessionModel authSession = mock(AuthenticationSessionModel.class);
+ when(session.getContext()).thenReturn(context);
+ when(context.getAuthenticationSession()).thenReturn(authSession);
+
+ assertFalse(authenticator.configuredFor(session, realm, null));
+ }
+
+ @Test
+ void autoCreateCredentialFactoryPropertyIsDisabledByDefault() {
+ Optional property =
+ new MessageOTPAuthenticatorFactory()
+ .getConfigProperties().stream()
+ .filter(prop -> Utils.AUTO_CREATE_CREDENTIAL_ATTRIBUTE.equals(prop.getName()))
+ .findFirst();
+
+ assertTrue(property.isPresent());
+ assertEquals(ProviderConfigProperty.BOOLEAN_TYPE, property.get().getType());
+ assertEquals("false", property.get().getDefaultValue());
+ }
+}