
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.
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.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.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.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..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..proto filesPicture 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:
application/x-protostream encodingapplication/x-protostream encoding...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.
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:
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.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.
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.
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
.protofiles at Keycloak startup and register all of them into Infinispan'sSerializationContext, 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.
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.
The initializer performs four steps:
.proto.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.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.
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.
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.
/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
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./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.
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:
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.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.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.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 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.
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 chainThe 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:
.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.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.
The operational story is mostly clean: ship a JAR, mount some files. But "mostly clean" is where the on-call pages hide.
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.
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.
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 volumeA 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.
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.
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.
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.
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