A convenient serializer can turn an object graph into bytes and later rebuild it with one function call. That convenience becomes a security problem when the bytes come from a request, message, uploaded file, cache entry, or other source an attacker can influence. Some native object formats carry more than plain values: they can encode types, object relationships, or instructions that cause application-defined behavior during reconstruction.

If an application treats such input as ordinary data, parsing may cross a trust boundary before validation gets a chance to help. The result can range from unexpected object state to dangerous code paths, depending on the serialization system and the classes available to it.

The defensive mental model is straightforward: untrusted input should describe data, not choose application behavior. This article explains why that distinction matters, how to design the boundary around it, and what to check when legacy native serialization cannot be removed immediately.

Deserialization can do more than parsing

Parsing a simple data format and reconstructing native objects are different operations.

Suppose an endpoint expects a saved filter containing two fields:

{
  "status": "open",
  "limit": 20
}

A narrow parser can produce a map containing a string and a number. The application then checks that status is allowed, that limit is an integer within range, and finally constructs its own SavedFilter value.

The important sequence is:

untrusted bytes
    -> generic data
    -> validation
    -> application object

The application decides which type to construct and when to construct it.

A native object deserializer may work differently. Its input format can contain enough information to select application types and rebuild object graphs directly. Some serialization ecosystems also invoke constructors, property setters, callbacks, conversion hooks, or other behavior as part of reconstruction. Exact behavior is format- and platform-dependent, but the security consequence is the same: the deserializer may act on attacker-controlled structure before the application has reduced that structure to the small set of states it intended to accept.

That is why “we validate the object afterward” can be too late. Validation can reject the final value, but it cannot undo behavior that already occurred while producing that value.

State the threat model clearly

This control is intended for data that crosses a trust boundary. Examples include request bodies, cookies or client-held state, queue messages from less-trusted producers, imported files, and records that another service or tenant can modify.

The threat is not merely malformed syntax. The attacker can deliberately choose serialized structure to make the deserializer instantiate an unintended type, create an unexpected graph, or trigger behavior exposed by the runtime’s deserialization mechanism.

Avoiding native object deserialization reduces that attack surface because the parser no longer gets authority to choose arbitrary application types. It does not make the resulting data trustworthy. You still need schema validation, authorization, size limits, and ordinary application checks. It also does not protect data at rest from modification; use integrity protection where tampering itself must be detected.

The boundary matters more than the format’s name. A native serialization format may be reasonable for data that is generated, stored, and consumed entirely inside one tightly controlled trust domain. The same format can be a poor choice once an untrusted party can influence its bytes.

Make type selection a server decision

The safer design is to accept a deliberately small data model at the boundary.

For the saved-filter example, the application might define an input schema with exactly two fields:

SavedFilterInput
  status: one of ["open", "closed"]
  limit: integer from 1 through 100

The processing path then becomes conceptually:

raw = parse_data(request.body)
input = validate_saved_filter(raw)
filter = SavedFilter(status=input.status, limit=input.limit)

This example is intentionally language-neutral. In production, use a maintained parser and the validation facilities appropriate to your platform rather than writing a parser yourself.

Notice what changed. The client can choose values inside the declared schema, but it cannot name a server class, request a different object type, or supply arbitrary object relationships. Type selection remains in trusted application code.

This separation also makes authorization easier to reason about. If a field such as owner_id must come from the authenticated session rather than the request, the server can construct the object with that trusted value instead of accepting it from serialized state.

A data format is not automatically a safe schema

Switching from a native object format to JSON, CBOR, or another data-oriented representation is useful only if the application keeps control over reconstruction.

Many frameworks support polymorphic binding: a field in the document identifies a concrete subtype, and the framework instantiates that type automatically. This can reintroduce part of the original problem if untrusted input is allowed to select from a broad set of application classes.

Sometimes polymorphism is genuinely needed. Consider an API that accepts one of two notification preferences. A constrained design can map fixed protocol names to fixed input types:

"email" -> EmailPreferenceInput
"sms"   -> SmsPreferenceInput
anything else -> reject

That is different from resolving an arbitrary class name supplied by the client. The accepted vocabulary is part of the protocol, small enough to review, and independent of whichever classes happen to exist in the application at runtime.

An allowlist helps when dynamic type selection cannot be avoided, but it should be narrow. Allowlisting a large package, namespace, or class hierarchy can become fragile as new classes are added. Prefer a fixed mapping of protocol-level identifiers to specifically reviewed data types.

Keep validation before behavior

A useful boundary has two phases: interpret the input as inert data, then decide what the application will do with it.

During the first phase, reject values that do not match the expected structure. Check required fields, types, lengths, numeric ranges, collection sizes, and allowed alternatives. Put resource limits on parsing as well; a structurally valid document can still consume unreasonable memory or processing time if nesting or collection sizes are unbounded.

During the second phase, construct application state and perform operations only after validation and authorization succeed. Avoid validation mechanisms that themselves execute arbitrary user-selected code. The point is to keep the initial interpretation step narrow and predictable.

This ordering gives each layer a clear job:

parser       -> syntax and basic representation
schema       -> permitted shape and values
authorizer   -> permitted action for this principal
application  -> deliberate behavior

No one layer replaces the others. A schema can say that project_id is a valid identifier, but authorization must still decide whether the current user may modify that project.

Do not treat signatures as permission to deserialize anything

Teams sometimes protect serialized blobs with a message authentication code or digital signature and then assume native deserialization is harmless. Integrity protection can be valuable, but its guarantee is narrower.

If verification succeeds, the application knows that the blob was produced by a holder of the relevant signing or authentication key and has not been modified without detection under the scheme’s assumptions. That does not prove that the serialized object is appropriate for the current codebase, purpose, or privilege level.

Old signed data may outlive classes whose behavior later changes. Multiple services may share a key even though they should not share deserialization authority. A signing endpoint with overly broad inputs may also create blobs that another component interprets more powerfully than intended.

So authenticate data when you need integrity or provenance, but keep the deserialization boundary narrow as a separate control. Cryptographic authenticity and safe interpretation answer different questions.

Handle legacy native serialization as a migration problem

Removing a native format immediately is not always practical. A system may have years of stored sessions, job payloads, or application records in that representation.

Start by separating trusted historical storage from new untrusted ingress. Do not expose the legacy deserializer directly to requests merely because the same code reads internal records. Introduce a new data-oriented format for newly created external state and convert old records through a controlled migration path.

Where the platform provides a restricted binder, type filter, or equivalent mechanism, use it as defense in depth while migration is underway. Configure it with the smallest explicit set of types required by the legacy data. Treat this as containment rather than proof that arbitrary native serialization has become suitable for hostile input.

Operationally, inventory the places that deserialize the legacy format. Record the source of each byte stream, who can modify it, and which types the reader can reconstruct. This often reveals that some uses are entirely internal while others quietly cross trust boundaries through caches, queues, or client-held state.

If changing the format breaks old data, plan the failure behavior. Expiring old sessions, reissuing cached state, or migrating stored records may be preferable to keeping a dangerous compatibility path indefinitely. The right choice depends on the availability and recovery requirements of the application.

Common fixes that leave the boundary exposed

Checking the final object’s type is insufficient when dangerous behavior can occur during reconstruction. The check happens after the risky step.

A denylist of known-dangerous classes has a similar maintenance problem. It assumes the team can enumerate every type that should never be instantiated. New dependencies and new application classes can invalidate that assumption without changing the deserialization call itself.

Catching exceptions does not address the trust problem either. Exception handling is useful for malformed input, but a successfully reconstructed object can still be outside the application’s intended authority.

Another mistake is assuming that “internal” automatically means trusted. A queue may have several producers, a cache may be writable by another service, and a database field may contain data originally supplied by a user. Trace who can influence the bytes, not just where the bytes are stored immediately before deserialization.

Verify the control at the boundary

Tests should prove that the application accepts the intended data model and rejects attempts to expand it.

For a schema-based endpoint, test unknown fields, unknown type identifiers, excessive nesting, oversized collections, wrong scalar types, and valid-looking identifiers that the caller is not authorized to use. If polymorphic input is supported, verify that only the documented protocol variants map to concrete input types.

For a legacy restricted deserializer, test that an unapproved type is rejected before it is instantiated. Also test the migration and fallback behavior so an invalid legacy payload does not silently switch to a broader, less-restricted reader.

Code review can use one simple question: Can these bytes make the runtime choose an application type or execute application behavior before our validation and authorization decisions? If the answer is yes, the boundary deserves redesign or stronger containment.

Keep the boundary boring

The safest input boundary is deliberately uneventful. Untrusted bytes become simple values. The application validates those values, checks the caller’s authority, and then constructs only the state it intended to create.

Native object serialization can be convenient inside a controlled trust domain, but convenience is not a reason to give external data authority over runtime types. When input can be influenced by an attacker, keep type selection and behavior on the trusted side of the boundary. That design is easier to review, easier to test, and less likely to turn a parser into an unexpected execution path.