Keymate Logo
← Back to Blog

Runtime Protobuf Schemas in Keycloak's Embedded Infinispan

Ali Tuğrul PınarAli Tuğrul Pınar · Senior DevOps & Platform Engineer
July 2026 · 17 min read
Runtime Protobuf Schemas in Keycloak's Embedded Infinispan

ProtoStream is Infinispan's binary serialization format: it uses Protobuf .proto schema definitions to marshal and unmarshal cache objects. Keycloak's embedded Infinispan runs on it, and Infinispan itself documents a clean extension point for contributing schemas. What Keycloak does not provide is any supported, configuration-only way to load arbitrary runtime-mounted .proto files into that machinery. At Keymate, we bridged that gap with a small internal extension built on Infinispan's SerializationContextInitializer lifecycle. This post covers the pattern itself, reviewed through a security and platform-engineering lens, with a public reference demo you can run end to end.

TL;DR

  • The gap is narrower than "no way in", and knowing its exact shape matters. Infinispan documents automatic ServiceLoader discovery of SerializationContextInitializer implementations for embedded caches. What is missing is the last mile in Keycloak: no config flag, no directory scan, no official Keycloak SPI that turns a mounted .proto file into a registered schema.
  • The fix is a small provider JAR. An initializer implementing Infinispan's SerializationContextInitializer, discovered through ServiceLoader, scans a directory of .proto files at startup and registers them into the SerializationContext in one pass. Drop the JAR in providers/, mount the schemas, restart.
  • It is runnable, not just describable. The reference demo pins Keycloak 26.6.2, Infinispan 16.0.8, ProtoStream 6.0.6, and Java 21, and proves the pattern with one command: docker compose up --build runs the test suite (cache put/get, an Ickle query, a cross-file import, a malformed-schema failure) and boots a real Keycloak that registers the mounted schemas.
  • The security story is a schema contract, not a gadget-chain purge. ProtoStream's wire format only resolves to registered message types. That is a strong structural property, but do not oversell it: application/x-java-object is plain POJO storage, not Java native serialization, and the gadget-chain risk belongs to a separately configured marshaller most deployments never enable.
  • Treat .proto files as configuration with a supply chain. They drive marshalling behavior across every node. Publish them as an immutable, versioned ConfigMap, roll the Deployment to change them, validate them in CI, and namespace them away from Keycloak's internals.

Pin down the gap: no supported path for runtime .proto files

Picture a common task: you are extending Keycloak and need to cache some tenant metadata or a few custom token attributes. Keycloak already runs an embedded Infinispan, so you reach for it, write your .proto schema, and go looking for the place to register it. There is no such place in Keycloak's configuration surface.

Keycloak ships with an embedded Infinispan instance that handles sessions, realms, authorization data, and more. This embedded cache uses ProtoStream as its default marshalling framework, which depends on Protobuf (.proto) schema definitions to serialize and deserialize objects.

Here is the precise limitation: Keycloak does not provide a supported, configuration-only mechanism for loading arbitrary runtime-mounted .proto files into its embedded Infinispan's SerializationContext. The extension point itself exists one layer down, and it is documented: Infinispan discovers SerializationContextInitializer implementations on the classpath through Java's ServiceLoader for embedded caches. But an initializer is compiled code. Nothing in Keycloak or Infinispan turns a directory of schema files, the thing your platform team actually wants to manage as configuration, into registered schemas.

So if you need to:

  • Store custom data types in an Infinispan cache within Keycloak with application/x-protostream encoding
  • Define new cache entries with application/x-protostream encoding
  • Index and query already-protobuf-encoded entries

...you have code to write. Keycloak's built-in schema registration is tightly coupled to its own internal data model, and the moment you want to put your data in its cache under a schema contract, you need a bridge from mounted files to the documented initializer lifecycle. That bridge is what this post describes.

Weigh the workarounds before building anything

Without that bridge, an extension that needs to store tenant-specific metadata, custom token attributes, or integration state in Keycloak's cache is limited to three workarounds, and each carries a real production cost:

  1. Rebuild the Keycloak image with your schemas baked into the classpath. This breaks the upgrade path and creates tight coupling: every Keycloak bump now drags your schemas through a rebuild, and your image diverges from the upstream one you are trying to track.
  2. Use application/x-java-object encoding. This works, and it is honest to say so: it stores plain POJOs, and embedded Ickle queries run over them (non-indexed queries resolve entities by Java class name; indexed embedded queries over Java objects are supported too). The costs are architectural. You give up the explicit schema contract, so wire-level compatibility across versions rests on your Java classes instead of a .proto history. And in the deployment that matters the escape is partial: in a replicated or distributed cache, values cross node boundaries through the cluster marshaller, so a custom type needs a registered ProtoStream schema and marshaller anyway. You end up back at schema registration, just without a managed path for it.
  3. Run an external Infinispan cluster. This adds operational complexity and network latency for data that logically belongs inside Keycloak, and gives you a second stateful system to secure, patch, and back up.

Keycloak offers no supported path from a mounted .proto file to its embedded Infinispan, and each workaround carries a production cost.

The gap is structural: schema registration is coupled to Keycloak's own data model, so external schemas need programmatic registration. The pattern below closes that gap without forking Keycloak or rebuilding the image per schema.

Why we chose ProtoStream over java-object storage

We did not start with an extension, and it is worth being precise about why we ended up with one, because the obvious alternative is not broken.

A prototype that drops plain Java objects into an application/x-java-object cache works. Embedded queries run over those POJOs; the demo's test suite includes an Ickle query precisely to show what does and does not depend on the schema. If your deployment is a single node and your cached types live and die with one code version, that prototype can be the end of the story.

Ours could not be, for two reasons. First, production Keycloak is a cluster. In replicated and distributed caches, values are marshalled to cross node boundaries, and a custom Java type needs a registered ProtoStream schema and marshaller to make that trip. The POJO shortcut quietly re-acquires the schema requirement the moment you scale past one node. Second, cache entries outlive deployments. A rolling upgrade runs two code versions side by side against the same cache, and we wanted the compatibility contract in explicit, reviewable .proto files with Protobuf's evolution rules, not implicit in whatever the Java classes happened to look like at write time.

So the decision was to treat schemas as first-class, runtime-managed configuration: ProtoStream encoding for the data, .proto files for the contract, and a loader that registers whatever the platform mounts. Baking schemas into a custom image would have satisfied the encoding but not the "runtime-managed" part, and it broke the first time we followed an upstream version bump. The extension below is where we landed.

Bridge the gap with a directory-scanning initializer

At Keymate, we addressed this need with a small internal extension that loads runtime-mounted .proto files through Infinispan's SerializationContextInitializer lifecycle. The core idea is straightforward:

Scan a designated directory for .proto files at Keycloak startup and register all of them into Infinispan's SerializationContext, automatically, with zero code changes per schema.

Everything below is the pattern itself, shown through the public reference demo rather than our production code, so every class name, path, and log line in this post is something you can run and inspect. Package names and directories are illustrative; adapt them to your conventions.

Schema registration has a precise scope. Knowing where it ends shapes your storage model. Registering a .proto gives ProtoStream the schema, the structure it uses to encode and decode a message, and the descriptor that indexing and remote queries run against. That is sufficient when entries are stored as ProtoStream-encoded bytes (the WrappedMessage / remote-query pattern) or when you are indexing already-protobuf-encoded entries. Storing and reading back an arbitrary Java object, by contrast, also needs a marshaller for that type, the code that maps the Java fields to and from the schema. ProtoStream generates those marshallers from annotated classes, or you write one by hand; the schema-scanning initializer deliberately stops at registration and does not register marshallers. So "zero code per schema" applies to the registration step, not to introducing a brand-new Java type: protobuf-encoded values and indexed entities come for free, while a custom POJO brings its own marshaller. The demo makes this boundary concrete: its DirectoryProtoSchemaInitializer contributes schemas only, and the test suite registers a separate BookMarshaller through its own initializer to store a Java type against a runtime-loaded schema.

Register everything in one pass: check, discover, combine, register

The extension implements Infinispan's SerializationContextInitializer interface and registers itself through Java's ServiceLoader mechanism via META-INF/services. No Keycloak configuration is required to wire it in. The JVM finds it.

Startup flow: ServiceLoader discovers the initializer, which checks the directory, discovers .proto files, and registers them as one combined set.

The initializer performs four steps:

  1. Directory check, verifies that the proto directory exists. If not, it logs a warning and exits gracefully without blocking Keycloak startup.
  2. File discovery, walks the directory tree and filters for files ending in .proto.
  3. Combine, adds every discovered file into a single FileDescriptorSource. This is what makes cross-file import statements safe: registering files one at a time makes registration order matter, and directory-walk order is filesystem-dependent, so a file could land before the dependency it imports and fail with an unresolved-import error. One combined source lets ProtoStream resolve the whole set together.
  4. Register, hands the combined source to the SerializationContext in one registerProtoFiles(source) call and logs the registered file list.
@Override
public void registerSchema(SerializationContext serializationContext) {
    LOGGER.infof("Proto schema registration started: directory=%s", protoDirectory);

    if (!Files.isDirectory(protoDirectory)) {
        LOGGER.warnf("Proto schema directory not found, skipping registration: directory=%s",
                protoDirectory);
        return; // fail-open: a missing mount must not block startup
    }

    List<Path> protoFiles = discoverProtoFiles();

    // One FileDescriptorSource resolves cross-file imports independent of file order.
    FileDescriptorSource source = new FileDescriptorSource();
    for (Path protoFile : protoFiles) {
        String name = relativeName(protoFile);
        LOGGER.infof("Adding proto schema: file=%s", name);
        source.addProtoFile(name, readFile(protoFile));
    }

    serializationContext.registerProtoFiles(source);

    LOGGER.infof("Proto schema registration completed: registeredFiles=%d, files=%s",
            protoFiles.size(), protoFiles.stream().map(this::relativeName).toList());
}

This is the demo's actual implementation, exercised by its test suite: the cross-file import case (book.proto importing library/common.proto), the malformed-schema failure, and the missing-directory warning are each pinned by a unit test you can run with mvn test.

Let Infinispan discover the initializer, not Keycloak

The initializer is discovered by Infinispan through a standard Java ServiceLoader entry. The file lives at:

META-INF/services/org.infinispan.protostream.SerializationContextInitializer

with a single line naming the implementation (the demo's neutral package; yours will differ):

com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer

This means no Keycloak configuration file changes are required for the extension itself. Placing the JAR in the providers/ directory is sufficient, Keycloak loads it at startup alongside its other providers, and Infinispan picks it up through the standard ProtoStream initializer discovery path. To be precise about what this is: a Keycloak provider JAR built against an Infinispan/ProtoStream extension point, not an official Keycloak SPI. Keycloak has no schema-registration SPI to implement; Infinispan's documented initializer lifecycle is the contract.

Two assumptions make that automatic discovery work, and both hold for a standard providers/ deployment. First, Infinispan only falls back to ServiceLoader discovery of SerializationContextInitializer implementations when no <context-initializer> elements are explicitly configured, list them and auto-discovery is disabled. Second, the JAR has to be visible to the classloader that Infinispan's ServiceFinder scans. If your initializer is ever not picked up, those are the first two things to check.

Ship the JAR once, mount schemas as config

The deployment model follows a clean separation of concerns: the code ships once as a JAR, and the schemas are configuration that can change independently.

The JAR ships once and stays in providers/. Schemas live in a versioned ConfigMap, and a reference bump rolls the change out.

/opt/keycloak/
└── providers/
    └── keycloak-runtime-protobuf-schemas-demo.jar   ← extension JAR (built once)
/etc/keycloak/
└── protos/                                          ← runtime mount (ConfigMap)
    └── library/
        ├── book.proto                               ← imports library/common.proto
        └── common.proto
  • Extension JAR: deployed to the providers/ directory. Keycloak loads it at startup alongside other providers, and the demo's Dockerfile runs kc.sh build so the augmented image is what ships.
  • Proto files: mounted into a directory the initializer knows (the demo uses /etc/keycloak/protos, configurable via the PROTO_SCHEMA_DIR environment variable). In Kubernetes these come from a ConfigMap, and the safe update pattern is versioned: publish a new immutable ConfigMap and bump the Deployment's volume reference, which rolls the change out as an ordered deployment event. The demo repository ships worked manifests for exactly this.

To see the whole thing run, one command suffices:

git clone https://github.com/Keymate-io/keycloak-runtime-protobuf-schemas-demo
cd keycloak-runtime-protobuf-schemas-demo
docker compose up --build

The build runs the full test suite (so a failing assertion fails the image build), augments Keycloak 26.6.2 with the provider via kc.sh build, and starts it with the protos/ directory mounted.

Configure caches to use ProtoStream encoding

Once schemas are registered, you configure caches that use them in cache-ispn.xml:

<local-cache name="my-custom-cache">
    <encoding>
        <key media-type="application/x-java-object"/>
        <value media-type="application/x-protostream"/>
    </encoding>
    <indexing storage="filesystem">
        <indexed-entities>
            <indexed-entity>library.Book</indexed-entity>
        </indexed-entities>
    </indexing>
</local-cache>

Key configuration points:

  • Encoding. Set the value media-type to application/x-protostream to store values in ProtoStream's schema-defined wire format. This is what puts your cache data under the explicit .proto contract, with the evolution rules that come with it.
  • Indexing. If you want indexed queries over protobuf-encoded entries, enable indexing and list your entities using the package.MessageName format from your .proto file. One nuance the demo's query test pins down: in embedded mode, non-indexed Ickle queries resolve entities by Java class name through reflection, even when the cache stores ProtoStream-encoded bytes; the protobuf type names belong to the indexed and remote (Hot Rod) query engines. Design your storage model knowing which query path you are on.
  • Cache type. Use local-cache for single-node deployments and distributed-cache or replicated-cache for HA clusters. One thing the single-node snippet above hides: in a clustered cache, keys are marshalled across nodes too. An application/x-java-object key is fine for String and primitive types. Built-in marshallers cover those. A custom-object key needs its own marshaller before it can travel between nodes. Do not copy this local-cache key encoding into a clustered config unchanged.

Read the startup logs before trusting the deployment

When Keycloak starts with the extension deployed, the logs confirm the registration. These lines are from the demo running against Keycloak 26.6.2; the block repeats because Infinispan initializes more than one ProtoStream context:

INFO  [com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer] Proto schema registration started: directory=/etc/keycloak/protos
INFO  [com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer] Adding proto schema: file=library/book.proto
INFO  [com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer] Adding proto schema: file=library/common.proto
INFO  [com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer] Proto schema registration completed: registeredFiles=2, files=[library/book.proto, library/common.proto]

If the proto directory does not exist, the initializer logs a warning and does not block Keycloak startup:

WARN  [com.example.keycloak.protobuf.DirectoryProtoSchemaInitializer] Proto schema directory not found, skipping registration: directory=/etc/keycloak/protos

A malformed schema behaves very differently, and we verified this on the pinned stack rather than assuming it: registration throws inside DefaultCacheManager.start(), the Infinispan component registry enters a FAILED state, and the container exits with code 1. Missing directory fails open; broken schema fails closed. Both behaviors are deliberate design choices, and they are exactly the kind of thing a security reviewer and a platform engineer will read very differently.

A Security Reviewer's Read

A schema-registration extension sounds benign, it moves data definitions, not executable code. Mostly that is true. But the security story deserves precision, because the obvious version of it overclaims.

A closed deserialization surface, stated precisely

Start with what ProtoStream actually gives you. It marshals to a schema-defined wire format, and its WrappedMessage embeds a type identifier that can only resolve to a registered message type. An encoded cache value can be parsed as a declared type or it can fail; there is no path from cache bytes to instantiating an arbitrary class. For data that crosses node boundaries in an IAM system, that closed surface is a genuinely strong structural property.

Now the part that is easy to get wrong, and that an earlier draft of this post did get wrong: application/x-java-object is not Java native serialization. In an embedded cache it means unmarshalled POJO storage, live objects on the heap. The notorious deserialization gadget-chain CVEs, crafted byte streams turning readObject() into remote code execution, belong to a different, explicitly configured path: Infinispan's JavaSerializationMarshaller with application/x-java-serialized-object encoding, plus an allow-list you must populate yourself. If your deployment never configures that marshaller, ProtoStream is not rescuing you from gadget chains, because nothing was deserializing untrusted bytes with java.io in the first place. The honest security claim is narrower and still worth making: ProtoStream keeps your custom cache data on a closed, schema-defined surface, and it removes any temptation to reach for the Java-serialization marshaller when a type will not otherwise travel between nodes.

.proto files are configuration with a supply chain

The flip side: these schemas drive marshalling on every node, and they arrive as files. Whoever can write to the ConfigMap can change how your cache interprets bytes. That makes the .proto directory a supply-chain artifact, not an inert config blob. Two controls follow directly:

  • Lock down write access to the ConfigMap. Treat RBAC on the schema ConfigMap with the same seriousness as RBAC on a Keycloak realm export. A user who can edit schemas can reshape cache semantics.
  • Validate schemas in CI before they ship. A malformed .proto is not just a typo: as verified above, a syntax error fails the cache container closed and the pod with it. Run protoc (or an equivalent lint) in the pipeline so a broken schema is rejected at merge time, never at pod start. One refinement on that check: protoc catches gross syntax errors, but ProtoStream parses .proto with its own parser and dialect, so a file that passes protoc can still fail ProtoStream registration. The higher-fidelity gate is to load each schema through ProtoStream's own FileDescriptorSource and SerializationContext in a unit test, that validates exactly what runs at pod start. The demo's malformed-schema test is the minimal template.

Namespace away from Keycloak's internals

The extension registers additional schemas; it does not edit Keycloak's. But the SerializationContext is shared, and type names live in a flat namespace per file descriptor. A custom message that collides with a Keycloak-internal type name is asking for trouble. Use a vendor-prefixed package, com.yourorg.keycloak.cache, so a collision is structurally impossible rather than merely unlikely.

The blast radius of a cache marshalling change is the whole cache container. A namespacing convention costs nothing and removes an entire failure mode. Adopt it on day one.

A Platform Engineer's Read

The operational story is mostly clean: ship a JAR, mount some files. But "mostly clean" is where the on-call pages hide.

Schema consistency is the cluster-wide invariant

The initializer runs independently on each node. That is fine only if every node registers identical .proto files. If two pods register different versions of tenant.proto, they will marshal the same logical object differently. A replicated or distributed cache entry written by one node can then fail to deserialize on another.

The defense starts with single-sourcing the schemas from one ConfigMap, but be careful with the tempting shortcut of editing that ConfigMap in place: it is necessary, not sufficient. Kubernetes propagates mounted ConfigMap updates to kubelets eventually, not atomically, and this initializer reads the directory only at startup. During a naive rollout, pods can therefore start from different schema snapshots. The pattern that closes the window is versioning: publish schemas as an immutable ConfigMap whose name carries a content version (a short content hash works), and change schemas by creating a new ConfigMap and bumping the volume reference in the Deployment. The pod template changes, Kubernetes performs an ordered rolling update, and every new pod starts from the same snapshot. A startup log or metric that exposes the registered schema set (the demo logs the full file list for exactly this reason) gives you the verification hook. And since old and new pods coexist during the rollout, adjacent schema versions must stay wire-compatible, which brings us to evolution discipline.

Schema evolution needs Protobuf discipline

Protobuf gives you forward and backward compatibility, but only if you respect its rules. Field numbers are the contract: never reuse a number, never renumber an existing field, and add new fields as optional. A rolling Keycloak upgrade will, for a window, run old and new schema versions side by side; additive-only changes are what keep that window non-breaking. Treat your .proto files like a database migration history, because operationally that is what they are.

You are coupling to an Infinispan extension point

SerializationContextInitializer is an Infinispan/ProtoStream interface, and the version matters. The pattern was built and verified against the specific stack below. A future Keycloak release that bumps its bundled Infinispan or ProtoStream version can change or move that interface. Pin the versions you build against and re-test the extension with every Keycloak upgrade. This is application-layer coupling to another project's extension point, manageable, but not free.

readOnlyRootFilesystem and indexing need a writable volume

A hardened Keycloak image should run with readOnlyRootFilesystem: true. If yours does, <indexing storage="filesystem"> needs a writable mount for its Lucene index. Mount a writable volume for the index path (an emptyDir is enough for an ephemeral, rebuildable index), or the cache will fail to start against a read-only root. This is the one place where the pattern's convenience runs into the hardened-image posture, and it is easy to miss until the first restart.

A closing operational note: schema changes are not hot-reloaded. The directory is scanned once, at startup. Adding or changing a schema means a pod restart, a deliberate, observable event rather than a live mutation, which is the right trade for a cache layer, but one your runbook should state plainly.

Technical Details

The pattern was built and verified against the following stack, and the public demo pins exactly these versions so the claims in this post stay checkable. Treat them as the tested matrix, not arbitrary minimums; the Infinispan and ProtoStream versions in particular are the ones the extension-point coupling is validated against.

Property Value
Keycloak Version 26.6.2
Infinispan Version 16.0.8
ProtoStream Version 6.0.6
Java Version 21
Build Tool Maven

What the demo's one-command run actually proves: the test suite exercises schema registration with a cross-file import, a malformed-schema failure, a missing-directory warning, a real embedded-cache put/get through ProtoStream encoding, and an Ickle query with assertions; the compose stage then boots Keycloak 26.6.2 with the provider built in via kc.sh build and registers the mounted schemas, with the log lines shown above.

Where This Fits

This pattern is a small piece of a larger practice: running Keycloak as a platform rather than a stock identity provider. Once you are storing tenant metadata, audit trails, or integration state in Keycloak's own cache, you are operating Keycloak the way you operate a stateful service, with schema governance, supply-chain hygiene over its configuration, and a hardened runtime underneath.

If you are interested in that hardened runtime, the companion to this post walks through hardening the Keycloak container image, Wolfi base, non-root execution, and three-tier CVE management, which is the layer the readOnlyRootFilesystem note above assumes you are running on. If you want the next installment when it lands, subscribe to the Keymate newsletter or follow us on LinkedIn.

References


Keymate operates Keycloak as a platform: custom extensions, hardened container images, and fine-grained authorization under regulated workloads. If that is what you are building toward, request a demo.

Running Keycloak as a platform, not just an IdP?

Keymate gives platform teams a production-grade Keycloak: custom extensions, hardened container images, and fine-grained authorization tooling, all validated under regulated workloads. See how Keymate extends Keycloak safely past authentication.

Stay updated with our latest insights and product updates

Frequently Asked Questions