Obfuscating a Polymorphic JSON Contract: Why [JsonDerivedType] Survives Renaming and TypeNameHandling Does Not
Polymorphic serialization puts type identity on the wire. Newtonsoft TypeNameHandling embeds the CLR type name, which obfuscation renames — breaking every persisted document. System.Text.Json [JsonDerivedType] uses a string discriminator decoupled from the type, so it renames freely. Here is exactly why, and how to obfuscate the hierarchy safely.
Property-level renaming breaks are annoying but shallow: a key comes out wrong, you pin it with [JsonPropertyName], done. Polymorphic serialization is a deeper hazard, because the thing that goes on the wire is not a property name — it is the identity of the type itself. Obfuscation exists to destroy that identity. So a polymorphic contract is exactly the place where “renaming broke serialization” stops being a cosmetic wire-name problem and becomes “every document we ever persisted is now unreadable.”
The good news is that this is entirely a design choice, and the modern .NET design is the safe one. This post is about why System.Text.Json’s [JsonDerivedType] is renaming-safe by construction, why Newtonsoft’s TypeNameHandling is not, and how to obfuscate a type hierarchy without keeping a single name.
Two ways to put a type on the wire
Say you persist a list of shapes — some circles, some rectangles — through a base Shape reference. Deserialization has to know which concrete type each element was. There are two fundamentally different ways to encode that.
Newtonsoft TypeNameHandling writes the CLR type name:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(shape, settings);
// {"$type":"MyApp.Shapes.Circle, MyApp","Radius":4.0,"Area":50.2}
The $type value is Circle’s assembly-qualified name. On read, Newtonsoft resolves that string back to a Type (the moral equivalent of Type.GetType) and constructs it. The wire format is welded to the CLR identity.
System.Text.Json [JsonDerivedType] writes a discriminator you choose:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Circle), typeDiscriminator: "circle")]
[JsonDerivedType(typeof(Rectangle), typeDiscriminator: "rect")]
public abstract class Shape
{
[JsonPropertyName("area")] public double Area { get; set; }
}
public sealed class Circle : Shape
{
[JsonPropertyName("radius")] public double Radius { get; set; }
}
Shape shape = new Circle { Radius = 4.0, Area = 50.2 };
string json = JsonSerializer.Serialize(shape);
// {"$type":"circle","area":50.2,"radius":4.0}
The $type value is the literal "circle". On read, System.Text.Json looks that literal up in the registration table the attributes built, finds typeof(Circle), and constructs it. The wire format is welded to a string you own, not to the type’s name.
That difference is invisible until an obfuscator renames Circle.
What renaming does to each design
Renaming rewrites the type’s name and every metadata token that references it, consistently — call, newobj, typeof, field types, all of it move together, which is why obfuscation is transparent to ordinary code. The question is only ever: does the format read a token (which moves) or a name/string (which may not)?
Newtonsoft reads a name. After obfuscation, two separate things break. Documents written before renaming say "MyApp.Shapes.Circle, MyApp", and the obfuscated assembly has no type by that name — JsonSerializationException: Could not load type 'MyApp.Shapes.Circle'. And documents written after renaming embed the obfuscated name (say a.b), coupling your persisted data to one build’s rename map and leaking the renamed identity straight back into the JSON. Either way the wire format tracks the CLR identity, and the CLR identity is precisely what you changed.
System.Text.Json reads the discriminator literal "circle". The attribute still carries "circle"; the typeof(Circle) argument is a metadata token the renamer rewrites to point at the renamed type; the registration table still maps "circle" to that type. The wire byte-for-byte is identical before and after obfuscation, and a document written by last year’s build still deserializes on today’s obfuscated one. Nothing to keep, nothing to leak.
The one way to break the safe design
[JsonDerivedType] is safe because the discriminator has no relationship to the class name. There is exactly one popular way to throw that away:
[JsonDerivedType(typeof(Circle), typeDiscriminator: nameof(Circle))] // don't
nameof(Circle) is resolved by the compiler to the literal "Circle" and frozen into the attribute, so this does not break on rename — the wire still says "Circle". But it quietly re-couples your public wire format to the original class name and, worse, hands an attacker the cleartext type names you paid to hide: your “protected” JSON now advertises Circle, Rectangle, PremiumSubscription, AdminCommand. Use opaque, intentional discriminators — "circle", or even "s1" — chosen as a wire vocabulary, never derived from the identifier. The same applies to the discriminator property name: if you want something other than the default "$type", put the string in [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")], never in a member.
Obfuscating the hierarchy: nothing to keep
Because the discriminators live in literals and the type registrations live in tokens, the hierarchy needs no keep rule at all — the thing that usually forces an exclusion simply isn’t present here. You obfuscate Shape and all its subtypes like any other code: rename the types, flatten the control flow of their methods, encrypt their strings, virtualize the valuable bodies. The only per-DTO hygiene is the ordinary one covered for any serialized type — give every serialized property an explicit [JsonPropertyName] so the keys don’t fall back to live CLR names.
nebula.config.json therefore has no special case for the model:
{
"schemaVersion": 1,
"inputs": ["publish/MyApp.dll"],
"outputDirectory": "protected",
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "MyApp.Shapes.*", "action": "rename" }
]
}
}
There is no keep rule because the discriminator strings are not member names — encryptStrings even encrypts them in the assembly, and they still decrypt to "circle" at the moment System.Text.Json writes them, so the wire is unchanged. Contrast that with the Newtonsoft design, which would force you either to keep the type names (surrendering the hierarchy you most want to hide) or to register a custom SerializationBinder that translates the rename map at runtime (coupling your persisted data to the obfuscator forever). The discriminator design asks for neither.
Run it the usual way, on the build server so local debugging stays readable:
dotnet publish MyApp -c Release -o publish
nebula protect --config nebula.config.json
<Target Name="NebulaProtect" AfterTargets="Publish" Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Prefer the source generator for a hot contract
For a contract on a hot path, a [JsonSerializable] context removes the runtime reflection walk and bakes the discriminators and property names into generated code at compile time — before Nebula runs:
[JsonSerializable(typeof(Shape))] // picks up the [JsonDerivedType] registrations
internal partial class ShapeJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(shape, ShapeJsonContext.Default.Shape);
The generated code already holds "circle" and "rect" as constants, so renaming the hierarchy afterwards cannot change a byte of output. The context type and its Default.Shape entry point rename consistently with the call sites that use them.
Prove it on the obfuscated build — including across builds
The assertion that matters runs against the protected artifact, and for a persisted contract it must also prove the format is stable across a rename — serialize with one build, read with the obfuscated one:
[Fact]
public void Protected_build_round_trips_the_polymorphic_contract()
{
Shape original = new Circle { Radius = 4.0, Area = 50.2 };
string json = JsonSerializer.Serialize(original);
Assert.Contains("\"$type\":\"circle\"", json); // discriminator is the stable literal
Assert.DoesNotContain("Circle", json); // original type name never leaks
var back = JsonSerializer.Deserialize<Shape>(json)!;
Assert.IsType<Circle>(back); // registration still resolves after rename
Assert.Equal(4.0, ((Circle)back).Radius);
}
Freeze a JSON string captured from an older build as a fixture and deserialize it on the obfuscated assembly; if it still yields a Circle, your persisted documents are safe across the rename. That single test converts the worst class of obfuscation incident — the silent, data-corrupting one you discover weeks later — into a red build.
The principle generalizes to every framework that encodes type identity: put the identity on the wire as a value you own, never as the CLR name the obfuscator is paid to change. [JsonDerivedType] does exactly that, which is why Nebula.NET can rename an entire polymorphic hierarchy without you keeping a thing. For the property-level companion to this, see obfuscating a System.Text.Json contract, and for the broader pattern, what obfuscation breaks in reflection and serialization.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.