필사 모드: Keycloak SPI Extension Development — From Custom Authenticators to EventListeners
EnglishIntroduction
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.
<!-- For the Kafka EventListener (compile scope: must ship in the JAR) -->
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;
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;
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;
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;
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;
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;
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;
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.
| Requirement | Mechanism |
| --- | --- |
| Change logo/colors/CSS | Theme (FreeMarker templates + CSS) |
| Add a field to the login form + validation logic | Authenticator SPI + theme template |
| Add localized messages | Theme messages properties |
| Add/modify authentication steps themselves | Authenticator SPI |
| Customize registration fields | User 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;
@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
- [Keycloak Server Development Guide](https://www.keycloak.org/docs/latest/server_development/index.html)
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)
- [Keycloak 26.6.0 Release Announcement](https://www.keycloak.org/2026/04/keycloak-2660-released)
- [Keycloak Server Configuration Guide (providers)](https://www.keycloak.org/server/configuration-provider)
- [testcontainers-keycloak (GitHub)](https://github.com/dasniko/testcontainers-keycloak)
- [Testcontainers Documentation](https://java.testcontainers.org/)
- [Apache Kafka Documentation](https://kafka.apache.org/documentation/)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OAuth 2.0 (RFC 6749)](https://datatracker.ietf.org/doc/html/rfc6749)
- [JWT (RFC 7519)](https://datatracker.ietf.org/doc/html/rfc7519)
- [Keycloak GitHub Repository](https://github.com/keycloak/keycloak)
현재 단락 (1/430)
The real power of Keycloak is not "lots of built-in features" but "everything can be swapped out." A...