INCONE60 Green - Digital and green transition of small ports
Andrzej Chybicki: projekty związane z wykorzystaniem sztucznej inteligencji to znacząca część naszych projektów
Behind the Scenes #2: Implementing email-based MFA in Keycloak
Keycloak natively supports many secure login solutions and comes with built-in one-time password (OTP) mechanisms, such as authentication via mobile apps like Google Authenticator or our solution AuthM8. However, if we want to use other advanced authentication methods and for example send OTP codes via email, then similar to SMS multi factor authentication (more details HERE), we need to implement this functionality ourselves. In this post, we’ll explore a custom MFA implementation that sends a one-time authentication code to the user’s email. 

The authentication process consists of two main stages:

    • Generating and sending the MFA code

If the user already has an active cookie confirming a previous MFA verification, they should be immediately authenticated. Otherwise, Keycloak creates a new credential for the user and generates a one-time code based on configurable parameters like length or time-to-live.  The code is stored in the user’s credentials and then is emailed using the email provider.

 

    • Verifying the entered code

When a user submits the code, KC retrieves the stored credential and compares the entered value. If the code is correct and still valid (not expired), authentication is successful, and a cookie is set to remember the verification. If the code is incorrect, the user is prompted to re-enter it and if the code has expired, an error message is shown and the process must be restarted.

Email MFA: Pros and Cons

Email-based MFA offers additional security when the primary factor, such as a password, has been compromised. This is particularly helpful in cases where passwords are brute-forced or easily guessed, such as with common combinations like 123456. Similarly, this solution offers protection against credential stuffing, where attackers use leaked passwords from other breaches to attempt logging into account.

There are several other benefits to using email as a MFA:

    • Email MFA does not require users to provide additional sensitive information, such as a phone number, reducing concerns about privacy.
    • It does not require users to install a separate app or complete a complicated setup, which simplifies the process.
    •  Users are accustomed to providing their email for various purposes, such as receiving important account updates or resetting passwords. This familiarity makes it more accessible.

However, email as a delivery channel does have some drawbacks. If an attacker compromises your email (gains access to an email account through stolen credentials or by exploiting an active session.), they could potentially reset other accounts’ passwords as well. For users in vulnerable situations, such as those with access to shared devices, email-based MFA can still leave them exposed. As with any security measure, it’s essential to weigh the benefits against the potential risks and mix email MFA with other safeguards, such as strong passwords policy and secure email practices.

Implementing Email MFA

In this modified Browser Authentication Flow, we integrate our custom MFA as an additional authentication method. There are two new steps:

    • MFA Email setup – this step ensures that email is set up and verified for the user before proceeding. If the user does not have a custom MFA Credential (which stores OTP codes as secrets), it will be set as well.
public class MfaEmailSetupAuthenticator implements Authenticator, CredentialValidator<MfaEmailCredentialProvider> {
@Override
public void authenticate(AuthenticationFlowContext context) {
[…]
// Require email verification
if (!userModel.isEmailVerified()) {
userModel.addRequiredAction(UserModel.RequiredAction.VERIFY_EMAIL);
}
// Add MFA email credential if not present
if (!getCredentialProvider(context.getSession()).isConfiguredFor(realmModel, userModel, MfaEmailCredentialModel.TYPE)) {
userModel.credentialManager().createStoredCredential(new MfaEmailCredentialModel(new MfaEmailCredentialData()));
}
[…]
    • MFA Email Authentication – this is the actual authentication step where a one-time code is sent via email. Marked as Alternative, meaning it can be used instead of other MFA methods like mobile app OTP.

Here, you can see how the configuration of this authenticator could look like in the Keycloak authentication flow.

    • Max Cookie Age this setting determines how long the MFA session (cookie) is valid. If the cookie is still valid, the user won’t be prompted for MFA. 
    • Time-to-live indicates the lifetime of the MFA code.

 

Now let’s take a look at the code. 

 

The method below handles the MFA process itself. If a valid cookie exists (indicating that the user has already completed MFA), the method immediately returns success, meaning the authentication flow is complete without requiring additional actions.

@Override
public void authenticate(AuthenticationFlowContext context) {
if (hasValidCookie(context)) {
context.success();
return;
}
[…]

If there is no cookie, we should try to retrieve the user’s existing MFA credential from the credential provider. If the user doesn’t have one, a new instance is created using the MfaEmailCredentialModel which just extends the built-in CredentialModel:

[…]
// get existing credential or create a new one
CredentialModel credentialModel = getCredentialProvider(session)
.getDefaultCredential(session, context.getRealm(), user);
if (credentialModel == null) {
credentialModel = user.credentialManager().createStoredCredential(new MfaEmailCredentialModel(new MfaEmailCredentialData()));
}
[…]

Then the authenticate method reads configuration properties like code length and TTL (time-to-live). The code itself can be generated using some utils method and will be stored as the secretData in the credential model.

// generate and store code
int length = Integer.parseInt(configMap.get(CONFIG_CODE_LENGTH));
int ttl = Integer.parseInt(configMap.get(CONFIG_CODE_TTL));
String code = MfaEmailCodesUtils.generateCode(length);
credentialModel.setSecretData(code);
user.credentialManager().updateStoredCredential(credentialModel);
AuthenticationSessionModel authSession = context.getAuthenticationSession();
authSession.setAuthNote("ttl", Long.toString(System.currentTimeMillis() + (ttl * 1000L)));

In the end the sendCode method is called to send the generated code to the user’s email. If the email is sent successfully, the method presents the form where the user can enter the MFA code.

// send email and show input form
try {
MfaEmailCodesUtils.sendCode(session, user, ttl, code, configMap);
context.challenge(context.form().setAttribute("realm", context.getRealm()).createForm(TPL_CODE));
} catch (Exception e) {
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
context.form().setError("mfaEmailNotSent", e.getMessage())  .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
}

The second major part of our Authenticator is the action method which handles the validation of the code entered by the user. It is invoked when the user submits the input form after receiving the email.  

The method retrieves the user’s credential from the provider and then the code is validated by checking it against the stored credential using the custom isValid method.

[…]
final MfaEmailCredentialModel credentialModel = getCredentialProvider(session)
       .getDefaultCredential(session, context.getRealm(), user);
boolean isValid = getCredentialProvider(session).isValid(context.getRealm(), user,
     new UserCredentialModel(credentialModel.getId(), getCredentialProvider(context.getSession()).getType(), enteredCode));
[…]

If the code is valid, the next step is to check if it is expired. We can also set a cookie that stores the MFA session to prevent the user from being prompted for MFA again during the cookie’s validity period.

[…]
// valid
HttpResponse response = context.getSession().getContext().getHttpResponse();
response.setCookieIfAbsent(createCookie(context));
context.success();
[…]

 

Of course, in this post, we will not cover the entire topic, omitting implementation details such as sending the code, generating the code, validation, and creating our custom cookie.


However, we have walked through the major steps of implementing 2FA using email-based codes. On the one hand, this approach offers a simple and accessible solution. Although it has its drawbacks, using it in solutions like Keycloak helps mitigate many of these vulnerabilities. Keycloak also provides the flexibility to combine email-based MFA with other security measures, creating a more layered and resilient authentication process that can help protect against evolving cybersecurity threats.

Do you need help configuring multi-factor authentication?

Schedule a meeting to find out how we can help you.

Sign up for our newsletter to receive the Keycloak Guide 2025 ​

and stay updated with our latest news!