Logo

Published

- 12 min read

Keycloak Custom SPIs: Authenticators, Password Policies and IdP Mappers

img of Keycloak Custom SPIs: Authenticators, Password Policies and IdP Mappers

A practical guide to extending Keycloak 26.x with Java 21 — and keeping your extensions alive through version upgrades.


Every Keycloak project hits the same wall.

You configure the realm, wire up the clients, adjust the theme — and then a requirement lands that the admin console simply cannot express.

Block passwords that contain the user’s own name. Add a confirmation step only for flagged accounts. Read a role out of a nested claim your customer’s IdP insists on sending.

At that point you stop configuring Keycloak and start extending it.

This guide builds three real extensions — an authenticator, a password policy provider, and an identity provider mapper — then covers the part most tutorials skip: keeping them working when Keycloak upgrades.

All code is on GitHub: keycloak-spi-examples

Prerequisites

  • Java 21 and Maven 3.9+
  • Docker, for running Keycloak locally
  • Keycloak 26.x
  • You have configured a realm before — this guide assumes the basics

When config isn’t enough

Before writing Java, rule out the cheaper options.

An extension is code you own forever, including through every upgrade.

RequirementReach forWrite an SPI?
Change login page look and wordingCustom themeNo
Add or rename a token claimBuilt-in protocol mapperNo
Standard password rule (length, digits, reuse)Built-in password policiesNo
Route users through different login stepsAuthentication flow with built-in executionsNo
Map a flat claim from an external IdPBuilt-in IdP mapperNo
Validate a password against your own business rulesPassword Policy SPIYes
Add a login step with custom logic or an external callAuthenticator SPIYes
Map a nested, array or conditional claimIdP Mapper SPIYes
React to login/registration events (audit, provisioning)Event Listener SPIYes

The rule I use: if a built-in feature gets you 80% of the way, bend the requirement instead of writing an SPI.

Every extension is a compatibility liability at upgrade time. Earn it.


How the SPI model actually works

Keycloak is built almost entirely out of swappable providers. Your code plugs into the same mechanism the server uses internally.

Three pieces, always:

  • Provider — the class that does the work. Created per request, never share state in it.
  • ProviderFactory — created once at startup. Holds config and produces providers.
  • Service file — a plain text file under META-INF/services that tells Keycloak your factory exists.

You package these into a JAR, drop it into providers/, and run kc.sh build.

Diagram showing the Keycloak SPI architecture: your JAR flows through providers/, kc.sh build, the augmented server image, and a runtime request into your Factory and Provider classes.

One thing that trips up everyone coming from WildFly-era Keycloak: the Quarkus distribution requires a build step. Dropping a JAR into the folder is not enough. If you skip kc.sh build, your provider silently does not exist.


Project setup

A minimal pom.xml. Note every Keycloak dependency is provided — the server supplies them at runtime, and shipping them inside your JAR causes classloader conflicts.

   <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <keycloak.version>26.0.7</keycloak.version>
</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>
</dependencies>

keycloak-server-spi-private is exactly what its name says. Classes in it can change between minor versions without notice. Use it when you must, and list every usage somewhere you will look before an upgrade.

Pin keycloak.version to the exact server version you run. Not a range, not LATEST. This single property is what makes upgrades a controlled exercise later.

Run it locally with Docker Compose:

   services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.0.7
    command: ['start-dev', '--log-level=INFO,com.onloadcode.keycloak:DEBUG']
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
    ports:
      - '8080:8080'
    volumes:
      - ./target/keycloak-spi-examples.jar:/opt/keycloak/providers/spi-examples.jar

The --log-level flag adds a DEBUG category for your own package only. You will want this in about ten minutes.

Terminal screenshot showing mvn package output followed by Keycloak container startup logs confirming the custom provider is registered.

Extension 1: a custom Authenticator

The requirement: accounts carrying a requiresReview attribute must confirm an extra screen before finishing login. Everyone else logs in normally.

An authenticator has two entry points:

  • authenticate() — called when the flow reaches your step. Either pass through, or render a challenge.
  • action() — called when the user submits the form you rendered.

Start with the provider:

   public class ReviewConfirmAuthenticator implements Authenticator {

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

        if (!Boolean.parseBoolean(user.getFirstAttribute("requiresReview"))) {
            context.success();   // not applicable — move on
            return;
        }

        Response challenge = context.form()
                .createForm("review-confirm.ftl");
        context.challenge(challenge);
    }

Then the submission handler:

       @Override
    public void action(AuthenticationFlowContext context) {
        MultivaluedMap<String, String> form =
                context.getHttpRequest().getDecodedFormParameters();

        if (!"true".equals(form.getFirst("accepted"))) {
            context.failureChallenge(
                    AuthenticationFlowError.INVALID_CREDENTIALS,
                    context.form()
                           .setError("reviewNotAccepted")
                           .createForm("review-confirm.ftl"));
            return;
        }

        context.getUser().removeAttribute("requiresReview");
        context.success();
    }

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

    @Override
    public boolean configuredFor(KeycloakSession s, RealmModel r, UserModel u) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession s, RealmModel r, UserModel u) { }

    @Override
    public void close() { }
}

Never call context.failure() for a user mistake. It aborts the whole flow and shows a generic error page. failureChallenge() re-renders your form with a message, which is what users actually need.

The factory is boilerplate, but two methods matter: getId() is the string Keycloak stores in the realm config, and getRequirementChoices() controls which REQUIRED/ALTERNATIVE/DISABLED options the admin console offers.

   public class ReviewConfirmAuthenticatorFactory implements AuthenticatorFactory {

    public static final String ID = "review-confirm-authenticator";

    private static final AuthenticationExecutionModel.Requirement[] CHOICES = {
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.DISABLED
    };

    @Override public String getId() { return ID; }
    @Override public String getDisplayType() { return "Review Confirmation"; }
    @Override public String getReferenceCategory() { return null; }
    @Override public boolean isConfigurable() { return false; }
    @Override public boolean isUserSetupAllowed() { return false; }
    @Override public String getHelpText() {
        return "Requires flagged users to confirm a review screen.";
    }

    @Override
    public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
        return CHOICES;
    }

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

    @Override public List<ProviderConfigProperty> getConfigProperties() {
        return List.of();
    }
    @Override public void init(Config.Scope config) { }
    @Override public void postInit(KeycloakSessionFactory factory) { }
    @Override public void close() { }
}

getId() is permanent. It is written into every realm that uses your authenticator. Renaming it later orphans the execution in existing realms and breaks their login flow.

Register it:

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

with a single line containing the fully-qualified factory class name.

To use it, copy the built-in browser flow (you cannot edit built-in flows), add your execution after the password step, and bind the copy to the realm.

Keycloak admin console authentication flow view showing the Review Confirmation step added as REQUIRED, with the copied flow bound as the browser flow. Sequence diagram of the custom authenticator flow: browser to Keycloak, authenticate() renders a challenge, the user submits, and action() returns success or loops back with a failureChallenge.

Extension 2: a custom Password Policy Provider

The requirement: reject passwords containing the username, the email local-part, or the first/last name.

Built-in policies cover length, digits, case, reuse and Have-I-Been-Pwned integration. They do not cover “must not contain the user’s own identity”, which is a common audit finding.

   public class NoIdentityInPasswordProvider implements PasswordPolicyProvider {

    private final KeycloakSession session;

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

    @Override
    public PolicyError validate(RealmModel realm, UserModel user, String password) {
        Stream<String> identityParts = Stream.of(
                user.getUsername(),
                localPart(user.getEmail()),
                user.getFirstName(),
                user.getLastName());

        String lower = password.toLowerCase(Locale.ROOT);

        boolean hit = identityParts
                .filter(Objects::nonNull)
                .map(s -> s.toLowerCase(Locale.ROOT))
                .filter(s -> s.length() >= 3)
                .anyMatch(lower::contains);

        return hit ? new PolicyError("invalidPasswordIdentityMessage") : null;
    }

The second overload is called during registration, before a UserModel exists:

       @Override
    public PolicyError validate(String username, String password) {
        if (username != null && username.length() >= 3
                && password.toLowerCase(Locale.ROOT)
                           .contains(username.toLowerCase(Locale.ROOT))) {
            return new PolicyError("invalidPasswordIdentityMessage");
        }
        return null;
    }

    @Override
    public Object parseConfig(String value) {
        return null;   // this policy takes no configuration
    }

    @Override
    public void close() { }

    private static String localPart(String email) {
        return email == null ? null : email.split("@")[0];
    }
}

Returning null means the password passed. Returning a PolicyError means it failed. It reads backwards the first time — get it wrong and you have a policy that accepts everything, silently.

The PolicyError argument is a message key. Add it to a theme-resources/messages bundle in your provider JAR — Keycloak merges it into whichever login theme is active, no custom theme required — or users see a raw key on screen:

   # src/main/resources/theme-resources/messages/messages_en.properties
invalidPasswordIdentityMessage=Password must not contain your name, username or email.

The factory declares the policy so it appears in the realm’s password policy dropdown:

   public class NoIdentityInPasswordProviderFactory
        implements PasswordPolicyProviderFactory {

    public static final String ID = "noIdentityInPassword";

    @Override public String getId() { return ID; }
    @Override public String getDisplayName() { return "Not Containing Identity"; }
    @Override public String getConfigType() { return null; }
    @Override public String getDefaultConfigValue() { return null; }
    @Override public boolean isMultiplSupported() { return false; }

    @Override
    public PasswordPolicyProvider create(KeycloakSession session) {
        return new NoIdentityInPasswordProvider(session);
    }

    @Override public void init(Config.Scope config) { }
    @Override public void postInit(KeycloakSessionFactory factory) { }
    @Override public void close() { }
}

Yes, isMultiplSupported is spelled that way in the Keycloak codebase. Leave the typo alone — it is the interface method.

Service file:

   META-INF/services/org.keycloak.policy.PasswordPolicyProviderFactory

Password policies are pure functions, which makes them the easiest SPI to unit test:

   @Test
void rejectsPasswordContainingUsername() {
    UserModel user = mock(UserModel.class);
    when(user.getUsername()).thenReturn("maduka");

    PolicyError error = provider.validate(realm, user, "Maduka2026!");

    assertThat(error).isNotNull();
}

Extension 3: an Identity Provider Mapper

The requirement: an enterprise IdP sends roles inside a nested array — resource.access.fleet.roles — and you need them as realm roles on the brokered user.

Built-in mappers handle flat claims. Nested paths and arrays need code.

   public class NestedClaimToRoleMapper extends AbstractIdentityProviderMapper {

    public static final String ID = "nested-claim-to-role-mapper";
    private static final String CLAIM_PATH = "claim.path";
    private static final String ROLE_PREFIX = "role.prefix";

    @Override public String getId() { return ID; }
    @Override public String getDisplayType() { return "Nested Claim To Role"; }
    @Override public String getDisplayCategory() { return "Role Importer"; }
    @Override public String getHelpText() {
        return "Maps values from a nested claim path to realm roles.";
    }

    @Override
    public String[] getCompatibleProviders() {
        return new String[]{ "oidc", "keycloak-oidc" };
    }

Two lifecycle methods, and both must be implemented. importNewUser runs on first login; updateBrokeredUser runs on every subsequent login.

       @Override
    public void importNewUser(KeycloakSession session, RealmModel realm,
                              UserModel user, IdentityProviderMapperModel mapper,
                              BrokeredIdentityContext context) {
        applyRoles(realm, user, mapper, context);
    }

    @Override
    public void updateBrokeredUser(KeycloakSession session, RealmModel realm,
                                   UserModel user, IdentityProviderMapperModel mapper,
                                   BrokeredIdentityContext context) {
        applyRoles(realm, user, mapper, context);
    }

Implementing only importNewUser is the single most common mapper bug. Roles apply on day one, then never update again. Most teams discover it months later, when someone’s access should have been revoked and wasn’t.

The extraction itself, with null-safety at every hop:

       private void applyRoles(RealmModel realm, UserModel user,
                            IdentityProviderMapperModel mapper,
                            BrokeredIdentityContext context) {

        String path = mapper.getConfig().get(CLAIM_PATH);
        String prefix = mapper.getConfig().getOrDefault(ROLE_PREFIX, "");

        Object node = context.getContextData().get("UNTRUSTED_ID_TOKEN_CLAIMS");

        for (String segment : path.split("\\.")) {
            if (!(node instanceof Map<?, ?> map)) return;   // path broken — stop
            node = map.get(segment);
        }

        if (node == null) return;

        toStream(node)
            .map(v -> realm.getRole(prefix + v))
            .filter(Objects::nonNull)
            .forEach(user::grantRole);
    }

Two more pieces applyRoles leans on: getConfigProperties() describes the claim.path and role.prefix keys so they show up in the admin console, and toStream() normalises the claim value — a single string or a JSON array — into a stream either way.

       private static final List<ProviderConfigProperty> CONFIG_PROPERTIES = List.of(
            new ProviderConfigProperty(
                    CLAIM_PATH,
                    "Claim path",
                    "Dot-separated path to the nested claim, e.g. resource.access.fleet.roles",
                    ProviderConfigProperty.STRING_TYPE,
                    null),
            new ProviderConfigProperty(
                    ROLE_PREFIX,
                    "Role prefix",
                    "Prefix prepended to each claim value before looking up the realm role.",
                    ProviderConfigProperty.STRING_TYPE,
                    ""));

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return CONFIG_PROPERTIES;
    }
    private static Stream<?> toStream(Object node) {
        if (node instanceof List<?> list) {
            return list.stream();
        }
        return Stream.of(node);
    }

Three defensive choices worth calling out:

  • A missing claim returns quietly. An IdP omitting an optional claim is normal, not an error. Throwing here breaks login for every user of that IdP.
  • Unknown roles are filtered out. realm.getRole() returns null for roles that don’t exist. Granting a null role throws; skipping it doesn’t.
  • The path walk checks the type at every level. A claim that is a string where you expected an object is a ClassCastException waiting for production.
Flow diagram of the identity provider mapper: an external IdP token's nested claim path is walked to matched realm roles granted to the user, including the claim-missing and role-not-found branches.

Service file:

   META-INF/services/org.keycloak.broker.provider.IdentityProviderMapper

Making extensions survive upgrades

This is the section that matters six months from now.

Keycloak ships several releases a year. Extensions break not because the feature changed, but because an interface underneath it did.

Here is what actually breaks, and how to catch each one early.

What changesTypical symptomHow you catch it
Interface method added or removedCompile errorBump the version property, run mvn compile
Class moved between packagesNoClassDefFoundError at startupStartup smoke test in CI
spi-private internals refactoredCompiles, fails at runtimeIntegration test that exercises the code path
FreeMarker template or theme variable renamedBlank or broken login pageScreenshot-level manual check of each custom form
Jakarta / RESTEasy version shiftNoSuchMethodErrorRecompile against the new BOM
Default flow or execution renamedRealm import failsImport your realm export into the new version
A CVE fix tightens validationPreviously accepted input now rejectedRead the release notes properly, once

The upgrade sequence I follow

  1. Bump keycloak.version in the POM. Change nothing else.
  2. mvn clean verify. Fix compile breaks first — they are the honest ones.
  3. Run the integration tests against the new server image.
  4. Import a production realm export into a throwaway instance of the new version.
  5. Manually exercise every custom login screen. Templates fail silently; only eyes catch it.
  6. Read the release notes for your major version, specifically the migration and deprecation sections.

Step 3 needs real tests, not mocks. Testcontainers makes this straightforward:

   @Testcontainers
class AuthenticatorIT {

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

    @Test
    void customAuthenticatorIsRegistered() {
        var info = keycloak.getKeycloakAdminClient()
                           .serverInfo().getInfo();

        assertThat(info.getProviders())
            .containsKey("authenticator");
    }
}

One more habit worth building: keep a short EXTENSIONS.md in the repo listing every spi-private class you touch, every FreeMarker template you ship, and every provider ID written into realm config.

Before an upgrade, that file is your checklist. Without it, you are re-discovering your own codebase under time pressure.

Test the downgrade too. If a realm import runs a schema migration, “rollback” is no longer a matter of swapping the container image. Know this before the maintenance window, not during it.


Deploying and verifying

Three checks before you call it done.

One — the provider is registered. Hit the admin REST endpoint:

   curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/admin/serverinfo | jq '.providers.authenticator'

Your getId() value should be in the list. If it isn’t, the build step didn’t pick up the JAR.

Two — the flow is bound. A registered authenticator that isn’t in a bound flow does nothing at all. Check the realm’s Authentication → Flows → bindings, not just the execution list.

Three — the logs say something. Your DEBUG category from the Compose file should show your provider executing on a real login. Silence means your code isn’t running.


Recap

  • Exhaust config, themes and built-in mappers first. Every SPI is a permanent upgrade cost.
  • Pin the Keycloak version in one property. It turns upgrades into a compile step instead of an investigation.
  • failureChallenge() for user errors, failure() for flow aborts. They are not interchangeable.
  • Implement both importNewUser and updateBrokeredUser. Otherwise your mappings freeze after first login.
  • Test with Testcontainers, not mocks. SPI bugs live in the wiring, and mocks never touch the wiring.
  • Keep an EXTENSIONS.md. Your future self, mid-upgrade, will thank you.

Full working code, tests and Compose setup: keycloak-spi-examples


Next in this series: Keycloak Realm Configuration as Code — how to stop clicking in the admin console, and verify two regions are byte-identical.

If this saved you an afternoon, follow along — I write about Keycloak, OAuth2 and identity architecture every few weeks.