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
Corsbuilder and echo the allowed web origins from the token — don’t hand-rollAccess-Control-*headers. - Admin-scoped variants. If you want an endpoint under
/admin/realms/{realm}/...that reuses the admin console’s permission model, implementAdminRealmResourceProviderinstead and evaluateAdminPermissionEvaluator.RealmResourceProvideris 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:
- Factory + provider + resource, with
getId()chosen deliberately — it’s the public path segment. jakarta.ws.rs.*imports and Keycloak deps atprovidedscope.- The
META-INF/services/…RealmResourceProviderFactoryfile, name exact — this is the #1 silent failure. - Explicit auth on every handler via
BearerTokenAuthenticator, plus role checks off theAccessTokenfor anything privileged. The endpoint protects nothing by default. - Writes through the
UserModel/session, letting the request transaction and cache invalidation do their job — and events fired so actions are auditable. - The JAR baked in before
kc.sh buildin 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