Keycloak Custom SPI and Database Extensions: Adding Your Own Tables 2/2

In the previous post, we learned the foundation for extending Keycloak with custom database entities and providers. In this post, we’ll explain how to expose the subscription management via a REST API extension.

· Prerequisites
· Overview
· Implementing Subscription CRUD in a Custom Provider
∘ Define the Provider Interface
∘ Implement the Provider
∘ Factory for Subscription Provider
∘ The SPI Class
∘ Service Loader Configuration
· Exposing Subscription CRUD via REST API
∘ REST Resource Implementation
∘ Factory for REST Resource
∘ Registering the REST Extension
· Test the REST APIs
· Conclusion
· References


Prerequisites

This is the list of all the prerequisites:

  • All steps in Part 1 must be completed

⚠️ Note: This post is based on Quarkus-based Keycloak distribution (Keycloak 26+).

Overview

In Part 1, we extended Keycloak’s persistence layer by:

  • Creating a new table (custom_subscription) linked to users.
  • Registering it through a JPA Entity Provider.
  • Packaging and deploying it as a custom SPI.

That work gave us the foundation: Keycloak can now recognize and store subscription data.

But right now, the table is just sitting there. It has no real interaction with users, no way to create or update records, and no external interface to manage subscriptions.

By the end of this post, your Keycloak server will not just store subscription data — it will actively manage it as part of the user lifecycle and make it accessible via REST endpoints.

Implementing Subscription CRUD in a Custom Provider

A Provider in Keycloak encapsulates logic that can be reused across the server — in this case, our subscription management logic.

Define the Provider Interface

We start with an interface that declares the operations Keycloak (or external callers) will need:

public interface SubscriptionProvider extends Provider {

/**
* Creates a new subscription based on the provided model.
*
* @param model the subscription input model
* @return the created {@link CustomSubscriptionEntity}
*/
CustomSubscriptionEntity create(SubscriptionModel model);

/**
* Retrieves a subscription by its primary key.
*/
CustomSubscriptionEntity getById(String id);

/**
* Retrieves a subscription linked to a specific user.
*/
CustomSubscriptionEntity getByUser(String userId);

/**
* Retrieves all subscriptions across the realm.
*/
List<CustomSubscriptionEntity> getAll();

/**
* Updates a subscription with new values from the model.
*
* @param id the subscription ID
* @param model the updated subscription input model
* @return the updated {@link CustomSubscriptionEntity}, or null if not found
*/
CustomSubscriptionEntity update(String id, SubscriptionModel model);

/**
* Deletes a subscription by its ID.
*/
boolean delete(String id);
}

Implement the Provider

Now we implement the interface with JPA operations. Keycloak makes this easier because it already provides an EntityManager via the session.

Inside repository/SubscriptionRepositoryProvider.java:


/**
* JPA implementation of the SubscriptionProvider with model-based input.
*/
public class SubscriptionRepositoryProvider implements SubscriptionProvider {

private final KeycloakSession session;
private final EntityManager em;

public SubscriptionRepositoryProvider(KeycloakSession session) {
this.session = session;
this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
}

@Override
public CustomSubscriptionEntity create(SubscriptionModel model) {
CustomSubscriptionEntity sub = new CustomSubscriptionEntity();
sub.setId(java.util.UUID.randomUUID().toString());
sub.setPlanType(model.getPlan().name());
sub.setStartDate(model.getStartDate());
sub.setEndDate(model.getEndDate());

// Fetch the Keycloak user entity
UserEntity userEntity = em.find(UserEntity.class, model.getUserId());
if (userEntity == null) {
throw new IllegalArgumentException("User not found with id: " + model.getUserId());
}
sub.setUser(userEntity);


em.persist(sub);
return sub;
}

@Override
public CustomSubscriptionEntity getById(String id) {
return em.find(CustomSubscriptionEntity.class, id);
}

@Override
public CustomSubscriptionEntity getByUser(String userId) {
try {
return em.createQuery(
"SELECT s FROM CustomSubscriptionEntity s WHERE s.user.id = :userId",
CustomSubscriptionEntity.class)
.setParameter("userId", userId)
.getSingleResult();
} catch (NoResultException e) {
return null;
}
}

@Override
public List<CustomSubscriptionEntity> getAll() {
return em.createQuery("SELECT s FROM CustomSubscriptionEntity s", CustomSubscriptionEntity.class)
.getResultList();
}

@Override
public CustomSubscriptionEntity update(String id, SubscriptionModel model) {
CustomSubscriptionEntity sub = getById(id);
if (sub != null) {
if (model.getPlan() != null) sub.setPlanType(model.getPlan().name());
if (model.getStartDate() != null) sub.setStartDate(model.getStartDate());
if (model.getEndDate() != null) sub.setEndDate(model.getEndDate());

if (model.getUserId() != null) {
UserEntity userEntity = em.find(UserEntity.class, model.getUserId());
if (userEntity == null) {
throw new IllegalArgumentException("User not found with id: " + model.getUserId());
}
sub.setUser(userEntity);
}

em.merge(sub);
}
return sub;
}

@Override
public boolean delete(String id) {
CustomSubscriptionEntity sub = getById(id);
if (sub != null) {
em.remove(sub);
return true;
}
return false;
}

@Override
public void close() {
// No cleanup needed
}
}

We don’t manually begin/commit transactions here because Keycloak manages transactions automatically around provider methods.

Factory for Subscription Provider

In Keycloak’s SPI architecture, the Provider contains the logic (our CRUD methods), while the Factory is responsible for creating instances of that Provider and registering it with Keycloak.

Without the factory, Keycloak doesn’t know how to bootstrap your provider during startup.

public interface SubscriptionProviderFactory extends ProviderFactory<SubscriptionProvider> {
}
public class SubscriptionProviderFactoryImpl implements SubscriptionProviderFactory {

public static final String PROVIDER_ID = "subscription-provider";

@Override
public SubscriptionProvider create(KeycloakSession session) {
return new SubscriptionRepositoryProvider(session);
}

@Override
public void init(org.keycloak.Config.Scope config) {
// Initialize from config if needed
}

@Override
public void postInit(KeycloakSessionFactory factory) {
// Perform actions after all factories initialized
}

@Override
public void close() {
// Cleanup resources if necessary
}

@Override
public String getId() {
return PROVIDER_ID;
}
}
  • Registration with Keycloak
  • The getId() method defines the provider’s unique name (subscription-provider).
  • Keycloak uses this ID when looking up the provider.
  • Provider Creation
  • Every time a session needs access to subscriptions, Keycloak calls create(session).
  • Here, we instantiate our SubscriptionRepositoryProvider, passing in: the current KeycloakSession
  • Lifecycle Hooks
  • init() → called once during server startup, can read configuration.
  • postInit() → runs after all providers are initialized (useful for cross-provider setup).
  • close() → cleanup on server shutdown.

The SPI Class

Starting with Keycloak 26, the SPI system got stricter: to introduce a new SPI, we must create a class that implements org.keycloak.provider.Spi

That means for our SubscriptionProvider, we need to declare the SPI interface class; otherwise, Keycloak won’t recognize it.

public class SubscriptionSpi implements Spi {

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

@Override
public String getName() {
// SPI name used by Keycloak to register providers
return "subscription";
}

@Override
public Class<? extends Provider> getProviderClass() {
return SubscriptionProvider.class;
}

@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return SubscriptionProviderFactory.class;
}
}

Service Loader Configuration

To make Keycloak discover your SPI + factory, we must add two service files:

File: META-INF/services/org.keycloak.provider.Spi

com.bootlabs.provider.SubscriptionSpi

File: META-INF/services/com.bootlabs.factory.SubscriptionProviderFactory

com.bootlabs.factory.SubscriptionProviderFactoryImpl

Now our Subscription SPI is properly defined for Keycloak 26+, fully recognized by the runtime.

Exposing Subscription CRUD via REST API

REST Resource Implementation

Let’s start by creating a class that implements RealmResourceProvider.

/**
* REST API for managing subscriptions.
*/
public class SubscriptionResource implements RealmResourceProvider {

private final KeycloakSession session;

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

private SubscriptionProvider provider() {
SubscriptionProvider provider = session.getProvider(SubscriptionProvider.class, "subscription-provider");
if (provider == null) {
throw new IllegalStateException("SubscriptionProvider is not found. Did you register the SPI correctly?");
}
return provider;
}

/**
* Create a new subscription.
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response createSubscription(SubscriptionModel model) {

CustomSubscriptionEntity entity = provider().create(model);
UserModel user = session.users().getUserById(session.getContext().getRealm(), model.getUserId());
SubscriptionRepresentation rep = toRepresentation(entity, user);

return Response.ok(rep).build();
}

/**
* Get subscription by ID.
*/
@GET
@Path("/{id}")
public Response getSubscription(@PathParam("id") String id) {
CustomSubscriptionEntity entity = provider().getById(id);
if (entity == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}

UserModel user = session.users().getUserById(session.getContext().getRealm(), entity.getUser().getId());
SubscriptionRepresentation rep = toRepresentation(entity, user);

return Response.ok(rep).build();
}

/**
* Get all subscriptions.
*/
@GET
public Response getAllSubscriptions() {
List<CustomSubscriptionEntity> entities = provider().getAll();

List<SubscriptionRepresentation> reps = entities.stream()
.map(entity -> {
UserModel user = session.users().getUserById(session.getContext().getRealm(), entity.getUser().getId());
return toRepresentation(entity, user);
})
.toList();

return Response.ok(reps).build();
}

/**
* Update subscription.
*/
@PUT
@Path("/{id}")
@Consumes(MediaType.APPLICATION_JSON)
public Response updateSubscription(@PathParam("id") String id, SubscriptionModel model) {
CustomSubscriptionEntity updated = provider().update(id, model);
if (updated == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}

UserModel user = session.users().getUserById(session.getContext().getRealm(), updated.getUser().getId());
SubscriptionRepresentation rep = toRepresentation(updated, user);

return Response.ok(rep).build();
}

/**
* Delete subscription.
*/
@DELETE
@Path("/{id}")
public Response deleteSubscription(@PathParam("id") String id) {
boolean deleted = provider().delete(id);
if (!deleted) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.noContent().build();
}


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

@Override
public void close() {
// Nothing to clean up
}


private SubscriptionRepresentation toRepresentation(CustomSubscriptionEntity entity, UserModel userModel) {
if (entity == null) return null;

SubscriptionRepresentation rep = new SubscriptionRepresentation();
rep.setId(entity.getId());

UserRepresentation userRep = ModelToRepresentation.toBriefRepresentation(userModel);
rep.setUser(userRep);

rep.setPlan(entity.getPlanType());
rep.setStartDate(entity.getStartDate());
rep.setEndDate(entity.getEndDate());

return rep;
}
}

Factory for REST Resource

public class SubscriptionResourceProviderFactory implements RealmResourceProviderFactory {

public static final String ID = "subscription-api";

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

@Override
public void init(org.keycloak.Config.Scope config) {
// do nothing
}

@Override
public void postInit(KeycloakSessionFactory factory) {
// do nothing
}

@Override
public void close() {
// do nothing
}

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

Registering the REST Extension

Add service loader file META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory:

com.bootlabs.factory.SubscriptionResourceProviderFactory

Test the REST APIs

Now we are all done with our code. You can check the deployment section in Part 1.

Our API will now be available at:

http://localhost:8080/realms/{realm}/subscription-api

subscription-api refers to the RealmResourceProviderFactory ID.

Ensure that you have created a realm and a client in your Keycloak instance with the necessary access settings.

Restart Keycloak → You should see logs confirming providers are registered.

keycloak-1  | WARN  [org.key.services] (build-57) KC-SERVICES0047: subscription-api (com.bootlabs.factory.SubscriptionResourceProviderFactory) is implementing the internal SPI realm-restapi-extension. This SPI is internal and may change without notice
keycloak-1 | WARN [org.key.services] (build-57) KC-SERVICES0047: subscription-provider (com.bootlabs.factory.SubscriptionProviderFactoryImpl) is implementing the internal SPI subscription. This SPI is internal and may change without notice
keycloak-1 | WARN [org.key.services] (build-57) KC-SERVICES0047: custom-subscription-provider (com.bootlabs.factory.CustomJpaEntityProviderFactory) is implementing the internal SPI jpa-entity-provider. This SPI is internal and may change without notice
  • Create subscription

The record has been successfully created in the database.

  • Get all subscriptions
  • Update subscription

Conclusion

Well done !!. In this second part, we turned a simple database integration into a fully functional subscription management system within Keycloak.

With this, Keycloak can act not just as an Identity Provider, but also as a subscription manager, exposing consistent and JSON-friendly APIs. Even though the raw CRUD works, it’s not safe to leave it unprotected. Keycloak doesn’t automatically enforce permissions on your custom REST resources. You need to secure the endpoints with realm roles and scopes, ensuring only authorized clients or users can manage subscriptions.

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉 Link to Medium blog

Related Posts