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 JsonConvert.SerializeObject(order) 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 document you stored last week and read back today — is now broken, and it broke at runtime, in a place no compiler and no build step could ever see.
This is the single most common way obfuscation "breaks serialization," and with Newtonsoft.Json it bites harder than with System.Text.Json, for one structural reason: Json.NET has no compile-time source generator. System.Text.Json lets you escape the reflection walk entirely with a [JsonSerializable] context that freezes the names into generated code before the obfuscator runs. Json.NET has no first-party equivalent — serialization always goes through a contract resolver that reflects over your live type at runtime. There is no escape hatch. So pinning the wire names with attributes is not an optimization you can skip for a hot path; it is the entire contract.
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. Json.NET's DefaultContractResolver does not use a token. At serialize time it walks your type with reflection, reads each member's actual CLR Name, applies the resolver's naming strategy, and writes that string as the JSON key:
// Inside the contract resolver, conceptually:
// property.PropertyName = namingStrategy.GetPropertyName(member.Name, hasSpecifiedName: false);
// member.Name == "Customer" -> "Customer" (default strategy: verbatim)
// -> "customer" (CamelCaseNamingStrategy)
var settings = new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() }
};
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 member. So when the renamer turns Customer into a, the resolver does exactly what it is told — it reads the member now called a and writes "a" — and the round-trip to anyone expecting "Customer" is dead. A naming strategy changes the casing of that string; it does nothing to save you, because it is still deriving the key from a name the obfuscator just moved.
The fix is [JsonProperty], 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 [JsonProperty] makes it one:
public sealed class Order
{
[JsonProperty("customer")] public string Customer { get; set; } = "";
[JsonProperty("total")] public decimal Total { get; set; }
[JsonProperty("placedOn")] public DateTime PlacedOn { get; set; }
}
Now the resolver reads the attribute's string literal — "customer" — not the member name. That literal lives in the custom-attribute blob in metadata, which renaming never touches; the renamer is free to turn Customer into b, and the JSON key is still "customer". This is the key distinction: the member does get renamed (which is the point of obfuscating), and only the string Json.NET reads is pinned. A keep rule would preserve the member name on disk — the opposite of what you want.
The rule, then, is simple and total: every serialized member gets an explicit [JsonProperty] name. A single un-attributed property falls back to its CLR name and becomes the one field that breaks. If you serialize fields or opt a type into MemberSerialization.Fields with [JsonObject], the same applies to every field. This is easy to enforce in review and easy to see in a diff — and unlike a naming strategy, it does not quietly depend on the member name surviving.
The constructor is a second name binding
Immutable DTOs hide a second trap. When a type has no default constructor, Json.NET deserializes it by matching each JSON value to a constructor parameter — by parameter name, case-insensitively:
public sealed class Order
{
[JsonConstructor]
public Order(string customer, decimal total, DateTime placedOn) // param names matter
{
Customer = customer; Total = total; PlacedOn = placedOn;
}
[JsonProperty("customer")] public string Customer { get; }
[JsonProperty("total")] public decimal Total { get; }
[JsonProperty("placedOn")] public DateTime PlacedOn { get; }
}
Json.NET pairs the "customer" 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 Json.NET 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 serialize by their member name too
By default Json.NET writes enums as their numeric value, which renaming never touches. But the moment you add a StringEnumConverter — common, because a string is more readable and more stable on the wire — the enum is written by its member name, and that is a renameable identifier again:
[JsonConverter(typeof(StringEnumConverter))]
public enum Priority
{
[EnumMember(Value = "low")] Low,
[EnumMember(Value = "high")] High,
}
Json.NET honours [EnumMember(Value = …)] on enum members, so pin each one. Now Priority.High serializes as "high" whether or not the field High has been renamed, because the converter reads the attribute's literal rather than the member name.
The bigger trap: TypeNameHandling writes your type's name into the document
Everything above is a property or a value. TypeNameHandling is different: it writes your type's identity into the document. When you enable it to round-trip polymorphic graphs — a base Payment with CreditCardPayment and BankTransferPayment subclasses, an object-typed field, a List<IMessage> — Json.NET embeds a $type discriminator so it can reconstruct the exact runtime type on the way back:
var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };
string json = JsonConvert.SerializeObject(payment, settings);
// {
// "$type": "MyApp.Payments.CreditCardPayment, MyApp",
// "last4": "4242", "amount": 42.50
// }
Look at the value of $type: it is the type's assembly-qualified name — namespace, type name, assembly — assembled from the exact identifiers an obfuscator renames. Rename CreditCardPayment to a and flatten the namespace, and the same object now serializes as "$type": "a, MyApp". Your [JsonProperty] attributes do nothing here, because $type is not a property of your type; it is the type's name, and no property attribute pins a type name.
That makes $type far more dangerous than a property key, because it breaks across builds and across versions, not just within one. A document written by an un-obfuscated build, or by last month's release, carries "…CreditCardPayment, MyApp". Feed it to your obfuscated build — whose type is now called a — and Json.NET cannot resolve the name and throws on deserialize. Anything that persists polymorphic JSON (a stored document, a queued message, a cached payload, a saved project file) is exposed the instant the type names on the two sides disagree.
The fix is neither an attribute nor a keep rule. It is a custom ISerializationBinder that maps a stable logical name you choose to the Type, so the wire carries a constant you own while the type underneath renames freely:
public sealed class StableTypeBinder : ISerializationBinder
{
// The wire names are string literals; the Type values rename consistently with your code.
private static readonly Dictionary<string, Type> ByName = new()
{
["creditCardPayment"] = typeof(CreditCardPayment),
["bankTransferPayment"] = typeof(BankTransferPayment),
};
private static readonly Dictionary<Type, string> ByType =
ByName.ToDictionary(kv => kv.Value, kv => kv.Key);
public void BindToName(Type serializedType, out string? assemblyName, out string? typeName)
{
assemblyName = null; // keep the assembly out of the wire entirely
typeName = ByType[serializedType]; // -> "creditCardPayment", a constant
}
public Type BindToType(string? assemblyName, string typeName) => ByName[typeName];
}
var settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new StableTypeBinder(),
};
// {"$type":"creditCardPayment","last4":"4242","amount":42.50}
The dictionary keys — "creditCardPayment" — are string literals the obfuscator cannot touch. The dictionary values are typeof(CreditCardPayment), which Json.NET and the renamer both resolve through the metadata token, so they rename consistently with the rest of your code. The result is a discriminator that is stable across every build and every version, no matter how aggressively the types are renamed, and as a bonus it keeps your namespace layout out of the document. (It is also the recommended posture for TypeNameHandling on security grounds: a binder that only resolves a fixed allow-list of types refuses to instantiate anything else, which is the mitigation for the deserialization-gadget risk TypeNameHandling otherwise carries.)
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 settings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.Auto,
SerializationBinder = new StableTypeBinder(),
};
Payment payment = new CreditCardPayment { Last4 = "4242", Amount = 42.50m };
string json = JsonConvert.SerializeObject(payment, settings);
// The contract, asserted literally — these are what consumers and stored docs depend on.
Assert.Contains("\"$type\":\"creditCardPayment\"", json);
Assert.Contains("\"last4\":\"4242\"", json);
Assert.DoesNotContain("\"a\"", json); // no leaked renamed member or type
var back = (CreditCardPayment)JsonConvert.DeserializeObject<Payment>(json, settings)!;
Assert.Equal("4242", back.Last4); // constructor/members matched by pinned names
Assert.Equal(42.50m, back.Amount);
}
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, a type name, or a namespace it is paid to.
Nebula.NET renames aggressively by design; [JsonProperty], [EnumMember], and a stable ISerializationBinder are how you keep the handful of names that have to stay while losing the ones that should go. For the System.Text.Json side of the same story, see obfuscating a type you serialize with System.Text.Json; for the broader pattern, what obfuscation breaks.
Top comments (0)