Keycloak MFA: Configuration Patterns for Enterprise

Guilliano Molaire Guilliano Molaire 21 min read
Cross-Region Identity Replication: Global Authentication Architecture

Last updated: August 2026

Keycloak MFA is configured through authentication flows: you add a second-factor step, OTP or WebAuthn, after the password step in the Browser flow, then decide whether it applies to everyone or only to certain users, roles or clients. The mechanics are straightforward. The hard part is everything around them.

Multi-factor authentication is one of those features that every enterprise application needs, but getting the implementation right is harder than it looks. You need to balance security requirements against user experience, support multiple second factors, handle edge cases like lost devices, and make sure downstream applications can actually detect whether MFA was used.

Keycloak provides a flexible authentication flow system that handles all of this, but the configuration options are extensive and the documentation can be sparse in places. This guide walks through the practical patterns for configuring MFA in Keycloak, from basic OTP setup through conditional flows that apply MFA selectively based on roles, clients, or other conditions. For background on Keycloak SSO concepts, see the Skycloak documentation.

How Keycloak Authentication Flows Work

An authentication flow in Keycloak is an ordered tree of authenticator steps, where each step either succeeds, fails, or is skipped based on its requirement level. MFA is implemented by adding second-factor authenticator steps (OTP, WebAuthn) after the username/password step in the Browser flow.

Before configuring MFA, it helps to understand how Keycloak structures authentication.

Keycloak ships with several built-in flows:

  • Browser: The standard login flow for web applications. Handles username/password entry, cookie-based session detection, and optional MFA steps.
  • Direct Grant: Used for the Resource Owner Password Credentials grant (machine-to-machine or CLI logins). No browser interaction.
  • Registration: Controls the user registration form.
  • Reset Credentials: Handles the forgot-password flow.

Each flow is a sequence of executions, and each execution has a requirement level:

Requirement Behavior
Required Must succeed for the flow to continue
Alternative At least one alternative must succeed
Conditional Executes only if its condition evaluates to true
Disabled Skipped entirely

MFA configuration primarily involves modifying the Browser flow to add second-factor authenticator steps after the username/password step.

The Default Browser Flow

For a realm created on Keycloak 26.4 or later, the Browser flow looks like this (verified against 26.7.1):

  1. Cookie (Alternative), checks for an existing session
  2. Kerberos (Disabled)
  3. Identity Provider Redirector (Alternative), handles IdP-initiated login
  4. Organization (Alternative), present when the Organization feature is on, which it is by default
  5. forms (Alternative), a sub-flow containing:
    • Username Password Form (Required)
    • Browser - Conditional 2FA (Conditional), a sub-flow containing:
      • Condition - user configured (Required)
      • Condition - credential (Required), which skips the second factor when the user signed in with a passkey
      • OTP Form (Alternative)
      • WebAuthn Authenticator (Disabled)
      • Recovery Authentication Code Form (Disabled)

Check your own version before following that listing. Flows are realm data, created once when the realm is created and never rewritten by an upgrade, so what you see depends on when your realm was made rather than on which Keycloak you are running today:

Realm created on What you will find
26.3 and later A sub-flow named Browser - Conditional 2FA
26.0 to 26.2 A sub-flow named Browser - Conditional OTP, with the OTP Form directly inside it
Before that Varies, but the Conditional sub-flow pattern has been the default for years

The Condition - credential step is newer still. Passkeys only became a default-enabled feature in 26.4, so on 26.3 you will not see it unless you turned the preview feature on. Everything in this guide works the same way on either sub-flow name. Substitute whichever one your realm actually has.

There is no Optional requirement in any of them. It was removed in Keycloak 8.0, released in November 2019, when Conditional sub-flows took over the job. The table above lists every requirement value that still exists. If you are reading a guide that tells you to switch the OTP Form from Optional to Required, that guide describes a screen that has been gone for years.

What you get by default is opt-in MFA, and it comes from a condition rather than a requirement. Condition - user configured evaluates to true only for users who already have a second factor registered, so those users get prompted for OTP and everyone else passes straight through. That is a sensible default: it is secure for users who enrolled and puts no barrier in front of users who have not. Making MFA mandatory is therefore a question of enrollment and conditions, not of flipping a requirement on the OTP Form.

Enabling OTP as a Required Action

The simplest way to roll out MFA is to configure OTP (one-time password) as a required action for all users. This prompts every user to set up an authenticator app on their next login.

Step 1: Configure the OTP Policy

Navigate to Authentication > Policies > OTP Policy in the Keycloak admin console. Here you configure the technical parameters of OTP generation:

Setting Recommended Value Notes
OTP Type Time-Based (TOTP) Preferred over counter-based (HOTP) for most use cases
Algorithm SHA-1 SHA-256 and SHA-512 are more secure but some authenticator apps do not support them
Number of Digits 6 Standard for most authenticator apps
Look Ahead Window 1 Number of intervals to check ahead/behind to handle clock skew
Period 30 seconds Standard TOTP interval

The Look Ahead Window is worth understanding. A value of 1 means Keycloak accepts the current code, the previous code, and the next code. This accounts for clock drift between the server and the user’s device. Increasing this value makes the system more forgiving but slightly less secure.

For HOTP (counter-based), the look-ahead window determines how many counter values ahead Keycloak will check. This handles cases where a user generates codes without submitting them, causing the counter to desynchronize.

Step 2: Make OTP a Required Action

Navigate to Authentication > Required Actions. Find Configure OTP in the list and enable two settings:

  • Enabled: Toggle on (allows the action to be triggered)
  • Default Action: Toggle on (every new user and every user who hasn’t configured OTP will be prompted)

With Default Action enabled, the next time any user logs in, Keycloak will interrupt the login flow and present a QR code for setting up their authenticator app.

Step 3: Make MFA Mandatory Rather Than Opt-In

Step 2 gets every user enrolled. This step decides whether the second factor is actually demanded at login.

Because the second factor sits behind Condition - user configured, enrollment and enforcement are the same lever by default. Once Configure OTP is a Default Action, every user ends up with OTP registered, the condition then passes for all of them, and everyone is prompted from that point on. For most rollouts this is enough, and it is the gentler path because nobody gets locked out mid-migration.

If you need the flow to demand a second factor whether or not the user has one registered, override the condition instead:

  1. Go to Authentication > Flows, find the browser flow and duplicate it. The console does let you change requirements on a built-in flow directly, but not its structure, so working on a copy keeps you a clean rollback.
  2. In the copy, find the Browser - Conditional 2FA sub-flow, or Browser - Conditional OTP on an older realm.
  3. Change that sub-flow’s own requirement from Conditional to Required.
  4. Bind the copy through Action > Bind flow > Browser flow.

This mirrors what Keycloak does for you when it builds a browser flow for a realm that already had OTP enforced. Be aware of the consequence: users who have not registered a second factor will hit the Configure OTP required action before they can finish logging in. Pair it with Step 2, or you will lock people out of their own accounts.

Configuring WebAuthn / Passkeys

WebAuthn provides phishing-resistant MFA using hardware security keys (YubiKey, Titan) or platform authenticators (Touch ID, Windows Hello, Android biometrics). Keycloak has built-in support for WebAuthn as both a second factor and a passwordless primary factor. For a dedicated walkthrough of passkey setup, see enabling passkeys for 2FA in Keycloak.

Enabling WebAuthn as a Second Factor

  1. Navigate to Authentication > Required Actions.
  2. Enable Webauthn Register and set it as a Default Action if you want all users prompted to register a security key.
  3. Go to Authentication > Flows and duplicate the Browser flow.
  4. Open the Browser - Conditional 2FA sub-flow. The WebAuthn Authenticator execution is already there, shipped as Disabled, so you are enabling it rather than adding it.
  5. Set it to Alternative to offer it alongside OTP, or Required to insist on a security key specifically.

WebAuthn Policy Configuration

Navigate to Authentication > Policies > WebAuthn Policy to configure:

  • Relying Party Entity Name: Your application name shown during registration (e.g., “Skycloak”)
  • Signature Algorithms: ES256 is recommended (widely supported and secure)
  • Attestation Conveyance Preference: Set to “none” unless you need to verify the make/model of security keys
  • Authenticator Attachment: “cross-platform” for security keys, “platform” for biometrics, or leave unset for both
  • Require Resident Key: Set to “No” for second-factor usage, “Yes” for passwordless flows
  • User Verification Requirement: “preferred” is a good default

Offering Multiple Second-Factor Options

Most enterprise deployments want to let users choose between OTP and WebAuthn. The default flow already has the shape for this, so you do not need to build a new sub-flow:

  1. Open the Browser - Conditional 2FA sub-flow.
  2. Leave OTP Form as Alternative.
  3. Change WebAuthn Authenticator from Disabled to Alternative.

This presents users with a choice. If they have both OTP and WebAuthn configured, Keycloak shows a selection screen. If they have only one configured, it goes directly to that method.

Conditional MFA with Keycloak’s Condition Authenticators

Requiring MFA for every user on every login is often too aggressive. Many organizations want MFA only for admin users, only for sensitive applications, or only when logging in from an untrusted network. Keycloak supports this through Conditional sub-flows.

How Conditional Flows Work

A conditional sub-flow contains one or more Condition authenticators followed by the actual authenticator steps. The conditions act as gates: if the condition evaluates to true, the subsequent steps execute. If false, the entire sub-flow is skipped.

Keycloak provides several built-in conditions:

  • Condition - User Role: Checks if the user has a specific realm or client role
  • Condition - User Configured: Checks if the user has configured a specific credential type
  • Condition - User Attribute: Checks a user attribute value (available in newer Keycloak versions)

Example: MFA Only for Admin Users

Here is how to require MFA only for users with the admin role:

  1. Go to Authentication > Flows and duplicate the Browser flow (name it “Browser with Conditional MFA”).
  2. Set the existing Browser - Conditional 2FA sub-flow to Disabled, so it does not fire alongside the role-based one you are about to add.
  3. Inside the forms sub-flow, add a new sub-flow called “Conditional Admin MFA” and set it to Conditional.
  4. Inside that sub-flow, add the execution Condition - User Role and set it to Required.
  5. Configure the condition: set the role to admin (realm role) or client-name.admin (client role).
  6. Add the OTP Form execution inside the same sub-flow and set it to Required.
  7. Bind the new flow: go to Authentication > Flows, select your new flow, and click Bind flow to set it as the Browser flow.

Now, when a user logs in, Keycloak checks their roles. If they have the admin role, the OTP form appears. Otherwise, it is skipped.

Stacking Multiple Conditions

You can combine conditions by adding multiple condition authenticators to the same conditional sub-flow. All conditions set to Required must be true for the sub-flow to execute. For example, you could require MFA only for users who both have the admin role AND have already configured OTP (preventing users from being forced to set up OTP mid-login).

MFA for Specific Clients Only

One of Keycloak’s most useful features for MFA is client-level authentication flow overrides. This lets you require MFA for your internal admin dashboard while keeping the customer-facing app on simple password authentication.

Configuring Client Flow Overrides

  1. Create a custom browser flow with MFA required (as described in previous sections).
  2. Navigate to Clients and select the client that requires MFA.
  3. Go to the Advanced tab (or Authentication Flow Overrides section, depending on your Keycloak version).
  4. Under Browser Flow, select your custom MFA-enforcing flow.
  5. Click Save.

Now this client uses the MFA flow while all other clients continue using the realm’s default Browser flow.

This pattern works well for scenarios like:

  • Internal admin tools requiring MFA, customer portals not requiring it
  • High-value applications (financial, healthcare) requiring MFA while lower-risk apps skip it
  • Gradual MFA rollout across applications

Example Configuration

Suppose you have three clients:

Client Browser Flow Override MFA Behavior
customer-portal (default) No MFA
admin-dashboard Browser with Required MFA Always MFA
reporting-tool Browser with Conditional MFA MFA for admin role only

Each client can reference a different authentication flow, giving you fine-grained control.

Building Custom Authentication Flows Step by Step

For complex MFA requirements, you will need to create custom authentication flows. Here is the complete process in the admin console.

Creating the Flow

  1. Navigate to Authentication > Flows.
  2. Click Create flow.
  3. Enter a name (e.g., “Enterprise Browser Flow”) and set the type to Basic flow.
  4. Click Create.

Adding Executions

The new flow starts empty. Build it up step by step:

  1. Click Add step and add Cookie (set to Alternative).
  2. Click Add step and add Identity Provider Redirector (set to Alternative).
  3. Click Add sub-flow, name it “Login Form” (set to Alternative).
  4. Inside Login Form, add Username Password Form (Required).
  5. Inside Login Form, add a sub-flow “MFA” (Required).
  6. Inside MFA, add OTP Form (Alternative).
  7. Inside MFA, add WebAuthn Authenticator (Alternative).

Making It Conditional

To make the MFA sub-flow conditional instead of required:

  1. Change the MFA sub-flow’s requirement to Conditional.
  2. Inside MFA, add Condition - User Role (Required) before the authenticator steps.
  3. Configure the condition with the desired role.
  4. Keep the OTP Form and WebAuthn Authenticator as Alternative.

Binding the Flow

After creating the flow, you must bind it:

  • Realm-wide: On the flow’s page, click the dropdown and select Bind flow to make it the default Browser flow.
  • Per-client: Go to the specific client’s Advanced settings and select the flow as the Browser Flow override.

Recovery Codes and Backup Authentication

Users inevitably lose their phones or security keys. Without backup authentication methods, they get locked out entirely. Keycloak provides several mechanisms to handle this.

Built-in Recovery Options

Keycloak does ship recovery codes, contrary to a lot of older writing about it. The Recovery Codes feature is enabled by default in current versions, but the flow execution is not, which is why people conclude it is missing.

Recovery Authentication Codes: Set the Recovery Authentication Code Form execution inside the Browser - Conditional 2FA sub-flow to Alternative, then enable the Recovery Authentication Codes required action under Authentication > Required Actions. Users are shown a one-time list of codes to store somewhere safe, and any one of them satisfies the second-factor step.

Admin-initiated OTP reset: An administrator can remove a user’s OTP credential from Users > [User] > Credentials, then re-add the “Configure OTP” required action. The user sets up a new authenticator on next login.

Multiple OTP devices: Users can register more than one OTP credential, so losing one device still leaves another. Users add extras themselves from the Account Console; there is no admin toggle that lets a required action fire repeatedly.

WebAuthn as backup: If a user has both OTP and a hardware security key registered, losing one device still leaves the other as a viable second factor.

Alternative Second Factors

For organizations that need additional MFA options beyond TOTP and WebAuthn, Keycloak’s SPI (Service Provider Interface) supports custom authenticator implementations. Common additions include:

  • Email OTP: Sends a one-time code to the user’s email address. Skycloak’s managed Keycloak platform includes an Email OTP extension pre-installed, adding email-based verification as a second factor option without custom development. See using the Email OTP extension with Skycloak for a setup guide.
  • SMS OTP: Sends codes via SMS (requires a custom authenticator or third-party extension).
  • Push notifications: Integrates with push notification services for approve/deny MFA prompts.

These extensions plug into the same authentication flow system, so they can be added as Alternative executions alongside OTP and WebAuthn.

Detecting MFA Status in Application Tokens

Configuring MFA in Keycloak is only half the story. Your applications need to know whether a user authenticated with MFA so they can make authorization decisions accordingly, such as allowing access to sensitive operations only after MFA.

The acr Claim

Keycloak can include the Authentication Context Class Reference (acr) claim in ID tokens and access tokens. It is the right place to look, but it does not mean what a lot of tutorials claim it means, and getting this wrong produces a check that silently passes password-only logins.

There is no built-in mapping where acr: "0" means single-factor and acr: "1" means MFA. The acr value is a Level of Authentication (LoA) number that you define yourself, by adding Conditional - Level Of Authentication conditions to your authentication flow and assigning each one a number. Until you configure those levels, the numbers carry no MFA meaning at all.

Here is what the values actually mean on a default install:

Value What it means
0 The session was resumed via SSO and the level the user had achieved has passed its configured Max Age. The OIDC Core specification describes this as authentication “based solely on a long-lived browser cookie”. Note that SSO on its own does not produce 0: a user coming back on the cookie while their level is still inside Max Age keeps that level.
1 The user authenticated at the first LoA condition in your flow, whatever that condition happens to contain.

That second row is the trap. In Keycloak’s own step-up walkthrough, level 1 is the Username Password Form and level 2 is the OTP Form. The documentation spells out the result: a login request that does not ask for a level means “Level 1 will be used and the user needs to authenticate with username and password. The token will have acr=1.”

So on a default flow, acr === "1" does not prove MFA happened. It proves the user typed a password and nothing more. A gate written against "1" is not merely weak, it is checking for the wrong thing entirely.

Making acr Mean Something

To get an acr value worth gating on, define the levels before you write the check:

Build a new flow rather than duplicating the browser flow. A duplicate already contains its own forms sub-flow and 2FA sub-flow, so layering levels on top gives you a second password prompt and a stray second factor. This is the structure from Keycloak’s own documented walkthrough:

  1. In Authentication > Flows, click Create flow, name it, and add the Cookie execution as Alternative.
  2. Add a sub-flow called “Auth Flow”, set to Alternative. Everything below goes inside it.
  3. Add a sub-flow set to Conditional, add the Conditional - Level Of Authentication condition to it, give it LoA 1, and put the Username Password Form inside.
  4. Add a second Conditional sub-flow with LoA 2, and put the OTP Form inside it as Required.
  5. Set Max Age on level 2 to 0 if the second factor should be re-checked on every sensitive request rather than reused for the rest of the session. Level 1 typically gets the realm’s SSO Session Max, 36000 seconds.
  6. Bind the flow, then have clients request a level with the acr_values or claims parameter.

Order the sub-flows lowest level first. During a user’s very first authentication Keycloak always runs the first Conditional - Level Of Authentication sub-flow it finds, regardless of what was requested, so a level 2 sub-flow placed first would be demanded of everyone.

The claim itself is emitted by the acr loa level protocol mapper in the built-in acr client scope. That scope is a realm default and is already attached to new clients, so there is nothing to add. You only touch it if you want to remove the claim or replace it with custom logic.

Optionally, map the numbers to names under Realm settings > General > ACR to LoA Mapping, so tokens carry acr: "gold" instead of acr: "2". If you do that, compare against the names in your application. The numeric comparisons in the samples below assume you have not configured a name mapping, and would reject "gold" outright.

Checking MFA Status in Your Application

Here is how to check the acr claim in a decoded JWT token (you can paste tokens into the JWT Token Analyzer to inspect claims interactively):

{
  "sub": "f1b2c3d4-5678-9abc-def0-1234567890ab",
  "email": "user@example.com",
  "acr": "2",
  "amr": ["pwd", "otp"],
  "aud": "my-application",
  "iss": "https://auth.example.com/realms/my-realm"
}

This token shows acr: "2", the level assigned to the sub-flow containing the OTP Form in the setup above. The amr (Authentication Methods References) claim lists the specific methods used, here a password (pwd) and a one-time password (otp).

In a Node.js application, you might enforce MFA for sensitive operations like this:

const jwt = require('jsonwebtoken');

// Realm signing key from /realms/{realm}/protocol/openid-connect/certs.
// In production use a JWKS client such as jwks-rsa so key rotation is handled.
const REALM_PUBLIC_KEY = process.env.REALM_PUBLIC_KEY;

// The LoA you assigned to the sub-flow containing the OTP Form.
const REQUIRED_LOA = 2;

function requireMfa(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  let claims;
  try {
    // Verify, never decode. jwt.decode() skips the signature check, which
    // would let a caller hand you any acr value they felt like typing.
    claims = jwt.verify(token, REALM_PUBLIC_KEY, {
      algorithms: ['RS256'],
      issuer: 'https://auth.example.com/realms/my-realm',
      audience: 'my-application'
    });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Compare numerically. A higher level satisfies a lower requirement,
  // and acr "0" means an SSO session whose level has expired.
  const acr = Number.parseInt(claims.acr, 10);
  if (!Number.isInteger(acr) || acr < REQUIRED_LOA) {
    return res.status(403).json({
      error: 'Multi-factor authentication required',
      hint: `Re-authenticate with acr_values=${REQUIRED_LOA}`
    });
  }

  next();
}

// Apply to sensitive routes
app.post('/api/billing/update', requireMfa, billingController.update);
app.delete('/api/users/:id', requireMfa, userController.delete);

In a Java Spring application using Spring Security’s OAuth2 resource server support (the old Keycloak Spring adapters are discontinued, so validation goes through Spring’s own JWT support):

@Component
public class MfaAuthorizationFilter extends OncePerRequestFilter {

    // The LoA assigned to the sub-flow containing the OTP Form.
    private static final int REQUIRED_LOA = 2;

    // Only guard the sensitive routes. Registered globally, this filter would
    // otherwise demand MFA for your health check.
    private static final List<String> PROTECTED = List.of(
            "/api/billing", "/api/users");

    /** Absent or non-numeric acr is treated as no level at all. */
    private static int parseLoa(String acr) {
        try {
            return acr == null ? -1 : Integer.parseInt(acr);
        } catch (NumberFormatException e) {
            return -1;
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return PROTECTED.stream().noneMatch(path::startsWith);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain)
            throws ServletException, IOException {

        Authentication auth = SecurityContextHolder.getContext()
                .getAuthentication();

        // Fail closed. Anything that is not a verified JWT gets rejected
        // rather than waved through to the next filter.
        if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        String acr = jwtAuth.getToken().getClaimAsString("acr");

        if (parseLoa(acr) < REQUIRED_LOA) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write(
                "{"error": "MFA required for this operation"}"
            );
            return;
        }

        chain.doFilter(request, response);
    }
}

Step-Up Authentication with ACR Values

For applications that need to request a specific authentication level, use the acr_values parameter in the OpenID Connect authorization request. Request the level that holds your second factor, which is 2 in the flow configured above:

GET /realms/my-realm/protocol/openid-connect/auth
  ?client_id=my-app
  &response_type=code
  &scope=openid
  &acr_values=2
  &redirect_uri=https://app.example.com/callback

When Keycloak receives acr_values=2, it checks whether the user already holds level 2 and whether that level is still inside its Max Age. If not, it prompts for the second factor before issuing tokens. A user who is already signed in with username and password only holds level 1, so they get the OTP prompt and nothing else, not a full re-login.

This enables step-up authentication: a user browses low-sensitivity pages at level 1, and when they reach a sensitive page your application redirects them to Keycloak with acr_values=2.

Two things to get right, both of which Keycloak’s documentation calls out explicitly:

  • Always re-check the token. A user can edit acr_values in the browser URL before the request reaches Keycloak. The parameter is a request, not a guarantee. Verify the acr claim in the returned token rather than assuming you got the level you asked for, or use Pushed Authorization Requests (PAR) so the parameter cannot be rewritten in transit.
  • Use claims when the level is non-negotiable. Sending acr as an essential claim through the claims parameter forces Keycloak to either return one of the levels you listed or fail with an error. Plain acr_values is treated as a non-essential hint, so a mismatch comes back quietly rather than as an error.

OTP Policy Tuning for Enterprise Environments

The default OTP policy works for most cases, but enterprise environments often have specific requirements.

Algorithm Selection

Algorithm Compatibility Security
SHA-1 Supported by all major authenticator apps Adequate for TOTP (used with HMAC, not raw hashing)
SHA-256 Google Authenticator (recent versions), Authy, 1Password Better theoretical security margin
SHA-512 Limited app support Overkill for 6-digit codes

If your organization standardizes on a specific authenticator app, check which algorithms it supports before changing from the SHA-1 default. Switching algorithms after users have enrolled means they need to re-register their authenticator.

For details on how authentication errors are presented to users during failed MFA attempts, see our guide on authentication error handling in Keycloak.

Longer Codes for High-Security Environments

Increasing from 6 to 8 digits raises the brute-force difficulty from 1-in-a-million to 1-in-100-million. The tradeoff is user friction, 8-digit codes are harder to type quickly. For most organizations, 6 digits with rate limiting on failed attempts provides sufficient security.

Adjusting the Time Period

The default 30-second window is standard, but some environments adjust it:

  • 15 seconds: Higher security, but users have less time to type the code. Can cause problems with slow typists or accessibility needs.
  • 60 seconds: More forgiving, but each code is valid for longer. Consider this for environments with known clock synchronization issues.

Putting It All Together: An Enterprise MFA Architecture

Here is a complete example combining the patterns discussed above for a typical enterprise deployment:

Realm-level defaults:

  • OTP Policy: TOTP, SHA-1, 6 digits, 30-second period
  • WebAuthn enabled as an alternative second factor
  • Default Browser flow left as shipped, so the second factor applies to users who have enrolled one

Client overrides:

  • customer-portal: Uses the default flow, so MFA is opt-in
  • admin-console: Custom flow with the 2FA sub-flow set to Required, so MFA applies to everyone
  • finance-app: Custom flow with conditional MFA, required for the finance-admin role and left opt-in for everyone else

Token-level enforcement:

  • LoA levels defined in the flow: level 1 is username and password, level 2 adds the second factor
  • All clients include the acr scope, which is a realm default
  • Sensitive API endpoints require acr >= 2, compared numerically, before processing requests
  • Step-up redirects users to Keycloak with acr_values=2 when accessing high-risk operations, and re-check the returned token rather than trusting the request

Recovery:

  • Users encouraged to register both an authenticator app and a hardware key
  • Email OTP available as a backup second factor (via Skycloak’s Email OTP extension on the managed platform)
  • Admins can reset user credentials and re-trigger OTP enrollment through the admin console

This layered approach provides strong security for sensitive resources without burdening every user on every login.

Keycloak MFA FAQ

How do I enable MFA in Keycloak?

Set Configure OTP as a Default Action under Authentication > Required Actions. Every user is then prompted to register an authenticator app at their next login, and the Browser - Conditional 2FA sub-flow in the default Browser flow prompts them for a code on every login after that. No flow editing is required for this path.

Why can’t I set the OTP Form to Optional?

The Optional requirement was removed in Keycloak 8.0. Current versions support only Required, Conditional, Alternative and Disabled. The opt-in behaviour Optional used to provide now comes from the Condition - user configured condition inside the Conditional 2FA sub-flow, which prompts only the users who have already registered a second factor.

Does acr: "1" in the token mean the user completed MFA?

No. On a default flow, acr: "1" means the user authenticated at the first Level of Authentication condition in the flow, which is normally username and password alone. acr: "0" means the session was resumed via SSO after the achieved level had passed its Max Age. To gate on MFA you must define your own LoA levels, put the second factor at level 2 or higher, and compare numerically.

How do I require MFA for every user, including those who have not enrolled?

Duplicate the browser flow and change the Browser - Conditional 2FA sub-flow requirement from Conditional to Required, then bind the copy. Combine it with Configure OTP as a Default Action so unenrolled users are sent to registration instead of being locked out.

Can a user register more than one second factor?

Yes. A user can hold multiple OTP credentials plus WebAuthn security keys at the same time. Any one of them satisfies the second-factor step, though note that the WebAuthn Authenticator execution ships Disabled, so a security key counts for nothing until you enable it in the flow. Registering two is the cheapest form of account recovery, because losing one device does not require an admin reset.

How do I apply MFA to some applications but not others?

Create a custom flow with the second factor required, then assign it to specific clients through the client’s Authentication flow overrides. The realm keeps its default flow and only the clients you override demand a second factor.

Managed MFA with Skycloak

Configuring MFA in Keycloak is straightforward for basic scenarios, but enterprise deployments often run into operational complexity: keeping OTP policies consistent across environments, managing WebAuthn relying party configuration, deploying custom authenticator extensions, and handling the inevitable support tickets from users who lose their devices.

Skycloak’s managed Keycloak platform handles this operational overhead. MFA features including OTP, WebAuthn, and Email OTP come pre-configured and ready to use. Authentication flows can be customized through the standard Keycloak admin console, with the underlying infrastructure, updates, and high availability managed for you.

If you are evaluating MFA options for your Keycloak deployment, take a look at Skycloak’s pricing plans to see how managed Keycloak can simplify your authentication infrastructure.

MFA is one of the most effective controls for reducing insider risk. Our guide on reducing insider risk with IAM security measures covers how MFA, RBAC, session management, and audit logging work together to limit the blast radius of compromised or malicious accounts.

Tired of running Keycloak yourself?

Skycloak runs real upstream Keycloak for you with a 99.99% SLA. No fork, no lock-in, just managed Keycloak that stays patched and on call so you don't have to.

Guilliano Molaire
Written by
Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Start Free Trial Talk to Sales
© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman