Keycloak’s SPI mechanism lets you inject custom code into its authentication flows, but it’s not about adding new ways to authenticate; it’s about modifying existing ones with custom logic.

Let’s see a custom authentication SPI in action. Imagine you want to add a step to your login flow that checks if a user’s account is "active" in an external system before Keycloak even considers their password valid.

Here’s a simplified AuthenticatorFactory and Authenticator implementation.

// AuthenticatorFactory.java
package com.example.keycloak.authenticators;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.DisplayType;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.services.messages.Messages;

import java.util.List;

public class ExternalAccountCheckAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "external-account-check";
    private static final ExternalAccountCheckAuthenticator SINGLETON = new ExternalAccountCheckAuthenticator();

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

    @Override
    public String getDisplayType() {
        return "External Account Check";
    }

    @Override
    public String getHelpText() {
        return "Checks if the user account is active in an external system.";
    }

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

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

    @Override
    public void init(org.keycloak.Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSession keycloakSession) {
    }

    @Override
    public void close() {
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return ProviderConfigurationBuilder.create()
                .property()
                .name("external.api.url")
                .label("External API URL")
                .helpText("URL of the external API to check account status.")
                .type(ProviderConfigProperty.STRING_TYPE)
                .defaultValue("http://localhost:8081/api/users/")
                .add()
                .property()
                .name("external.api.timeout")
                .label("External API Timeout (ms)")
                .helpText("Timeout for the external API call in milliseconds.")
                .type(ProviderConfigProperty.INT_TYPE)
                .defaultValue(5000)
                .add()
                .build();
    }

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

    @Override
    public String getReferenceCategory() {
        return null;
    }

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

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

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

    @Override
    public String getAutodetectKey() {
        return null;
    }

    @Override
    public DisplayType getDisplayType(KeycloakSession session) {
        return DisplayType.FORM;
    }

    @Override
    public String getHelpText(KeycloakSession session) {
        return getHelpText();
    }

    @Override
    public String getLabel(KeycloakSession session) {
        return getDisplayType();
    }
}
// Authenticator.java
package com.example.keycloak.authenticators;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.services.messages.Messages;

import javax.ws.rs.core.Response;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class ExternalAccountCheckAuthenticator implements Authenticator {

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        MultivaluedHashMap<String, String> config = context.getAuthenticatorConfig().getConfig();
        String apiUrl = config.getFirst("external.api.url");
        int timeout = Integer.parseInt(config.getFirst("external.api.timeout"));
        String username = context.getUser().getUsername();

        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(timeout))
                .build();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(apiUrl + username))
                .timeout(Duration.ofMillis(timeout))
                .GET()
                .build();

        try {
            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() == 200) {
                // Assuming a 200 OK means the account is active
                context.success();
            } else {
                // Any other status code means inactive or error
                context.failureChallenge(AuthenticationFlowError.INVALID_USER,
                        context.form().setError("Account is not active in external system.").createErrorPage(Response.Status.UNAUTHORIZED));
            }
        } catch (IOException | InterruptedException e) {
            // Handle connection errors, timeouts, etc.
            context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
                    context.form().setError("Error checking account status.").createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
        }
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // This authenticator doesn't require user interaction, so we just succeed.
        context.success();
    }

    @Override
    public boolean requiresUser() {
        return true; // We need the user context to get their username
    }

    @Override
    public boolean configuredFor(KeycloakSession session, org.keycloak.models.RealmModel realm, UserModel user) {
        return true; // Always considered configured
    }

    @Override
    public void setRequiredActions(KeycloakSession session, org.keycloak.models.RealmModel realm, UserModel user) {
        // No required actions for this authenticator
    }

    @Override
    public void close() {
    }
}

To use this, you’d package it as a JAR, deploy it to Keycloak’s providers directory, and then in the Keycloak Admin Console, under "Authentication" -> "Flows", you’d add this as a new step in a custom authentication flow. You’d configure the external.api.url and external.api.timeout in the "Configure" section for this new authentication step.

The core problem this solves is extending Keycloak’s built-in authentication mechanisms without modifying Keycloak’s core code. You’re essentially plugging into the authentication pipeline. Keycloak’s SPIs are designed around the concept of "providers." An AuthenticatorFactory is a provider that knows how to create Authenticator instances. The AuthenticatorFactory also defines configuration properties for the authenticator it creates, allowing administrators to customize its behavior without touching code.

When a user attempts to log in, Keycloak executes the configured authentication flow. If your custom authenticator is part of that flow, Keycloak will call its authenticate method. This method receives an AuthenticationFlowContext object, which is your window into the current authentication attempt. You can access user information, retrieve configuration, and crucially, decide whether the authentication should success(), failureChallenge() (meaning an error occurred and you want to present a specific error page or prompt), or challenge() (meaning you need more user input, like a password or OTP, which isn’t the case here).

The action method is called if the authenticate method returns challenge or required and the user needs to provide additional input. In this specific example, we don’t need further user interaction after the external check, so action simply calls context.success().

The requiresUser() method tells Keycloak whether this authenticator needs access to the UserModel object. Here, we need it to get the username for the external API call. configuredFor() and setRequiredActions() are for more advanced scenarios, like conditionally enabling or disabling authenticators based on user attributes or setting up user-facing required actions (like "set password").

The most surprising thing is that you don’t directly add a new authentication method (like a completely new social login provider) with this SPI. Instead, you’re inserting logic into existing flows. You can’t just create a "Biometric Login" SPI that is biometric login. You’d create an SPI that enhances an existing flow (e.g., the browser flow) by adding a step that invokes your biometric check after username/password but before success. The SPI model is about extending and customizing, not replacing entire authentication paradigms from scratch.

When you deploy your JAR, Keycloak scans for classes implementing ProviderFactory (and AuthenticatorFactory is a sub-interface). It then makes these providers available for selection within the Admin Console’s authentication flow configuration. The Id returned by getId() is how Keycloak internally references your provider.

The next hurdle is managing the flow logic itself – understanding how different authentication steps interact, how to create custom flows, and how to handle conditional execution based on user attributes or realm settings.

Want structured learning?

Take the full Keycloak course →