Skip to content
Published on

Keycloak SPI Extension Development — From Custom Authenticators to EventListeners

Authors

Introduction

The real power of Keycloak is not "lots of built-in features" but "everything can be swapped out." Almost every internal behavior — authentication steps, event handling, user storage, even REST APIs — is abstracted behind an SPI (Service Provider Interface), letting you fill the gaps the standard features leave with Java code.

As of 2026, demand for SPI development is actually growing. Integrating with internal legacy systems (logging in with users from a legacy DB), meeting country-specific regulations (SMS identity verification), streaming audit logs in real time (Kafka), and automating identity issuance for AI agents are requirements that standard configuration alone cannot solve. This article covers the following.

  • The SPI architecture: the relationship between Provider and ProviderFactory
  • Setting up the development environment (Java 17+, Maven)
  • A complete custom Authenticator implementation — OTP via an internal SMS gateway
  • Forcing phone number registration with a RequiredAction
  • Streaming audit logs to Kafka with an EventListener
  • Building custom REST endpoints with RealmResourceProvider
  • Integrating a legacy DB with the User Storage SPI
  • Deployment, Testcontainers integration tests, and managing upgrade compatibility

The SPI Architecture — Provider and ProviderFactory

Every extension point in Keycloak follows the same three-layer pattern.

+------------------------------------------------------------+
|  Spi (extension point definition)                          |
|   e.g., AuthenticatorSpi, EventListenerSpi                  |
|                                                            |
|  +------------------------------------------------------+  |
|  |  ProviderFactory (singleton, one per server lifetime) |  |
|  |   - init(Config.Scope): load configuration            |  |
|  |   - create(KeycloakSession): create Provider instance |  |
|  |   - getId(): unique identifier                        |  |
|  |                                                      |  |
|  |  +------------------------------------------------+  |  |
|  |  |  Provider (created per request/transaction)    |  |  |
|  |  |   - the actual business logic                  |  |  |
|  |  |   - close(): cleanup at end of request         |  |  |
|  |  +------------------------------------------------+  |  |
|  +------------------------------------------------------+  |
+------------------------------------------------------------+

The key rules are as follows.

  • The ProviderFactory is a singleton created once at server startup. Do heavy initialization (connection pools, HTTP clients) here.
  • The Provider is created per KeycloakSession (roughly per HTTP request). It should be stateless or hold only session-scoped state.
  • Implementation registration follows the Java ServiceLoader convention: create a file named after the fully qualified interface in the META-INF/services directory and list the implementation class.

Setting Up the Development Environment

Keycloak 26.x requires Java 17 or later and also runs on 21. Maven dependencies use the provided scope, so that classes the server already ships are not duplicated in your JAR.

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example.iam</groupId>
  <artifactId>keycloak-extensions</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <keycloak.version>26.6.2</keycloak.version>
    <maven.compiler.release>17</maven.compiler.release>
  </properties>

  <dependencies>
    <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>
    <!-- For the Kafka EventListener (compile scope: must ship in the JAR) -->
    <dependency>
      <groupId>org.apache.kafka</groupId>
      <artifactId>kafka-clients</artifactId>
      <version>3.9.0</version>
    </dependency>
  </dependencies>
</project>

Keep in mind that interfaces in spi-private can change even in minor versions. We return to this in the compatibility management section below.

A Custom Authenticator — OTP via an Internal SMS Gateway

This is the most in-demand extension. Let's build an Authenticator that sends a 6-digit code through an internal SMS gateway after password authentication and verifies it.

1. Implementing the Authenticator

package com.example.iam.sms;

import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import java.security.SecureRandom;

public class SmsOtpAuthenticator implements Authenticator {

    private static final String OTP_NOTE = "sms-otp-code";
    private static final String PHONE_ATTR = "phoneNumber";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        UserModel user = context.getUser();
        String phone = user.getFirstAttribute(PHONE_ATTR);

        if (phone == null || phone.isBlank()) {
            // No phone number registered -> route to a RequiredAction
            user.addRequiredAction("register-phone-number");
            context.attempted();
            return;
        }

        String code = generateCode();
        // auth notes are temporary storage valid only for this auth session
        context.getAuthenticationSession().setAuthNote(OTP_NOTE, code);

        SmsGateway gateway = SmsGateway.fromConfig(
            context.getAuthenticatorConfig());
        gateway.send(phone, "Verification code: " + code + " (enter within 5 min)");

        context.challenge(
            context.form()
                   .setAttribute("phoneHint", maskPhone(phone))
                   .createForm("sms-otp.ftl"));
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        String expected = context.getAuthenticationSession()
                                 .getAuthNote(OTP_NOTE);
        String submitted = context.getHttpRequest()
                                  .getDecodedFormParameters()
                                  .getFirst("otp");

        if (expected != null && expected.equals(submitted)) {
            context.success();
            return;
        }
        context.failureChallenge(
            AuthenticationFlowError.INVALID_CREDENTIALS,
            context.form()
                   .setError("invalidOtpCode")
                   .createForm("sms-otp.ftl"));
    }

    @Override
    public boolean requiresUser() {
        return true; // must come after password authentication
    }

    @Override
    public boolean configuredFor(KeycloakSession session,
                                 RealmModel realm, UserModel user) {
        return user.getFirstAttribute(PHONE_ATTR) != null;
    }

    @Override
    public void setRequiredActions(KeycloakSession session,
                                   RealmModel realm, UserModel user) {
        user.addRequiredAction("register-phone-number");
    }

    @Override
    public void close() { }

    private String generateCode() {
        return String.format("%06d", new SecureRandom().nextInt(1_000_000));
    }

    private String maskPhone(String phone) {
        int len = phone.length();
        return phone.substring(0, 3) + "****" + phone.substring(len - 2);
    }
}

2. Implementing the AuthenticatorFactory

package com.example.iam.sms;

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 SmsOtpAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "sms-otp-authenticator";
    private static final SmsOtpAuthenticator SINGLETON =
        new SmsOtpAuthenticator();

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

    @Override
    public String getDisplayType() { return "SMS OTP (internal gateway)"; }

    @Override
    public Authenticator create(KeycloakSession session) {
        return SINGLETON; // stateless, so the singleton can be reused
    }

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

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

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty url = new ProviderConfigProperty(
            "gateway.url", "SMS Gateway URL",
            "Internal SMS gateway endpoint",
            ProviderConfigProperty.STRING_TYPE,
            "https://sms.internal.example.com/v1/send");
        return List.of(url);
    }

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

    @Override
    public String getHelpText() {
        return "Sends a 6-digit OTP via the internal SMS gateway.";
    }

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

    @Override
    public void postInit(KeycloakSessionFactory factory) { }

    @Override
    public void close() { }
}

3. ServiceLoader registration

# File path:
# src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
com.example.iam.sms.SmsOtpAuthenticatorFactory

Afterwards, duplicate the browser flow in the admin console and add the SMS OTP execution after the Username Password Form to put it into effect. We covered the mechanics of the authentication flow engine in the architecture article, so we skip them here.

RequiredAction — Forcing Phone Number Registration

The Authenticator above adds a RequiredAction called register-phone-number when no phone number exists. A RequiredAction is "something the user must do before continuing, even though login succeeded."

package com.example.iam.sms;

import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionProvider;

public class RegisterPhoneRequiredAction implements RequiredActionProvider {

    public static final String PROVIDER_ID = "register-phone-number";

    @Override
    public void evaluateTriggers(RequiredActionContext context) {
        // Evaluate auto-trigger conditions on each login (unused here)
    }

    @Override
    public void requiredActionChallenge(RequiredActionContext context) {
        context.challenge(
            context.form().createForm("register-phone.ftl"));
    }

    @Override
    public void processAction(RequiredActionContext context) {
        String phone = context.getHttpRequest()
                              .getDecodedFormParameters()
                              .getFirst("phoneNumber");
        if (phone == null || !phone.matches("^\\+?[0-9]{10,15}$")) {
            context.challenge(
                context.form().setError("invalidPhone")
                       .createForm("register-phone.ftl"));
            return;
        }
        context.getUser().setSingleAttribute("phoneNumber", phone);
        context.success();
    }

    @Override
    public void close() { }
}

The Factory and the ServiceLoader registration (org.keycloak.authentication.RequiredActionFactory) follow the same pattern as the Authenticator, so we omit them.

EventListener — Streaming Audit Logs to Kafka

The most common security-audit requirement is "ship every login/admin event to the SIEM in real time." We implement it with the EventListener SPI.

package com.example.iam.audit;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.util.JsonSerialization;

public class KafkaEventListener implements EventListenerProvider {

    private final KafkaProducer<String, String> producer;
    private final String topic;

    public KafkaEventListener(KafkaProducer<String, String> producer,
                              String topic) {
        this.producer = producer;
        this.topic = topic;
    }

    @Override
    public void onEvent(Event event) {
        // LOGIN, LOGIN_ERROR, LOGOUT, TOKEN_REFRESH ...
        send(event.getRealmId(), toJson(event));
    }

    @Override
    public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) {
        // Change history from the admin console / Admin API
        send(adminEvent.getRealmId(), toJson(adminEvent));
    }

    private void send(String key, String value) {
        // Async send: it is critical not to block the authentication path
        producer.send(new ProducerRecord<>(topic, key, value), (md, ex) -> {
            if (ex != null) {
                // A Kafka outage must not become a login outage: log only
                org.jboss.logging.Logger
                    .getLogger(KafkaEventListener.class)
                    .errorf(ex, "audit event publish failed: key=%s", key);
            }
        });
    }

    private String toJson(Object o) {
        try {
            return JsonSerialization.writeValueAsString(o);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void close() {
        // Per-request close: the Factory owns the producer, so do not close here
    }
}

The point is to manage the KafkaProducer as a singleton in the Factory.

package com.example.iam.audit;

import org.apache.kafka.clients.producer.KafkaProducer;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;

import java.util.Properties;

public class KafkaEventListenerFactory
        implements EventListenerProviderFactory {

    private KafkaProducer<String, String> producer;
    private String topic;

    @Override
    public String getId() { return "kafka-audit"; }

    @Override
    public void init(Config.Scope config) {
        // Loaded from spi-events-listener-kafka-audit-* options
        String bootstrap = config.get("bootstrapServers",
            "kafka.internal:9092");
        topic = config.get("topic", "keycloak-audit");

        Properties props = new Properties();
        props.put("bootstrap.servers", bootstrap);
        props.put("key.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer",
            "org.apache.kafka.common.serialization.StringSerializer");
        props.put("acks", "1");
        props.put("linger.ms", "20");
        producer = new KafkaProducer<>(props);
    }

    @Override
    public EventListenerProvider create(KeycloakSession session) {
        return new KafkaEventListener(producer, topic);
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) { }

    @Override
    public void close() {
        if (producer != null) producer.close();
    }
}

Configuration values are injected via kc.sh options.

bin/kc.sh start --optimized \
  --spi-events-listener-kafka-audit-bootstrap-servers=kafka.internal:9092 \
  --spi-events-listener-kafka-audit-topic=keycloak-audit

To activate it, add kafka-audit to Event listeners on the Events tab under Realm settings in the admin console.

A Custom REST Endpoint — RealmResourceProvider

Functionality missing from the Admin API (e.g., optimized bulk user lookups, health reports in an internal format) can be exposed as a REST API under a realm subpath.

package com.example.iam.rest;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.RealmResourceProvider;

import java.util.Map;

public class StatsResourceProvider implements RealmResourceProvider {

    private final KeycloakSession session;

    public StatsResourceProvider(KeycloakSession session) {
        this.session = session;
    }

    @Override
    public Object getResource() { return this; }

    @GET
    @Path("summary")
    @Produces(MediaType.APPLICATION_JSON)
    public Map<String, Object> summary() {
        // Token validation: 401 without a Bearer token
        AuthenticationManager.AuthResult auth =
            new AppAuthManager.BearerTokenAuthenticator(session)
                .authenticate();
        if (auth == null) {
            throw new jakarta.ws.rs.NotAuthorizedException("Bearer");
        }

        long userCount = session.users()
            .getUsersCount(session.getContext().getRealm());
        return Map.of(
            "realm", session.getContext().getRealm().getName(),
            "users", userCount
        );
    }

    @Override
    public void close() { }
}

If the Factory getId is stats, the final URL of this endpoint is:

GET /realms/production/stats/summary
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Custom REST is as dangerous as it is powerful. You must implement token validation and role checks yourself — omit them and you have an unauthenticated public API.

The User Storage SPI — Integrating a Legacy DB

This extension point lets an existing user database (e.g., an old membership table) serve as a Keycloak user source without migration. The core interface is UserStorageProvider, composed with per-capability interfaces.

package com.example.iam.legacy;

import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;

public class LegacyDbStorageProvider implements UserStorageProvider,
        UserLookupProvider, CredentialInputValidator {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final LegacyUserDao dao; // JDBC access layer

    public LegacyDbStorageProvider(KeycloakSession session,
                                   ComponentModel model,
                                   LegacyUserDao dao) {
        this.session = session;
        this.model = model;
        this.dao = dao;
    }

    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        LegacyUser u = dao.findByUsername(username);
        return u == null ? null
            : new LegacyUserAdapter(session, realm, model, u);
    }

    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        String externalId = StorageId.externalId(id);
        LegacyUser u = dao.findById(externalId);
        return u == null ? null
            : new LegacyUserAdapter(session, realm, model, u);
    }

    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        LegacyUser u = dao.findByEmail(email);
        return u == null ? null
            : new LegacyUserAdapter(session, realm, model, u);
    }

    @Override
    public boolean supportsCredentialType(String type) {
        return PasswordCredentialModel.TYPE.equals(type);
    }

    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user,
                                   String type) {
        return supportsCredentialType(type);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user,
                           CredentialInput input) {
        if (!supportsCredentialType(input.getType())) return false;
        // Delegate verification of legacy hashes (bcrypt, etc.)
        return dao.verifyPassword(
            StorageId.externalId(user.getId()),
            input.getChallengeResponse());
    }

    @Override
    public void close() { }
}

Three practical tips.

  • Decide early between import mode (copying users into the Keycloak DB) and non-import mode (always querying externally). Long term, we recommend a migration path of importing and then retiring the legacy system.
  • If you re-hash with the standard Keycloak hash at the first login where the legacy password verifies (updating the credential), you get gradual migration for free.
  • Put timeouts and a circuit breaker in the DAO layer so an external DB outage does not cascade into a total login outage.

How This Differs from Theme Customization

If all you want is to change how the login screen looks, use themes, not SPIs.

RequirementMechanism
Change logo/colors/CSSTheme (FreeMarker templates + CSS)
Add a field to the login form + validation logicAuthenticator SPI + theme template
Add localized messagesTheme messages properties
Add/modify authentication steps themselvesAuthenticator SPI
Customize registration fieldsUser Profile configuration (often no code needed)

As with sms-otp.ftl in the SMS OTP example above, SPIs and themes are often used together. Templates live under the login folder of the theme directory.

Deployment — The providers Directory and Build

Place the extension JAR in the providers directory and re-run the build.

FROM quay.io/keycloak/keycloak:26.6 AS builder
COPY target/keycloak-extensions-1.0.0.jar /opt/keycloak/providers/
# External dependencies like kafka-clients too (unnecessary with a shaded JAR)
COPY target/dependency/kafka-clients-3.9.0.jar /opt/keycloak/providers/
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:26.6
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"]

Verify via the startup logs or Provider info in the admin console.

# Check registered providers
bin/kc.sh show-config
kubectl logs keycloak-0 | grep -i "sms-otp"

Integration Testing with Testcontainers

Unit tests alone are not enough for SPIs. Integration tests that load the JAR into a real Keycloak container are essential. We use the community-standard testcontainers-keycloak by dasniko.

package com.example.iam;

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.keycloak.admin.client.Keycloak;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;

@Testcontainers
class SmsOtpAuthenticatorIT {

    @Container
    static KeycloakContainer keycloak =
        new KeycloakContainer("quay.io/keycloak/keycloak:26.6")
            .withProviderClassesFrom("target/classes")
            .withRealmImportFile("test-realm.json");

    @Test
    void authenticatorFactoryIsRegistered() {
        Keycloak admin = keycloak.getKeycloakAdminClient();
        var info = admin.serverInfo().getInfo();

        var authenticators = info.getProviders()
            .get("authenticator").getProviders();

        assertThat(authenticators)
            .containsKey("sms-otp-authenticator");
    }

    @Test
    void loginWithoutPhoneTriggersRequiredAction() {
        // Pre-define a flow containing sms-otp and a user without a
        // phone number in test-realm.json, then simulate the OAuth
        // code flow with RestAssured
        // (omitted for space — see the full example in the repository)
    }
}

Running a Keycloak version matrix in CI (26.5, 26.6, etc.) catches upgrade-compatibility regressions early.

Managing Upgrade Compatibility

The biggest risk in SPI development is internal API changes during Keycloak upgrades. Practical rules of thumb:

  1. Depend on the public SPI (keycloak-server-spi) and minimize use of the private SPI. Private interfaces can break even in minor versions.
  2. Pin keycloak.version in the pom to the server version, and handle server upgrades and extension builds in one pipeline.
  3. Always read the deprecation section of the release notes before upgrading. Changes like the legacy console theme removal in 26 and the jakarta namespace transition (22) have historically broken extension code.
  4. Keep a canary job that runs the Testcontainers ITs against the next version image ahead of time.
  5. Do not pack too much responsibility into the extension JAR. Keep logic like Kafka publishing thin where possible; enrich and transform downstream.
Upgrade procedure (recommended)
1. Review the next version release notes / deprecations
2. Bump keycloak.version and compile -> detect API breakage
3. Run the Testcontainers ITs against the new image
4. Deploy the new image + extension JAR to staging
5. Apply to production: rolling for 26.6+ patch versions, recreate otherwise

Conclusion

SPIs are what turn Keycloak from a "product" into a "platform." Master the single Provider/ProviderFactory pattern and you can extend Authenticators, EventListeners, REST resources, and User Storage with the same structure. But remember that extension code runs in the middle of the authentication path — isolating external system failures (timeouts, async) and managing version compatibility matter as much as writing the code. Build on the examples in this article, and always go to production accompanied by integration tests.

References