Building Custom Authentication Flows in Keycloak

Guilliano Molaire Guilliano Molaire Updated June 2, 2026 10 min read

Last updated: March 2026

Keycloak’s authentication system is built around flows — configurable sequences of authentication steps that users must complete to log in. The built-in flows handle standard username/password login, OTP verification, and social login. But real-world requirements often demand custom logic: IP-based restrictions, device fingerprinting, progressive profiling, conditional MFA based on risk signals, or integration with external systems.

This guide walks through Keycloak’s authentication flow system, from using the admin console flow editor to building and deploying a custom authenticator SPI in Java.

Understanding Authentication Flows

An authentication flow in Keycloak is a directed graph of authentication steps called executions. Each execution has a requirement that determines how it participates in the flow:

Requirement Behavior
Required Must succeed. If it fails, the entire flow fails.
Alternative At least one alternative execution must succeed. If one passes, the others are skipped.
Conditional Acts as a gate. If the condition is true, the sub-flow’s executions run. If false, they are skipped entirely.
Disabled Ignored. Useful for temporarily removing a step without deleting it.

Built-in Flows

Keycloak ships with several pre-configured flows:

  • Browser Flow: The main login flow for web applications
  • Direct Grant Flow: For Resource Owner Password Credentials (not recommended for production)
  • Registration Flow: User self-registration
  • Reset Credentials Flow: Password reset
  • First Broker Login Flow: What happens when a user authenticates via an identity provider for the first time

You cannot modify built-in flows directly. Instead, copy them and modify the copy.

Using the Flow Editor

Copying and Modifying a Flow

  1. Navigate to Authentication in the admin console
  2. Select the Browser flow
  3. Click Duplicate and name it (e.g., “Custom Browser Flow”)
  4. Now you can add, remove, and reorder executions

Adding Conditional OTP

A common customization is requiring multi-factor authentication only for certain users or conditions. Here is how to set up conditional OTP:

  1. In your custom browser flow, find the Browser – Conditional OTP sub-flow

  2. By default, it contains:

    • Condition – User Configured (Conditional): Checks if the user has OTP configured
    • OTP Form (Required): Prompts for OTP if the condition is met
  3. To make OTP required for all users (not just those who have configured it):

    • Remove the conditional sub-flow
    • Add OTP Form directly as a Required execution
  4. To require OTP only for users with a specific role:

    • Add a Condition – User Role execution before the OTP Form
    • Configure it with the role name (e.g., admin)
    • Set the OTP Form as Required within the conditional sub-flow

Adding WebAuthn (Passkeys)

To add passkey support as an alternative to passwords:

  1. Add a sub-flow named “Passwordless” at the same level as the username/password form
  2. Set it as Alternative
  3. Inside the sub-flow, add WebAuthn Passwordless Authenticator as Required
  4. Set the existing username/password sub-flow to Alternative as well

Now users can choose between password-based login and passkey authentication. For more on passkey configuration, see our guides on WebAuthn with passkeys and enabling passkeys for 2FA.

Binding the Flow

After creating your custom flow, bind it to your realm:

  1. Go to Authentication > Bindings
  2. Set Browser Flow to your custom flow
  3. Click Save

All browser-based logins in the realm now use your custom flow.

Custom Authenticator SPI

When the built-in authenticators do not meet your requirements, you can write a custom one using Keycloak’s Service Provider Interface (SPI).

Project Setup

Create a Maven project:

<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>keycloak-ip-authenticator</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <keycloak.version>26.0.0</keycloak.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-core</artifactId>
            <version>${keycloak.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi</artifactId>
            <version>${keycloak.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-server-spi-private</artifactId>
            <version>${keycloak.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-services</artifactId>
            <version>${keycloak.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>
</project>

Example: IP Allowlist Authenticator

This custom authenticator checks the user’s IP address against an allowlist. If the IP is not allowed, authentication fails.

// src/main/java/com/example/IpAllowlistAuthenticator.java
package com.example;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.jboss.logging.Logger;

import java.util.Arrays;
import java.util.List;

public class IpAllowlistAuthenticator implements Authenticator {

    private static final Logger LOG = Logger.getLogger(IpAllowlistAuthenticator.class);

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        String remoteAddress = context.getConnection().getRemoteAddr();

        // Also check X-Forwarded-For for proxied requests
        String forwardedFor = context.getHttpRequest()
                .getHttpHeaders()
                .getHeaderString("X-Forwarded-For");

        String clientIp = forwardedFor != null
                ? forwardedFor.split(",")[0].trim()
                : remoteAddress;

        LOG.infof("Checking IP allowlist for client IP: %s", clientIp);

        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        if (config == null || config.getConfig() == null) {
            LOG.warn("No authenticator config found, allowing access");
            context.success();
            return;
        }

        String allowlistStr = config.getConfig().get("ip-allowlist");
        if (allowlistStr == null || allowlistStr.trim().isEmpty()) {
            LOG.warn("Empty IP allowlist, allowing access");
            context.success();
            return;
        }

        List<String> allowedIps = Arrays.asList(allowlistStr.split(","));
        boolean allowed = allowedIps.stream()
                .map(String::trim)
                .anyMatch(ip -> matchIp(clientIp, ip));

        if (allowed) {
            LOG.infof("IP %s is in allowlist, granting access", clientIp);
            context.success();
        } else {
            LOG.warnf("IP %s is NOT in allowlist, denying access", clientIp);
            context.failure(AuthenticationFlowError.ACCESS_DENIED);
        }
    }

    private boolean matchIp(String clientIp, String pattern) {
        if (pattern.contains("/")) {
            return matchCidr(clientIp, pattern);
        }
        return clientIp.equals(pattern);
    }

    private boolean matchCidr(String ip, String cidr) {
        try {
            String[] parts = cidr.split("/");
            String cidrIp = parts[0];
            int prefixLength = Integer.parseInt(parts[1]);

            long ipLong = ipToLong(ip);
            long cidrIpLong = ipToLong(cidrIp);
            long mask = -(1L << (32 - prefixLength));

            return (ipLong & mask) == (cidrIpLong & mask);
        } catch (Exception e) {
            LOG.warnf("Invalid CIDR pattern: %s", cidr);
            return false;
        }
    }

    private long ipToLong(String ip) {
        String[] octets = ip.split("\.");
        return (Long.parseLong(octets[0]) << 24)
             + (Long.parseLong(octets[1]) << 16)
             + (Long.parseLong(octets[2]) << 8)
             + Long.parseLong(octets[3]);
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // No user interaction needed
    }

    @Override
    public boolean requiresUser() {
        return false;
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        // No required actions
    }

    @Override
    public void close() {
        // No resources to clean up
    }
}

Authenticator Factory

The factory tells Keycloak about your authenticator and its configurable properties:

// src/main/java/com/example/IpAllowlistAuthenticatorFactory.java
package com.example;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.List;

public class IpAllowlistAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "ip-allowlist-authenticator";
    private static final IpAllowlistAuthenticator INSTANCE = new IpAllowlistAuthenticator();

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public String getDisplayType() {
        return "IP Allowlist";
    }

    @Override
    public String getHelpText() {
        return "Checks if the client IP is in the configured allowlist. "
             + "Supports individual IPs and CIDR notation.";
    }

    @Override
    public String getReferenceCategory() {
        return "Access Control";
    }

    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return new AuthenticationExecutionModel.Requirement[]{
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.ALTERNATIVE,
            AuthenticationExecutionModel.Requirement.DISABLED,
        };
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty allowlist = new ProviderConfigProperty();
        allowlist.setName("ip-allowlist");
        allowlist.setLabel("IP Allowlist");
        allowlist.setHelpText(
            "Comma-separated list of allowed IPs or CIDR ranges. "
            + "Example: 192.168.1.0/24, 10.0.0.1, 203.0.113.0/28"
        );
        allowlist.setType(ProviderConfigProperty.STRING_TYPE);
        allowlist.setDefaultValue("");

        return List.of(allowlist);
    }

    @Override
    public Authenticator create(KeycloakSession session) {
        return INSTANCE;
    }

    @Override
    public void init(Config.Scope scope) {
        // No initialization needed
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
        // No post-initialization needed
    }

    @Override
    public void close() {
        // No resources to clean up
    }
}

SPI Service Registration

Create the service provider registration file:

src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory

With the content:

com.example.IpAllowlistAuthenticatorFactory

Building and Deploying

Build the JAR:

mvn clean package

Deploy to Keycloak by copying the JAR to the providers directory:

# For standalone Keycloak
cp target/keycloak-ip-authenticator-1.0.0.jar /opt/keycloak/providers/

# Rebuild Keycloak's optimized runtime
/opt/keycloak/bin/kc.sh build

# Restart Keycloak
/opt/keycloak/bin/kc.sh start

For Kubernetes deployments, build a custom Keycloak image:

FROM quay.io/keycloak/keycloak:26.0 as builder

COPY target/keycloak-ip-authenticator-1.0.0.jar /opt/keycloak/providers/

RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.0

COPY --from=builder /opt/keycloak/ /opt/keycloak/

ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

For deploying custom providers in Kubernetes, see our Kubernetes production deployment guide.

Using the Custom Authenticator

After deployment:

  1. Go to Authentication in the admin console
  2. Open your custom flow
  3. Click Add step
  4. Find “IP Allowlist” in the list
  5. Add it to your flow (usually as the first execution, before username/password)
  6. Click the gear icon to configure the allowed IPs
  7. Set the requirement to Required

Advanced Flow Patterns

Conditional MFA Based on User Role

Require OTP only for users with the admin role:

Custom Browser Flow with Username Password Form and conditional OTP required for admin role

Configure the “Condition – User Role” execution with the role name. When the user does not have the admin role, the entire sub-flow is skipped.

Step-Up Authentication

Allow users to log in with a password for general access, but require additional authentication for sensitive operations:

Step-Up Authentication Flow with Level 1 password and Level 2 conditional OTP with ACR gold

Applications request step-up authentication by including the acr_values parameter in the authorization request:

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

The resulting token includes the ACR value, which the application can check before allowing access to sensitive features. Verify the ACR claim using the JWT Token Analyzer.

Progressive Profiling

Collect additional user information over time instead of requiring everything at registration:

  1. Create a custom Required Action that prompts users for missing profile fields
  2. Add the required action to users who need to complete their profile
  3. Keycloak shows the required action form after successful authentication
// Simplified Required Action Provider
public class ProfileCompletionAction implements RequiredActionProvider {

    @Override
    public void evaluateTriggers(RequiredActionContext context) {
        UserModel user = context.getUser();
        // Check if profile is incomplete
        if (user.getFirstAttribute("department") == null
                || user.getFirstAttribute("phone") == null) {
            user.addRequiredAction("COMPLETE_PROFILE");
        }
    }

    @Override
    public void requiredActionChallenge(RequiredActionContext context) {
        // Show the profile completion form
        Response challenge = context.form()
                .setAttribute("username", context.getUser().getUsername())
                .createForm("profile-completion.ftl");
        context.challenge(challenge);
    }

    @Override
    public void processAction(RequiredActionContext context) {
        MultivaluedMap<String, String> formData =
                context.getHttpRequest().getDecodedFormParameters();

        String department = formData.getFirst("department");
        String phone = formData.getFirst("phone");

        if (department != null && !department.isEmpty()) {
            context.getUser().setSingleAttribute("department", department);
        }
        if (phone != null && !phone.isEmpty()) {
            context.getUser().setSingleAttribute("phone", phone);
        }

        context.success();
    }

    // ... other interface methods
}

Deny Access for Disabled Accounts with Custom Message

Instead of a generic error, show a specific message when a disabled user tries to log in:

@Override
public void authenticate(AuthenticationFlowContext context) {
    UserModel user = context.getUser();
    if (user != null && !user.isEnabled()) {
        Response challenge = context.form()
                .setError("accountDisabledCustomMessage")
                .createErrorPage(Response.Status.FORBIDDEN);
        context.failure(AuthenticationFlowError.USER_DISABLED, challenge);
        return;
    }
    context.success();
}

Testing Custom Flows

Before deploying custom flows to production:

  1. Test in a development environment: Use a local Keycloak instance or a Docker-based setup to test your flow.

  2. Test all paths: Verify that every execution path works, including error cases:

    • Successful login with all conditions met
    • Failed login with wrong credentials
    • Conditional flows where the condition is true and false
    • Required actions triggering correctly
  3. Test with your application: Verify that tokens issued after the custom flow contain the expected claims. Use the JWT Token Analyzer to inspect tokens.

  4. Monitor authentication events: Enable event logging and check the audit logs for any unexpected authentication failures.

Flow Management with Infrastructure as Code

For version-controlled flow management, use the Keycloak Terraform provider or Keycloak’s realm export/import:

# Export realm configuration including flows
/opt/keycloak/bin/kc.sh export 
  --dir /tmp/export 
  --realm my-realm 
  --users skip

For Terraform-based flow management, see our advanced Terraform patterns guide. For Ansible-based management, see the Ansible automation guide.

Security Considerations

Custom authentication flows directly impact your security posture:

  • Never skip credential verification: A custom authenticator that grants access without proper credential checks creates a bypass vulnerability.
  • Log authentication decisions: Use Keycloak’s event logging to track when custom authenticators grant or deny access.
  • Handle errors gracefully: Do not expose internal details in error messages shown to users.
  • Test for timing attacks: Ensure that your custom authenticator takes the same amount of time for success and failure paths.
  • Review regularly: Custom authenticators do not receive Keycloak security updates automatically. Review your SPI code when upgrading Keycloak.

Conclusion

Keycloak’s authentication flow system provides flexibility that ranges from simple drag-and-drop configuration in the admin console to fully custom Java SPIs. Start with the built-in authenticators and conditional sub-flows — they cover a wide range of requirements without custom code. When you do need a custom authenticator, the Keycloak SPI documentation gives you full control over the authentication logic while integrating seamlessly with Keycloak’s session management, event logging, and user model.

For production deployments where you want custom authentication flows without managing Keycloak infrastructure, Skycloak’s managed hosting supports custom SPI deployment. See our pricing for plans that include custom provider support, or reach out through our contact page for enterprise requirements.

Guilliano Molaire
Written by Guilliano Molaire 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.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman