Most teams that need an identity operation Keycloak doesn’t ship — “resolve this user’s effective entitlements in one call”, “let a user accept updated terms”, “expose a tenant-scoped lookup” — build a separate microservice. That service then has to validate Keycloak’s tokens, hold a service-account credential to call the Admin REST API, reconstruct realm and group logic over HTTP, and get deployed, scaled, and monitored as its own thing. It’s a lot of moving parts to add one endpoint.

You often don’t need any of it. Keycloak’s SPI framework has an inobvious extension point — the RealmResourceProvider — that lets you mount your own JAX-RS endpoints inside the server. Your code runs in-process with direct access to the KeycloakSession, the same JPA transaction Keycloak uses, its caches, its event pipeline, and its bearer-token authentication. No sidecar, no second credential, no network hop.

This post is a build-and-ship walkthrough of that SPI, targeting Keycloak 24. It assumes you already run Keycloak on Kubernetes as an optimized custom image (if not, start with the deployment blueprint); the payoff here is that a provider is just another layer in that same image.

Which SPI, and why this one

Keycloak exposes dozens of SPIs. The ones that get all the blog posts are the obvious extension points:

  • EventListenerProvider — react to logins and admin changes.
  • Authenticator — add a step to a browser flow.
  • ProtocolMapper — put a custom claim in a token.
  • UserStorageProvider — federate an external user store.

Reach past those and you find RealmResourceProvider. It doesn’t hook an existing flow — it adds surface area. That makes it the right tool for a specific, common shape of problem: “I need an HTTP operation that talks to identity data, and the stock APIs don’t have it.” Instead of a sidecar that re-implements half of Keycloak’s model over the Admin API, you write the handler where the data already lives.

What you give up is process isolation — a bug in your resource runs in the Keycloak JVM. So the fit is read-mostly, identity-adjacent, low-blast-radius endpoints, not a general-purpose application backend. Within that box it’s a huge simplification.

What we’ll build

A concrete example: an entitlements endpoint. Two operations, both scoped to the calling user’s own access token:

  • GET /realms/{realm}/entitlements — the user’s effective realm roles, resolved through composites and group membership, in one call. The stock endpoints make you stitch this from /userinfo, role mappings, and group lookups.
  • POST /realms/{realm}/entitlements/terms — record that the user accepted the current terms version, writing an attribute and firing a Keycloak event so it lands in the audit log.

The two interfaces

A REST extension is two small classes. The factory is the SPI entry point Keycloak discovers at boot; its getId() becomes the path segment under /realms/{realm}/.

package net.zimakov.keycloak.entitlements;

import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;

public class EntitlementsResourceProviderFactory
        implements RealmResourceProviderFactory {

    // -> /realms/{realm}/entitlements
    public static final String ID = "entitlements";

    @Override
    public RealmResourceProvider create(KeycloakSession session) {
        return new EntitlementsResourceProvider(session);
    }

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

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

create() is called per request — Keycloak hands you a fresh, live KeycloakSession bound to that request’s transaction. Never cache the session in a field on the factory; it’s request-scoped. The provider just returns the JAX-RS object:

package net.zimakov.keycloak.entitlements;

import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resource.RealmResourceProvider;

public class EntitlementsResourceProvider implements RealmResourceProvider {

    private final KeycloakSession session;

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

    @Override
    public Object getResource() {
        return new EntitlementsResource(session);
    }

    @Override
    public void close() { }
}

The JAX-RS resource

The object returned from getResource() is a plain JAX-RS resource. Keycloak 24 runs on Quarkus, and the SPI moved to Jakarta EE namespaces back in Keycloak 20 — so the imports are jakarta.ws.rs.*, not javax.ws.rs.*. Mixing those up is the single most common “why won’t my endpoint register” mistake.

package net.zimakov.keycloak.entitlements;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.RoleUtils;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;

import java.util.List;

public class EntitlementsResource {

    private final KeycloakSession session;

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

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public EntitlementsRepresentation get() {
        AuthResult auth = authenticate();
        UserModel user = auth.getUser();

        // Roles resolved through composites AND group membership — the
        // "one call" the stock endpoints don't give you.
        List<String> roles = RoleUtils.getDeepUserRoleMappings(user).stream()
                .filter(r -> r.getContainer() instanceof RealmModel)
                .map(r -> r.getName())
                .sorted()
                .toList();

        return new EntitlementsRepresentation(user.getUsername(), roles);
    }

    private AuthResult authenticate() {
        AuthResult auth = new AppAuthManager.BearerTokenAuthenticator(session)
                .authenticate();
        if (auth == null) {
            throw new NotAuthorizedException("Bearer token required");
        }
        return auth;
    }
}

EntitlementsRepresentation is a trivial POJO (or record) — Keycloak’s JSON provider serializes it. RoleUtils.getDeepUserRoleMappings is exactly the internal helper Keycloak uses itself, which is the whole point: you’re reusing the server’s model logic, not re-deriving it over HTTP.

Authentication is yours to enforce

This is the part people get wrong, so it’s worth being blunt:

A custom endpoint bypasses nothing and protects nothing on its own. getResource() returns an object; JAX-RS calls it for anyone who hits the path. There is no implicit auth. If you don’t authenticate, you’ve published an unauthenticated endpoint on your identity server.

The AppAuthManager.BearerTokenAuthenticator shown above is how you plug into Keycloak’s own token validation — it verifies the Authorization: Bearer access token against the current realm (signature, expiry, issuer, active session) and returns the UserModel, the session, and the parsed AccessToken. A null result means no valid token.

For anything privileged, authentication isn’t enough — check authorization off the token. If your POST should require a specific role, read it from the access token rather than trusting the caller:

AccessToken token = auth.getToken();
boolean allowed = token.getResourceAccess("realm-management") != null
        && token.getResourceAccess("realm-management")
                .isUserInRole("manage-users");
if (!allowed) {
    throw new ForbiddenException("manage-users role required");
}

Two more edges worth knowing:

  • CORS. These endpoints live on the same origin as the rest of Keycloak, but browsers still enforce CORS for cross-origin XHR. If a SPA calls your endpoint directly, wrap the response with Keycloak’s Cors builder and echo the allowed web origins from the token — don’t hand-roll Access-Control-* headers.
  • Admin-scoped variants. If you want an endpoint under /admin/realms/{realm}/... that reuses the admin console’s permission model, implement AdminRealmResourceProvider instead and evaluate AdminPermissionEvaluator. RealmResourceProvider is the public, realm-scoped surface.

Writing state, transactions, and events

The read path is the easy sell; the write path is where running in-process really pays off. A POST gets the same transaction Keycloak is already using for the request — your attribute write and Keycloak’s own bookkeeping commit or roll back together, with no distributed-transaction problem to invent.

import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;

import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;

@POST
@Path("terms")
public Response acceptTerms() {
    AuthResult auth = authenticate();
    UserModel user = auth.getUser();
    RealmModel realm = session.getContext().getRealm();

    // Persisted through the request transaction — committed on 2xx return,
    // rolled back if the handler throws.
    user.setSingleAttribute("terms_accepted_version", CURRENT_TERMS_VERSION);

    // Land it in the audit log like any first-class Keycloak action.
    new EventBuilder(realm, session, session.getContext().getConnection())
            .event(EventType.UPDATE_PROFILE)
            .user(user)
            .detail("terms_version", CURRENT_TERMS_VERSION)
            .success();

    return Response.noContent().build();
}

If the handler throws after the write, Keycloak rolls the transaction back — you don’t manage commit boundaries by hand. When you genuinely need to force one (say, before a long external call), session.getTransactionManager() is there, but reach for it deliberately. And because the write went through the normal UserModel, it invalidates the user cache correctly across the cluster — a thing a sidecar poking the database directly would silently get wrong.

Registration

Keycloak finds providers through the JDK ServiceLoader. One line, one file, exact name:

# src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory
net.zimakov.keycloak.entitlements.EntitlementsResourceProviderFactory

Get the filename wrong and the JAR loads with zero errors and zero endpoints — there’s no warning, because from Keycloak’s side there’s simply nothing to register. The Maven side is unremarkable; every Keycloak dependency is provided, because the server supplies them at runtime and shipping them in your JAR causes classloader conflicts:

<properties>
  <keycloak.version>24.0.5</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>

mvn package produces a thin entitlements-spi.jar.

Shipping it on Kubernetes

Here’s the part that makes this practical rather than a lab toy. In the Quarkus distribution, a provider is not hot-deployed in production — copying a JAR into a running pod does nothing useful. Providers are indexed at build time, the same kc.sh build augmentation that bakes in the DB vendor and features. So a provider is just another layer in the optimized image, and it inherits the exact deploy pipeline you already have.

Extend the multi-stage Containerfile from the deployment blueprint: drop the JAR into /opt/keycloak/providers/ before the build step so the augmentation picks it up.

FROM quay.io/keycloak/keycloak:24.0.5 AS builder

ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
ENV KC_CACHE=ispn

# The provider must be present at augmentation time.
COPY entitlements-spi.jar /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build

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

Nothing in the Keycloak CR changes — you point spec.image at the new tag and the Operator rolls the StatefulSet. The provider ships, versions, and rolls back atomically with the server image, which is exactly what you want: no drift between “which Keycloak” and “which extension”.

It’s a first-class realm route, behind your ingress

A routing detail that saves an afternoon: custom REST resources are served on the main HTTP listener (8080/8443) under KC_HTTP_RELATIVE_PATH (default /), so /realms/{realm}/entitlements reaches users through the same ingress as the rest of Keycloak — the login pages, the token endpoint, the admin console. There is no extra ingress rule to write and no separate Service; your endpoint is a peer of every other realm route because it is one.

In Keycloak 24 that same listener is also where /health and /metrics live, so if you firewall or route on paths, your custom endpoint sits alongside them. (Keycloak 25 later moves health and metrics off to a dedicated management port 9000 — worth knowing for when you upgrade, since it changes probe and scrape targets but not where your custom endpoint is served.)

Verify it actually loaded

A silently-missing provider is the failure mode, so confirm registration rather than assuming it. The provider list is reported at GET /admin/realms/{realm}/... server-info, but the fastest checks are:

# Boot logs enumerate registered SPIs on build/start.
kubectl logs deploy/iam-keycloak | grep -i "realm-restapi\|providers"

# And the endpoint itself, with a real user token:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://auth.example.com/realms/platform/entitlements

A 200 with your JSON means the JAR built in, the service file resolved, and the token authenticator wired up. A 404 means the provider didn’t register — almost always the META-INF/services filename or a JAR that landed after kc.sh build.

The checklist

A sound custom-endpoint provider on Keycloak 24 has:

  1. Factory + provider + resource, with getId() chosen deliberately — it’s the public path segment.
  2. jakarta.ws.rs.* imports and Keycloak deps at provided scope.
  3. The META-INF/services/…RealmResourceProviderFactory file, name exact — this is the #1 silent failure.
  4. Explicit auth on every handler via BearerTokenAuthenticator, plus role checks off the AccessToken for anything privileged. The endpoint protects nothing by default.
  5. Writes through the UserModel/session, letting the request transaction and cache invalidation do their job — and events fired so actions are auditable.
  6. The JAR baked in before kc.sh build in the optimized image, shipped and rolled back atomically with the server.

The reason to reach for this SPI isn’t that it’s clever — it’s that it deletes a service. When the operation you need is fundamentally about identity data Keycloak already owns, the lowest-friction place to run it is inside Keycloak, and the deployment cost is one more layer in an image you were already building.

Upstream: Server Developer Guide — extensions · keycloak/keycloak · Configuring providers · dasniko/keycloak-extensions-demo