Obfuscating a type you serialize with System.Text.Json: [JsonPropertyName] is the contract, the renamed property is not
Reflection-based System.Text.Json reads your DTO by its property names — Order becomes "order" on the wire because the property is literally called Order — which means the moment an obfuscator renames that property to a, the JSON your service produces turns into { "a": …, "b": … } and every consumer and every round-trip breaks, silently, at runtime where no build step can catch it. This post pins down exactly which names the serializer binds to and which it does not: why a bare auto-property is a landmine, why [JsonPropertyName("order")] is the fix that makes the wire name a constant the renamer cannot touch, why a parameterized [JsonConstructor] matches parameters to properties by name so the constructor arguments are pinned too, how [JsonExtensionData] and enum [JsonStringEnumMemberName] behave, and why the source-generated JsonSerializerContext changes the picture entirely. It shows the Nebula configuration that keeps the contract intact — attribute the names, let the members rename — and the round-trip test that proves a protected build still deserializes what it serialized.
Here is a type that works perfectly until the day you obfuscate it:
public sealed class Order
{
public string Customer { get; set; } = "";
public decimal Total { get; set; }
public DateTime PlacedOn { get; set; }
}
Serialize it with the default reflection-based System.Text.Json and you get {"customer":"…","total":…,"placedOn":"…"}. Run the assembly through a renamer and serialize again, and you get {"a":"…","b":…,"c":"…"}. Nothing threw. The build was green. Every consumer of that JSON — a frontend, a sister service, a stored document you read back next week — is now broken, and it broke at runtime, in a place no compiler and no validateOnBuild could ever see.
This is the single most common way obfuscation “breaks serialization”, and it is worth understanding precisely, because the fix is not a blunt keep rule — it is one attribute, and it lets the property keep renaming.
Why the rename reaches the wire
Internal calls survive renaming because they are linked by metadata token: the compiler emits call against a token, and a renamer that rewrites the callee rewrites every token that points at it, so the graph stays connected. Reflection-based System.Text.Json does not use a token. At serialize time it walks your type with reflection, reads each property’s actual CLR Name, applies the configured naming policy, and writes that string as the JSON key:
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// For each property p: key = opts.PropertyNamingPolicy.ConvertName(p.Name);
// p.Name == "Customer" -> "customer"
The JSON key "customer" exists only because the property is named Customer. There is no token joining the string in the file to the member in the assembly — the join is the name. Deserialization runs the same machinery in reverse: it reads the incoming key and matches it, by string, to a property. So when the renamer turns Customer into a, the serializer does exactly what it is told — it reads the property now called a and writes "a" — and the round-trip to anyone expecting "customer" is dead.
The fix is an attribute, not a keep rule
The instinct is to exclude the DTO from renaming. Don’t — that surrenders exactly the type an attacker most wants to read, and it is unnecessary. The wire name should be a constant, not a side effect of the member name, and [JsonPropertyName] makes it one:
public sealed class Order
{
[JsonPropertyName("customer")] public string Customer { get; set; } = "";
[JsonPropertyName("total")] public decimal Total { get; set; }
[JsonPropertyName("placedOn")] public DateTime PlacedOn { get; set; }
}
Now the serializer reads the attribute’s string literal — "customer" — not the property name. The renamer is free to turn Customer into b; the attribute still says "customer", the JSON key is still "customer", and the contract holds. This is the key distinction: the member does get renamed (which is the point of obfuscating), and only the string the serializer reads is pinned. A keep rule would preserve the member name on disk — the opposite of what you want. The attribute preserves the behaviour while the name disappears.
The rule, then, is simple and total: every serialized member gets an explicit name. A single un-attributed property falls back to its CLR name and becomes the one field that breaks. That is easy to enforce in review, and easy to see in a diff.
The constructor is a second name binding
Immutable DTOs hide a second trap. When a type has no parameterless constructor, System.Text.Json deserializes it by matching each JSON property to a constructor parameter — by parameter name, case-insensitively:
public sealed class Order
{
public Order(string customer, decimal total, DateTime placedOn) // param names matter
{
Customer = customer; Total = total; PlacedOn = placedOn;
}
[JsonPropertyName("customer")] public string Customer { get; }
[JsonPropertyName("total")] public decimal Total { get; }
[JsonPropertyName("placedOn")] public DateTime PlacedOn { get; }
}
The serializer pairs the "customer" JSON value with the constructor parameter named customer. That pairing lives in parameter-name metadata — and if your obfuscator rewrites parameter names to p0, p1, p2, the match fails and each property is silently left at its default. Two things keep this safe. Mark the chosen constructor with [JsonConstructor] so the serializer is unambiguous about which one to use, and ensure parameter-name renaming is off for it. Most obfuscators, Nebula included, leave parameter names alone by default — they are not a meaningful attack surface — but when a DTO deserializes through a constructor, that default is load-bearing, so confirm it rather than assume it.
Enums, extension data, and the members that don’t bind by name
A few more members have their own rules. String-serialized enums are written by their member name unless you override it — the same reflection-reads-the-name problem — so pin them explicitly ([.NET 9] gives you [JsonStringEnumMemberName] for exactly this):
[JsonConverter(typeof(JsonStringEnumConverter<Priority>))]
public enum Priority
{
[JsonStringEnumMemberName("low")] Low,
[JsonStringEnumMemberName("high")] High,
}
[JsonExtensionData] on a Dictionary<string, object> is the one member whose property name never reaches the wire — its contents are spliced into the parent object — so it needs no [JsonPropertyName]; the dictionary keys are runtime data, not member names, and renaming never touches them. And [JsonIgnore] members, by definition, are not serialized and carry no contract.
The real escape from reflection: the source generator
Everything above is about the reflection serializer reading a live property name. The source generator sidesteps the walk entirely. A [JsonSerializable] context generates serialization code at compile time, baking each JSON name into the generated metadata as a string constant and reading the property directly:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(order, OrderJsonContext.Default.Order);
The names are resolved before Nebula runs and frozen into the generated context as literals, so renaming the DTO afterwards cannot change what the generated code emits — it already knows which property "customer" means. (The generated OrderJsonContext type and its Default.Order entry point are referenced by your call sites and rename consistently with them, like any other internal link; only a reflective use of the context by name would be a separate dependency.) For a hot DTO you are obfuscating, the source generator is both the faster path and the one with the fewest name hazards — but you still attribute the names you care about, because the generator reads [JsonPropertyName] the same way reflection does.
Prove it with a round-trip on the protected build
Whichever path you pick, the test that matters runs against the obfuscated output, not the debug build — the whole failure mode is that the names only move after Nebula runs. A round-trip against the protected assembly is the one assertion that catches it:
[Fact]
public void Protected_build_round_trips_the_wire_contract()
{
var order = new Order("Acme", 42.50m, new DateTime(2026, 10, 16));
var json = JsonSerializer.Serialize(order);
// The contract, asserted literally — these keys are what consumers depend on.
Assert.Contains("\"customer\":\"Acme\"", json);
Assert.Contains("\"total\":42.50", json);
Assert.DoesNotContain("\"a\":", json); // no leaked renamed member
var back = JsonSerializer.Deserialize<Order>(json)!;
Assert.Equal("Acme", back.Customer); // constructor matched by name
Assert.Equal(42.50m, back.Total);
}
Wire this against the post-obfuscation artifact in CI and the “serialization broke” incident becomes a failed test instead of a customer email. The principle underneath it is the one worth carrying to every reflection-driven framework you obfuscate: a name a framework reads at runtime is a contract, and a contract belongs in a string literal the obfuscator cannot move — not in a member name it is paid to.
Nebula.NET renames aggressively by design; the attributes above are how you keep the one name that has to stay while losing the ones that should go. For the broader pattern across reflection and serialization, see what obfuscation breaks.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.