Core engineering · Practical technical guide · 22 October 2026
How Java serialization filters from JEP 290 and JEP 415 work, why an allowlist without a final reject pattern allows everything, how to build the allowlist from the classes your application actually reads, how limits stop resource attacks, and how to layer per-stream and JVM-wide filters.
Why filters exist
Java deserialization creates objects of whatever classes the byte stream names, as long as they are on the classpath and serializable, and runs their readObject methods along the way. An attacker who can send a stream to an endpoint that deserializes it can choose classes whose side effects, chained together, run code or exhaust memory. The fix the platform offers is a filter: a callback consulted for each class, array and reference in the stream before the object is created. JEP 290 added filters in JDK 9; JEP 415 added context-specific filters through a filter factory in JDK 17.
The best defence remains not to deserialize untrusted data at all. Filters are for the systems that still do: RMI endpoints, old session stores, message formats nobody has migrated yet.
How a pattern filter decides
A pattern filter is a list separated by semicolons. Limit patterns contain an equals sign: maxdepth, maxrefs, maxbytes and maxarray; they are checked before any class pattern, and exceeding one rejects the stream. Class patterns are matched left to right against the class name: a name matches exactly, pkg.* matches a package, pkg.** matches a package and its subpackages, a trailing * matches a prefix, a prefix ending in / names a module, and a leading ! turns a match into a rejection. Arrays are matched by their element type. If nothing matches, the result is undecided.
Undecided is the trap. An undecided class is not rejected by the stream; deserialization continues. An allowlist only becomes an allowlist when it ends with !*, or when the filter is wrapped by ObjectInputFilter.rejectUndecidedClass, which maps undecided to rejected.
Worked example, observed
The probe below serializes an application class Order (with an ArrayList inside), a stand-in gadget class, the gadget wrapped in an allowed class, and a two-million-element int array, then reads each through different filters. It was run with JDK 23.0.2; the results are what it printed.
Java, shortened from the probe that produced the table below.
String app = "FilterProbe$Order;FilterProbe$Wrapper;java.base/*";
ObjectInputFilter allowOnly = ObjectInputFilter.Config.createFilter(app + ";!*");
ObjectInputFilter noCatchAll = ObjectInputFilter.Config.createFilter(app);
ObjectInputFilter limits = ObjectInputFilter.Config.createFilter("maxarray=100000;maxdepth=20;java.base/*;!*");
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
in.setObjectInputFilter(allowOnly); // per stream; must be set before the first readObject
Object o = in.readObject(); // InvalidClassException "filter status: REJECTED"
}| Filter | Stream | Result |
|---|---|---|
| Allowlist ending in !* | Order | Read |
| Allowlist ending in !* | Gadget | Rejected |
| Allowlist ending in !* | Gadget inside an allowed Wrapper | Rejected: every nested class is checked |
| Same allowlist without !* | Gadget | Read: the class was undecided |
| Without !*, wrapped in rejectUndecidedClass | Gadget | Rejected |
| maxarray=100000 | int[2000000] | Rejected before the array is allocated |
Building the allowlist from evidence
Nobody knows the full list of classes an application deserializes; collections, boxed numbers, enums and arrays appear inside the obvious types. Record them. A filter that returns undecided for everything and notes each class costs nothing in behaviour and produces the list from your own test suite or from a shadow period in production. In the probe, reading one Order recorded three classes: FilterProbe$Order, java.util.ArrayList and [Ljava.lang.Object;, the array inside the list.
Illustrative java, checked for syntax; adapt names and storage to your system.
Set<String> seen = ConcurrentHashMap.newKeySet();
ObjectInputFilter recorder = info -> {
if (info.serialClass() != null) seen.add(info.serialClass().getName());
return ObjectInputFilter.Status.UNDECIDED; // observe only
};Filter decisions are also logged to the java.io.serialization logger when logging is enabled, which gives the same evidence without code changes. Turn the recorded names into patterns, prefer exact names and narrow packages over .**, add the catch-all reject, and keep the recorder running in shadow until no new names appear.
JVM-wide and per-context filters
The jdk.serialFilter system or security property sets one JVM-wide filter, for example -Djdk.serialFilter="com.example.**;java.base/*;!*". That is the right baseline for a service that deserializes in one place. Applications assembled from many libraries have different needs per call site, which is what JEP 415 addresses: a filter factory, set with jdk.serialFilterFactory or ObjectInputFilter.Config.setSerialFilterFactory, is called for every new stream and decides which filter applies, typically by combining a thread-local or library-specific filter with the JVM-wide one using ObjectInputFilter.merge and finishing with rejectUndecidedClass. The order matters: a merged filter rejects if either rejects, and only allows when one allows and neither rejects.
What a filter does not protect against
A filter decides on class names, array sizes, depth, references and stream length. It does not look at field values, and it cannot make an allowed class safe: if an allowed class has a readObject with dangerous side effects, the filter lets it run. That is why a narrow list of the classes you recorded is worth more than a broad package pattern, and why widely used library packages should never be allowed wholesale on an endpoint that faces untrusted input. Filters also apply only to Java serialization through ObjectInputStream. Other deserializers in the same application, such as JSON libraries with polymorphic typing enabled, XML decoders or binary formats with class registration, need their own controls, which the inventory below should cover too.
Rollout steps
- Inventory every place that reads serialized Java objects, including frameworks and libraries, and note where each stream comes from.
- Deploy the recorder, or enable the java.io.serialization logger, in a staging environment and then in production shadow mode.
- Write the JVM-wide filter from the recorded names, with limits and the catch-all reject, and run the full test suite against it.
- Enable it in production with rejection logging and an alert, so a missed class shows up as a clear failure rather than a silent data problem.
- Add per-stream or factory filters only where call sites genuinely differ.
- Plan the migration of each untrusted endpoint to a data format that does not instantiate arbitrary classes.
Failure modes
- An allowlist without
!*, which reads like protection and rejects nothing that is not explicitly listed with!. - Allowing
java.base/*wholesale on an endpoint that never needs most of it; narrow it to the collection and value types you recorded. - Setting the filter after the first
readObjecton a stream; it must be set before reading starts. - No limits, so a small stream that declares a huge array or a deep graph exhausts memory even with an allowlist.
- A filter factory that returns only the stream’s own filter and drops the JVM-wide one.
- Treating filters as a reason to keep accepting serialized objects from untrusted sources instead of migrating the format.
Verification
- Unit tests that read known-good streams and a set of disallowed classes through the production filter.
- A test that the filter string ends with
!*or that the factory wraps withrejectUndecidedClass. - A shadow period with the recorder or
java.io.serializationlogging, ending when no new classes appear. - An inventory of every
ObjectInputStreamin the codebase and its dependencies, so no reader is left unfiltered.
Sources and scope
Checked against these sources on 22 October 2026. Examples and figures are illustrative, not measurements.
End every allowlist with !* or wrap it in rejectUndecidedClass, build it from recorded classes, add maxarray and maxdepth limits, and merge per-stream filters with the JVM-wide one.