When a microservice reads a serialized Java object, it makes a security decision. Every class name in that byte stream becomes a candidate for instantiation, and every object that crosses a network boundary is an untrusted input. The classic fix for insecure deserialization in Java microservices has been “don’t deserialize untrusted data,” but that is no longer practical in a distributed system. Instead, modern teams must combine two complementary defenses: strict allowlists for class resolution and runtime object filtering that inspects the stream before the JVM can bring a gadget chain to life.
Why Java Microservices Are Still Exposed
Java’s native serialization mechanism assumes trust. When ObjectInputStream.readObject() is called, it will happily reconstruct nearly any class on the classpath. Microservices amplify the risk because they use serialization for many mundane chores: session replication, cache keys, message payloads, distributed tracing headers, RMI, and even temporary files shared between worker nodes. In the rush to split monolithic applications into services, teams often copy serializable DTOs from an older codebase, then expose them through Redis, Kafka, RabbitMQ, or direct HTTP endpoints.
Attackers do not need a Runtime.exec() call buried in the stream. They use gadget chain attacks—small, existing classes whose methods can be chained into arbitrary code execution. Because popular libraries such as Commons Collections, Spring, or Hibernate include useful gadget components, a single unsafe deserialization point can lead to remote code execution. The vulnerability is not in the attacker’s payload alone; it is in the application’s willingness to resolve every class the stream requests.
The Missing Defense: Allowlists and Runtime Object Filtering Together
Most security guidance stops at “use a class allowlist.” That is necessary, but not sufficient. An allowlist says which classes may be deserialized, but it does not say how many objects can be created, how deeply nested the object graph can become, or how much memory the stream can consume. Runtime object filtering adds those missing checks. It evaluates the stream as it is read, allowing the application to approve or reject individual class names, reject suspicious references, and enforce graph constraints before the full object is materialized.
The two controls work best as layers: allowlists protect against unknown gadget classes, while runtime object filtering protects against stream-based resource exhaustion and odd graph structures. In a Java microservice, both can be applied with ObjectInputFilter, which has been available since Java 9 and improved in Java 17. It is no longer acceptable to rely only on class names from a package prefix. The filter must run at the same time as deserialization, not as a pre-parser after the damage is done.
Build an Allowlist for Every Deserialization Entry Point
Start by inventorying every input stream that can be deserialized. Look for ObjectInputStream in network listeners, Kafka consumers, JMS receivers, and even test utilities. For each entry point, define the exact set of classes that the service is allowed to receive. When possible, organize allowlists by package or API module, and reject anything else with a fail-closed status.
ObjectInputFilter allowlistFilter = info -> {
if (info.serialClass() == null) {
return ObjectInputFilter.Status.UNDECIDED;
}
String name = info.serialClass().getName();
if (name.startsWith("com.example.shared.dto.")) {
return ObjectInputFilter.Status.ALLOWED;
}
return ObjectInputFilter.Status.REJECTED;
};
This filter should be registered on every ObjectInputStream that handles external data. If a stream contains a class that is not in the allowlist, the JVM throws InvalidClassException before any objects of that class are created. For existing systems, start with a strict allowlist for new endpoints, then migrate legacy endpoints one by one. Use traffic logs to catch missing DTOs, but resist the temptation to add every class from the classpath. A wildcard allowlist is not an allowlist.
Add Runtime Object Filtering for Shape, Depth, and Size
Allowing a known DTO class is not the same as allowing an unbounded tree of DTO objects. A stream can request the same allowed class thousands of times, force recursion into nested collections, or specify a huge array size. Runtime object filtering catches these attacks because it checks properties of the stream while the object graph is being built.
ObjectInputFilter combinedFilter = ObjectInputFilter.allowFilter(
clazz -> clazz.getName().startsWith("com.example.shared.dto."),
ObjectInputFilter.Status.REJECTED
).andThen(ObjectInputFilter.Config.createFilter(
"maxdepth=20;maxarray=10000;maxrefs=50000;maxbytes=2000000"
));
The first portion performs class allowlisting; the second caps object graph depth, array length, references, and total bytes. These are concrete, understandable limits. A request that exceeds them is terminated mid-read, long before the service allocates memory or invokes a suspicious readObject() method. Runtime object filtering is especially valuable because it stops deserialization bombs and deep-graph gadget-chain triggers that rely on a large, convoluted object graph.
Use a Deserialization Firewall Pattern Across Service Types
A single filter is not enough for a system with many microservices. Each service should have its own deserialization firewall, but the pattern should be standardized across the organization. Create a shared library that wraps all ObjectInputStream usage, applies the allowlist filter, and enforces runtime object filtering. Then use that library in every service rather than allowing developers to instantiate raw streams.
For services that use JSON or XML libraries with polymorphic type handling, apply the same concept. Jackson’s default typing should not be enabled without a @JsonTypeInfo strategy that maps types to safe names, and even then, the registered subtype list should act as an allowlist. Kryo, Hessian, and other serialization libraries also need configuration to limit which classes can be loaded. Remember that the filter is not just about native Java serialization; any form of object reconstruction from untrusted bytes is a deserialization boundary.
Deserialization firewalls should be placed as close to the edge as possible. An API gateway may not know the exact DTO classes used by every downstream service, but it can still reject obvious indicators of an attack: enormous stream lengths, unexpected compression patterns, or content types that do not match the endpoint contract. Downstream, each service remains responsible for its own class allowlist and graph limits.
Handle Polymorphic Deserialization Without Reopening a Gadget Gap
Java microservices often use polymorphic types because event-driven architecture depends on base classes and subtype implementations. This is a common source of insecure deserialization because an attacker can replace a harmless subtype with a gadget class. If you cannot remove polymorphism completely, make the type mapping explicit and name-based.
For Jackson, define an interface with @JsonTypeInfo(use = Id.NAME) and @JsonSubTypes. Do not use Id.CLASS, because it lets the incoming payload determine the Java class. For native serialization, avoid writing blocks that call Class.forName() on a value from the stream. Instead, map a restricted set of type identifiers to classes in your code. This keeps the type system closed and ensures that runtime object filtering can be applied consistently.
Verify Your Fixes With Gadget-Chain Simulations in Staging
Security controls need evidence. Build a small staging environment where you can send suspicious serialized payloads to each microservice and confirm that the service rejects them. Use known gadget payload generators, but only in isolated test networks. The goal is not to create a perfect exploit, but to demonstrate that the allowlist blocks unknown classes and that runtime object filtering aborts dangerous stream shapes.
Automate these checks with integration tests. For every deserialization entry point, add a test case that sends a stream containing a class outside the allowlist, a deeply nested object graph, and a stream that claims to contain a huge array. The service should fail closed and log a structured security event. If any of these tests begins to deserialize successfully, your fix is incomplete.
Conclusion
Fixing insecure deserialization in Java microservices is not about removing one dangerous call. It is about building a boundary where every class, object graph, and stream property is treated as untrusted input. Allowlists prevent gadget chain classes from ever being resolved, while runtime object filtering stops the stream from exhausting resources or abusing allowed types. Together, these two layers give Java microservices a realistic defense against deserialization attacks without forcing you to abandon Java serialization completely.
