skip to content

Deserialization Safety

Compile-time codegen avoids the gadget-chain risk of reflective deserializers, but untrusted input still needs validation in init, skepticism about polymorphic discriminators, and proper handling of SerializationException. This is the security angle interviewers raise on any parsing topic.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

How and why do you enforce domain invariants on a @Serializable class so that an invalid instance can never be deserialized?

level: juniorimportance: must knowfreq 55%

answer

  1. Decoder calls the constructor → init {} runs
  2. require() throws IllegalArgumentException
  3. Make illegal states unrepresentable
  4. Defaults must also pass init {} checks
  5. Catch SerializationException + IllegalArgumentException at boundary

basics

~10 s

Put checks in the class's init {} block using require(). Deserialization calls the real constructor, so the checks run too. Bad input throws instead of producing a broken object.

solid answer

~40 s

kotlinx.serialization's generated deserializer calls the class's primary constructor with decoded values, which means the `init {}` block runs for deserialized instances exactly as for hand-constructed ones. Use `require(condition) { message }` inside `init {}` to assert invariants (non-empty, ranges, enum-like strings, cross-field consistency). A failed `require` throws `IllegalArgumentException`, so an object violating the invariant is never created — there is no path where a partially-valid instance escapes. This centralizes validation in the type itself ('make illegal states unrepresentable') instead of scattering checks across call sites. Note `init {}` runs in declaration order with property initializers, and that decoding wraps it: a thrown `IllegalArgumentException` surfaces from `decodeFromString`. Catch both it and `SerializationException` at the trust boundary.

go deeper

for a junior

Knows to put require() in init {} and that it runs on deserialization; can write a basic example.

for a middle

Explains require vs check, where the exception surfaces, and that defaults must satisfy invariants.

for a senior

Frames it as 'illegal states unrepresentable', distinguishes validate-only vs normalize (custom serializer), wires boundary catching.

for a principal

Sets a team convention for invariant location, considers performance of validation on hot decode paths and consistency across modules/contracts.

## Why init {} is the right place When the kotlinx.serialization compiler plugin generates `deserialize`, it reads each property value and then **invokes the primary constructor** of your class. Kotlin runs **property initializers and `init {}` blocks in source order** as part of construction. Therefore validation written in `init {}` executes for *every* instance — whether created in code or rebuilt from JSON. ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable data class Account(val id: String, val balanceCents: Long, val email: String) { init { require(id.isNotBlank()) { "id required" } require(balanceCents >= 0) { "balance cannot be negative" } require('@' in email) { "invalid email" } } } ``` Decoding hostile JSON like `{"id":"","balanceCents":-1,"email":"x"}` throws **`IllegalArgumentException`** from `require`, so no `Account` with a negative balance ever exists. ## require vs check vs IllegalStateException - `require(...)` → throws `IllegalArgumentException`; use for *argument/precondition* validation (the deserialized values are inputs). This is the idiomatic choice here. - `check(...)` → throws `IllegalStateException`; for internal state, not input. - Throwing inside `init {}` is fine; it aborts construction. ## 'Make illegal states unrepresentable' By validating in the type, every consumer of `Account` can trust it. You avoid the anti-pattern of constructing the object first and validating later, where a window exists in which an invalid object is live (and might be logged, persisted, or passed on). ## Boundary handling A deserialized invariant violation surfaces as `IllegalArgumentException` out of `Json.decodeFromString`. Malformed JSON surfaces as `SerializationException`. At an untrusted boundary catch both: ```kotlin fun parseAccount(json: String): Account? = try { Json.decodeFromString<Account>(json) } catch (e: SerializationException) { null // malformed JSON / missing fields } catch (e: IllegalArgumentException) { null // failed init {} invariant } ``` ## Gotchas - Default values still flow through `init {}`, so defaults must also satisfy invariants. - `init {}` cannot fix data (it only accepts/rejects); for normalization use a private constructor + factory or a custom serializer. - Validation in `init {}` does not run for the *very rare* `@OptIn`-gated constructor-bypass paths; in standard kotlinx.serialization usage the constructor is always called.

  • What exception type does a failed require() in init {} produce, and where does it surface?
    IllegalArgumentException, which propagates out of Json.decodeFromString — so callers must catch it alongside SerializationException.
  • Can init {} normalize/clean a value, e.g. trim whitespace?
    No — init {} can only validate. To transform, store the normalized value via a property initializer expression or use a custom serializer/factory.

init {} is a bouncer at the only door: every guest (including the one arriving as JSON) gets checked before entering.

saying these in an interview costs you the question

  • Validating in a separate method called after construction (leaves a window for invalid objects)
  • Using check()/IllegalStateException for input validation
  • Assuming default property values bypass init {} checks
  • Forgetting that init {} throws on bad deserialized data and not catching it

context

open as a page

When decoding untrusted input with kotlinx.serialization, what exceptions can be thrown and how should you handle them at a trust boundary?

level: middleimportance: must knowfreq 50%

basics

~10 s

Bad or malformed input makes decodeFromString throw. Catch SerializationException (and IllegalArgumentException from your own checks), turn it into a safe error response, and never leak the raw message or stack trace to the caller.

open as a page

Why is kotlinx.serialization's @Serializable generally considered safer against arbitrary-gadget deserialization attacks than reflection-based deserializers like Java's ObjectInputStream or Jackson default typing?

level: middleimportance: must knowfreq 60%

basics

~20 s

Kotlin generates serialization code at compile time. It only fills fields of types you declared, so attacker JSON cannot make it build a random object or run hidden code the way classic Java deserialization gadgets do.

open as a page

Why must you distrust the polymorphic type discriminator in JSON, and how does sealed-class polymorphism plus a controlled SerializersModule keep deserialization safe?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A JSON 'type' field tells the decoder which subtype to build. If attackers can pick any type, that is dangerous. With sealed classes the choices are a fixed, known list, so an unknown type just fails safely.

open as a page

How do Json configuration flags like ignoreUnknownKeys, coerceInputValues, isLenient, and decodeEnumsCaseInsensitive affect the security/trust posture of deserializing untrusted input?

level: seniorimportance: nice to knowfreq 30%

basics

~10 s

These flags make the parser more forgiving. Forgiving is convenient but can silently accept or change attacker data. For untrusted input, prefer strict settings so unexpected data fails loudly instead of slipping through.

open as a page