Skip to main content

Java Serialization in 2026: Why It’s Dangerous and What Replaced It

Why ObjectInputStream.readObject() on untrusted bytes is dangerous, shown with harmless stand-ins: serialVersionUID drift, code that runs before your cast, and a 37-byte stream that asks for 1 GB. Then the JDK’s answer (JEP 290 filters, JEP 415 filter factories), records, and a measured size and speed comparison with Jackson 3 and Protobuf, all from a runnable companion repo.

A class gets a new field, somebody redeploys one service before the other, and the log fills with java.io.InvalidClassException: local class incompatible. That is the polite failure. The impolite one is that the same ObjectInputStream.readObject() call, pointed at bytes from the network, will load whichever class those bytes name and run its code before your program ever sees the result. This article starts with the polite failure, shows the impolite one using a harmless stand-in (no exploit, no real library gadget), walks through the JDK’s answer — deserialization filters, JEP 290 and JEP 415 — looks at what records changed, and ends with a measured size and speed comparison against Jackson 3 JSON and Protobuf.
Versions. JDK 25.0.4.1+1 (Temurin, LTS), Jackson 3.2.3 (tools.jackson.core:jackson-databind, latest release in Maven Central’s maven-metadata.xml when this was written), protobuf-java 4.36.2, JMH 1.37, JUnit Jupiter 5.11.0, on a 2-vCPU x86-64 virtual machine. JEP 290 is listed as Closed / Delivered in release 9 and JEP 415 as Closed / Delivered in release 17 on openjdk.org/jeps. Every code block and output line below comes from the serialization module of the java-core-examples repository. There is no docs folder on purpose — deeper material is in the collapsible sections here.

The bytes carry a version number, and the reader checks it

Serialization turns an object graph into bytes (ObjectOutputStream.writeObject) and back (ObjectInputStream.readObject). A class opts in by implementing the marker interface Serializable. Along with the field values, the stream stores the class name and a 64-bit number called the serialVersionUID. When the reader loads the class, it compares the number in the stream with the number of its own copy of the class. If they differ it refuses, with InvalidClassException. You can see the number in the stream directly. The demo writes a Ticket whose UID is declared as 1L, finds the eight bytes after the class name, then changes the last one to 2 to play the part of a writer running a different version of the class (UidInStreamDemo.java):
/** Offset of the 8-byte UID: magic+version (4), TC_OBJECT (1), TC_CLASSDESC (1), name length (2), name. */
static int uidOffset(Class<?> c) { return 4 + 1 + 1 + 2 + c.getName().length(); }

public static void main(String[] args) throws Exception {
    byte[] bytes = Wire.write(new Ticket());
    int off = uidOffset(Ticket.class);
    long inStream = java.nio.ByteBuffer.wrap(bytes, off, 8).getLong();
    System.out.println("declared serialVersionUID = " + ObjectStreamClass.lookup(Ticket.class).getSerialVersionUID());
    System.out.println("UID found in the stream   = " + inStream);

    bytes[off + 7] = 2;   // pretend the writer was running version 2 of the class
    System.out.println("patched stream UID        = " + java.nio.ByteBuffer.wrap(bytes, off, 8).getLong());
    try {
        Wire.read(bytes);
    } catch (InvalidClassException e) {
        System.out.println("InvalidClassException: " + e.getMessage());
    }
Output (01-uid-in-stream.txt):
declared serialVersionUID = 1
UID found in the stream   = 1
patched stream UID        = 2
InvalidClassException: com.ankurm.serialization.UidInStreamDemo$Ticket; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 1
Writer JVMclass Account v1bytes on the wirename + UID + fieldsReader JVMclass Account v2same UID: object rebuiltdifferent UID: InvalidClassExceptionThe reader decides from one number; it does not compare the fields.
The diagram shows where the check happens: in the reader, against the number inside the stream. What the demo above did by patching one byte happens in real life when two versions of a class meet. That is the next experiment: one class compiled twice, version 1 with two fields and version 2 with a third (sources in src/versions/). First with no serialVersionUID declared:
$ # implicit serialVersionUID (none declared)
$ java -cp v1 DriftWrite
wrote 113 bytes; serialVersionUID in stream = 1201216851776330172
$ java -cp v2 DriftRead   # v2 adds a field
this class's serialVersionUID = -732213015030957059
InvalidClassException: com.ankurm.serialization.drift.Account; local class incompatible: stream classdesc serialVersionUID = 1201216851776330172, local class serialVersionUID = -732213015030957059
Nobody edited a number, yet the two UIDs differ. When a class does not declare one, the JDK computes a default from the class’s name, interfaces, fields and methods, so adding a field changes it. The same experiment with serialVersionUID = 1L declared in both versions (03-drift-explicit.txt):
$ # explicit serialVersionUID = 1L
$ java -cp v1 DriftWrite
wrote 113 bytes; serialVersionUID in stream = 1
$ java -cp v2 DriftRead   # v2 adds a field
this class's serialVersionUID = 1
read: Account[owner=asha, balance=500, email=null]
Declaring the UID is a promise, not a safeguard. With 1L the old bytes load into the new class, and the field the writer never knew about arrives as null. That is the compatible case. The Java Object Serialization Specification also lists incompatible changes (for example changing a field’s type, or removing Serializable) that fail even with a matching UID; I did not run those, so treat that list as spec-reading rather than something verified here. The practical rule is: declare the UID yourself for any class whose bytes leave the process, and bump it on purpose when you break compatibility.
Going deeper

Deserialization runs code from the class named in the bytes, before you can check the type

Here is the part that makes readObject() on untrusted input dangerous. The stream says which class to build. ObjectInputStream loads that class from your classpath, allocates it without calling its constructor, and then calls the class’s own readObject method if it has one. Your code only regains control when readObject() returns, which is after all of that has happened. A cast such as (Greeting) in.readObject() is therefore a check that runs too late to matter. To show this without building anything harmful, the demo defines a class Noisy whose readObject prints one line. The application writes a Noisy but reads it as if expecting a Greeting. Then the same bytes go through an allow-list filter that only admits Greeting (ReadObjectRunsCodeDemo.java):
/** A class that happens to be on the classpath and has a readObject with a side effect. */
static class Noisy implements Serializable {
    private static final long serialVersionUID = 1L;
    String note = "hello";

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        System.out.println("  >>> Noisy.readObject() is running -- code of the class named in the stream");
    }
}

/** The class the application thinks it is reading. */
record Greeting(String text) implements Serializable {}
public static void main(String[] args) throws Exception {
    byte[] bytes = Wire.write(new Noisy());
    System.out.println("application expects a Greeting and writes: String greeting = (Greeting) in.readObject()");
    try {
        Greeting g = (Greeting) Wire.read(bytes);
        System.out.println("got " + g);
    } catch (ClassCastException e) {
        System.out.println("ClassCastException AFTER the side effect: " + e.getMessage());
    }

    System.out.println();
    System.out.println("same bytes, allow-list filter that only admits Greeting:");
    ObjectInputFilter onlyGreeting = ObjectInputFilter.Config.createFilter(
            "com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting;!*");
    try {
        Wire.read(bytes, onlyGreeting);
    } catch (InvalidClassException e) {
        System.out.println("InvalidClassException: " + e.getMessage());
    }
Output (04-readobject-runs-code.txt):
application expects a Greeting and writes: String greeting = (Greeting) in.readObject()
  >>> Noisy.readObject() is running -- code of the class named in the stream
ClassCastException AFTER the side effect: class com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy cannot be cast to class com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting (com.ankurm.serialization.ReadObjectRunsCodeDemo$Noisy and com.ankurm.serialization.ReadObjectRunsCodeDemo$Greeting are in unnamed module of loader 'app')

same bytes, allow-list filter that only admits Greeting:
InvalidClassException: filter status: REJECTED
1. bytes arrivefrom disk, socket,cache, cookie2. class loadedname comes fromthe bytes3. readObject()of THAT classruns4. object returnedto your code5. your castfails — too latefilter: checkInput(class) -> REJECTEDstops the sequence before step 3The side effect in step 3 happened even though the application eventually rejected the type.
The diagram puts the filter where it has to be: between steps 1 and 3, so the class is refused before any of its code is involved. This demo used a class I wrote. Real attacks use classes that already exist in the JDK or in libraries on your classpath and chain their readObject methods together; I deliberately did not build one, and this article does not contain one. The point that carries over is that the attacker chooses from every serializable class on your classpath, and you do not get a vote after the fact.
The same idea exists in JSON, with different knobs. Libraries that let a document name the Java class to instantiate (polymorphic typing) recreate this problem in text form. The companion article on this site covers Jackson’s version of it: Jackson Security Best Practices: Defending Against Deserialization Gadget Attacks.
Going deeper

A stream can exhaust your memory or stack with no gadget at all

You do not need a clever class to hurt a server. The stream itself says how long an array is, and the reader believes it. The demo serializes a ten-byte array, then overwrites the four length bytes so the stream claims byte[1_000_000_000] (about 1 GB). The forged stream is 37 bytes. It then tries to read it with no filter and with maxarray=100000, under a 256 MiB heap (set by run-all.sh). It also builds a legitimate chain of 200 linked nodes and reads it with and without maxdepth=50 (ResourceLimitsDemo.java):
/** Serialize a byte[10], then patch the declared array length to 'claimed'. The array length int is 14 bytes from the end. */
static byte[] forgedArray(int claimed) throws IOException {
    byte[] b = Wire.write(new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
    Wire.putInt(b, b.length - 14, claimed);
    return b;
}
max heap = 256 MiB
forged stream is 37 bytes long but claims a byte[1000000000]
no filter            -> OutOfMemoryError: Java heap space
maxarray=100000      -> InvalidClassException: filter status: REJECTED
    filter saw: class=class [B arrayLength=-1 depth=1 streamBytes=21 -> UNDECIDED
    filter saw: class=class [B arrayLength=1000000000 depth=1 streamBytes=27 -> REJECTED
maxarray, logged     -> InvalidClassException: filter status: REJECTED

a legitimate-looking chain of 200 nodes is 1324 bytes
no filter            -> accepted: Node
maxdepth=50          -> InvalidClassException: filter status: REJECTED
Without a filter, a 37-byte message asks the JVM for a gigabyte and the answer is OutOfMemoryError. With the filter, the request is rejected when the reader sees the array header, before allocation; the logged run shows what the filter was told: arrayLength=1000000000 and streamBytes=27. (On a machine with a larger default heap the unfiltered read may allocate the array and then fail with EOFException instead; the script pins the heap so the result is repeatable.) The deep chain illustrates maxdepth: the unfiltered read accepted it, while maxdepth=50 refused it. Deserialization of nested objects is recursive, so depth is a stack-usage question; I did not measure how deep an unfiltered stream can go before StackOverflowError, only that the limit is enforced. The third limit has a subtlety worth seeing. maxbytes compares the number of bytes read so far against the limit, but only when the filter is called. Here is a single 50,000-byte array next to a list of 5,000 Integer objects, both read with maxbytes=10000 (output 05-resource-limits.txt):
maxbytes=10000, one 50 KB array    -> accepted: byte[]
an ArrayList of 5000 integers is 50125 bytes
maxbytes=10000, 5000 integers       -> InvalidClassException: filter status: REJECTED
maxbytes=1000000, 5000 integers     -> accepted: ArrayList
maxbytes alone does not stop a single big array. The filter runs at the array header, when only a few bytes of the stream have been read, so the 50 KB array passed; the list of objects was rejected because each object gives the filter another look. The array-specific limit is maxarray, so set both, plus maxdepth. The JDK has a fourth limit, maxrefs, for the number of internal references; I did not demo it.
Going deeper

The fix the JDK shipped: deserialization filters, first per stream, then per context

JEP 290 (delivered in Java 9) added ObjectInputFilter. A filter receives a description of what the stream is about to do — which class, which array length, how deep, how many bytes so far — and answers ALLOWED, REJECTED or UNDECIDED. You can write one in code, or describe it as a pattern string such as maxdepth=5;maxarray=100000;com.example.dto.*;!*: limits first, then class patterns evaluated in order, where the final !* rejects everything not already allowed. That is an allow-list, and it is the important design choice. A block-list of known-bad classes goes stale the day someone finds the next one. You can attach a filter to one stream with ObjectInputStream.setObjectInputFilter, which is what the demos above did through the small helper Wire.java. You can also set a process-wide filter without touching code, using the jdk.serialFilter system property or the security property of the same name. The Javadoc for ObjectInputFilter.Config documents both; they apply to every stream that does not set its own. One process-wide pattern is blunt, because a login endpoint and a cache reader have different legitimate classes. JEP 415 (delivered in Java 17) added a filter factory: a BinaryOperator<ObjectInputFilter> that the JDK calls whenever a stream is created, handing it the filter currently in effect and the one requested for that stream, and using whatever it returns. A factory can consult a ThreadLocal, a scoped value or the calling class, so each part of the program gets its own allow-list on top of a global baseline. The demo below sets a factory that merges a per-request filter with the process filter, and runs with -Djdk.serialFilter set (FilterFactoryDemo.java):
BinaryOperator<ObjectInputFilter> factory = (current, requested) -> {
    ObjectInputFilter ctx = CONTEXT.get();
    // 'current' is the filter already in effect for the stream (the process-wide one on first call)
    ObjectInputFilter merged = ObjectInputFilter.merge(ctx, current);
    return ObjectInputFilter.merge(requested, merged);
};
ObjectInputFilter.Config.setSerialFilterFactory(factory);
System.out.println("factory installed; installing a second one:");
try {
    ObjectInputFilter.Config.setSerialFilterFactory(factory);
} catch (IllegalStateException e) {
    System.out.println("IllegalStateException: " + e.getMessage().substring(0, e.getMessage().indexOf(':')));
}
Output (07-filter-factory.txt):
$ java -Djdk.serialFilter="maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*" FilterFactoryDemo
jdk.serialFilter (system property) = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
Config.getSerialFilter()            = maxdepth=10;com.ankurm.serialization.*;java.lang.*;!*
factory before                      = java.io.ObjectInputFilter$Config$BuiltinFilterFactory
factory installed; installing a second one:
IllegalStateException: Cannot replace filter factory
context A (Ok + String allowed):
  read Ok     -> Ok[s=fine]
  read String -> a plain string
  read 50-deep chain -> InvalidClassException: filter status: REJECTED
context B (String only):
  read String -> a plain string
  read Ok     -> InvalidClassException: filter status: REJECTED
new ObjectInputStreamin some requestFilter factory(current, requested) -> mergedset once per JVMfilter for THISstreamjdk.serialFiltermaxdepth=10;…;!*context (ThreadLocal)A: Ok+StringBoth layers apply: in context A the Ok record loads, yet a 50-deep chain is still rejected by the baseline.In context B the same Ok bytes are rejected, because that request only allows String.
The transcript shows the three behaviours that matter. A second setSerialFilterFactory call throws IllegalStateException; context A reads Ok and String but the baseline maxdepth=10 still rejects the 50-deep chain; and context B, a different request, is refused the very same Ok bytes. The Javadoc adds two rules you will trip over: the factory can be set only once, and only before any ObjectInputStream has been created; and a factory configured with the jdk.serialFilterFactory property cannot be replaced by setSerialFilterFactory, so a command-line choice is not overridden by application code.
An allow-list filter is damage control, not a licence. It narrows what a hostile stream can reach, but every class you allow is still deserialized with its readObject, and the allow-list has to be maintained as the code changes. Also remember the rejection message is terse: filter status: REJECTED names no class. If you need to know what was refused, wrap your filter in one that logs the FilterInfo, as the logged run in the previous section does.
Reference: what the pattern string accepts From the ObjectInputFilter.Config.createFilter Javadoc: limits maxdepth, maxrefs, maxbytes, maxarray take a non-negative number; class patterns can be an exact name, a package wildcard (com.example.*), a recursive wildcard (com.example.**), or a module-qualified form (java.base/*); a leading ! rejects; patterns are evaluated in order and the first match decides. A pattern containing / without a module name, or a limit that does not parse, throws IllegalArgumentException. Besides pattern strings, the ObjectInputFilter interface has allowFilter(Predicate, Status), rejectFilter(...), merge(...) and rejectUndecidedClass(...) helpers; the demos here use merge and createFilter.
Going deeper

Records deserialize through their constructor, so their validation still runs

Ordinary classes are rebuilt without calling any of their constructors, which means the checks you wrote in the constructor are skipped when the object comes from a stream. A forged stream can therefore carry values your constructor would never allow. Records behave differently: the serialization specification says a record is reconstructed by calling its canonical constructor with the deserialized component values. The demo forges the same invalid value, years = -5, into a stream of each kind (RecordsDemo.java):
record AgeRecord(int years) implements Serializable {
    AgeRecord {
        if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
    }
}

static class AgeClass implements Serializable {
    private static final long serialVersionUID = 1L;
    final int years;
    AgeClass(int years) {
        if (years < 0 || years > 150) throw new IllegalArgumentException("years out of range: " + years);
        this.years = years;
    }
    @Override public String toString() { return "AgeClass[years=" + years + "]"; }
}
Output (06-records.txt):
record default serialVersionUID = 0
forged stream with years = -5:
  record -> InvalidObjectException: years out of range: -5
            cause: java.lang.IllegalArgumentException: years out of range: -5
  class  -> AgeClass[years=-5]   (no constructor ran)
The record rejected the forged value: its compact constructor ran, threw IllegalArgumentException, and deserialization surfaced it as InvalidObjectException. The ordinary class happily produced years=-5. Note that records also start from a default serialVersionUID of 0, as the first output line shows, and that their serialization ignores custom readObject and writeObject methods; that second point comes from the specification, not from something I ran.
Records fix invariants, not exposure. A record’s constructor can refuse a bad value, but readObject() still loads whichever class the stream names. Everything in the previous sections still applies: use a filter, and prefer not to accept Java-serialized data from outside at all.
Going deeper

What replaced it: a data format that does not name classes

The replacement for Java serialization at a system boundary is a format that carries data and leaves the choice of class to your code. Two common choices are JSON (with Jackson) and Protobuf. The demo encodes one small Order — an id, two strings and three line items — in all three and checks that each round-trips to an equal object (FormatSizeDemo.java, encoders in Codecs.java):
Java serialization : 382 bytes, starts with aced0005 (magic aced0005)
Jackson 3 JSON     : 223 bytes
Protobuf wire      : 80 bytes

JSON text: {"id":1000042,"customer":"Asha Mehta","currency":"INR","lines":[{"sku":"BK-JAVA-25","quantity":2,"priceMinor":49900},{"sku":"BK-JVM-INT","quantity":1,"priceMinor":79900},{"sku":"CBL-USB-C","quantity":3,"priceMinor":19900}]}

round trips equal  : java=true json=true protobuf=true

JSON cannot name a class to instantiate unless you opt in. Feeding it a type hint:
  parsed as Order[id=1, customer=x, currency=INR, lines=[]]
Java serialization is the largest because it spells out class names, field names and type descriptors inside the stream. JSON repeats its field names in every line item. Protobuf uses numbered fields and variable-length integers, so it is the smallest. Speed is measured with JMH: one round trip (encode, then decode) per call, 2 forks with 5 warm-up and 8 measured iterations of one second each (SerializationBenchmark.java, raw scores in 09-jmh-raw.txt):
Average time per encode+decode round trip, ns — JMH, JDK 25, 2 vCPUjava serialization11317 ns/opjackson 3 json3107 ns/opprotobuf wire (hand-coded)3736 ns/opRaw scores and confidence intervals are in serialization/output/09-jmh-raw.txt
Benchmark                                 Mode  Cnt      Score     Error  Units
SerializationBenchmark.jacksonJson        avgt   16   3107.305 ± 467.508  ns/op
SerializationBenchmark.javaSerialization  avgt   16  11316.514 ± 538.967  ns/op
SerializationBenchmark.protobufWire       avgt   16   3736.484 ± 283.111  ns/op
In this run Java serialization took about 3.6x as long as Jackson (11317 ns against 3107 ns), which is a large, stable gap. The surprise is that Protobuf did not beat JSON: 3736 ns against 3107 ns. Two caveats explain why I would not generalise from that. The Protobuf side here is written by hand against CodedOutputStream and CodedInputStream (following order.proto) and allocates a scratch buffer per line item, so it is neither protoc-generated code nor tuned. And the object is tiny. The size result is solid; the speed ordering between the last two is not a verdict on either library.
What JSON bought here, and what it did not. I fed Jackson a document with an @class field naming java.lang.ProcessBuilder. Jackson 3 with default settings did not instantiate anything and did not fail: it parsed an ordinary Order and ignored the unknown property, as the last lines of the transcript show. That is the safe default, but it is a default: enabling polymorphic type handling with a permissive validator is how JSON re-creates the class-named-by-the-input problem, which is what the Jackson security article linked above is about. Jackson 3 also moved to the tools.jackson packages, which is why the imports in the demo differ from Jackson 2 code you may have seen.
Reference: what the Protobuf encoding looks like and how this was measured order.proto declares Order { int64 id = 1; string customer = 2; string currency = 3; repeated Line lines = 4; } and Line { string sku = 1; int32 quantity = 2; int64 price_minor = 3; }. The hand-written codec follows those field numbers, so the bytes are valid Protobuf wire format; I did not run protoc, so generated-code timings are not measured here. The JMH annotation-processor setting in the module’s pom.xml is required on JDK 25, which does not discover processors implicitly. Regenerate everything with scripts/run-all.sh (needs JDK25_HOME). Tests that pin the size ordering and the round trips are in SerializationTest; the run summary is in 10-tests.txt:
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.851 s -- in com.ankurm.serialization.SerializationTest
Going deeper

Should you still use Java serialization? Only inside a wall you control

Honest advice (opinion, informed by the experiments above). Do not call readObject() on bytes that cross a trust boundary: the network, a file users upload, a cookie, a cache that other systems write. If you inherit such code, set jdk.serialFilter to an allow-list with maxdepth, maxarray, maxbytes and !* today, then migrate the boundary to JSON or Protobuf with explicit schemas. Inside a single trusted process or a test fixture, plain serialization is fine, and records make it less error-prone. Whatever you keep, declare serialVersionUID, and treat an InvalidClassException in production as a deployment-ordering bug, not a serialization bug.
The checklist that falls out of the output: the UID travels in the stream; the class named in the stream runs before your cast; a 37-byte message can request a gigabyte; limits and an allow-list stop that; records re-run their constructor; and a format that names no classes removes the whole category.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.