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.