# Obfuscating source-generated code: why System.Text.Json contexts, [GeneratedRegex] and [LoggerMessage] survive renaming — and the one name they leak


A source generator is a compiler plugin that reads your code and emits more code into the same compilation. By the time the C# compiler finishes, the generated files are just part of your assembly — same metadata tables, same IL, no marker in the output that distinguishes a method you wrote from one the generator wrote. Which raises the obvious question for an obfuscator: when Nebula renames your types and members, what happens to code that was *generated around them*?

The answer is better than you'd expect, with one twist. Generators resolve names at **compile time**, which is exactly the property that makes code rename-safe. But the way they do it is by writing your names down as **string literals**, and those literals survive renaming verbatim — occasionally telling an attacker what a method used to be called.

## Runtime lookup versus compile-time capture

The whole reason obfuscation breaks things is runtime name lookup. `Type.GetProperty("Name")`, `JsonSerializer.Serialize(obj)` without a context, `IConfiguration.Bind(options)` — each walks the metadata at runtime, finds a member by its string, and stops finding it once the member is renamed. That's why [autoDetect](https://delta1labs.com/docs/nebula/keeping-your-app-working/) preserves members carrying serialization attributes and the docs tell you to exclude your settings classes.

A generator does the lookup at compile time and emits two things instead: a **token reference** to the member (a direct `call`/`ldfld`, or a delegate) and, where a name is needed at runtime, a **literal** copied from your source. Renaming rewrites every token reference and leaves literals alone.

<figure class="my-8"><svg viewBox="0 0 640 210" role="img" aria-label="Left: reflection-based code looks a member up by string at runtime, so renaming Name to a breaks the lookup. Right: source-generated code reaches the member by token through a delegate and carries the name as a literal captured at compile time, so renaming the member keeps both the access and the wire name working." style="width:100%;height:auto"><defs><style>.p{fill:#e8eefc;stroke:#8eb4ff}.no{fill:#fee2e2;stroke:#f87171}.ok{fill:#dcfce7;stroke:#4ade80}.t{font:600 12px system-ui;fill:#1e293b}.s{font:11px system-ui;fill:#475569}.m{font:11px ui-monospace,monospace;fill:#334155}.a{stroke:#64748b;stroke-width:1.5;marker-end:url(#ah2)}</style><marker id="ah2" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="#64748b"/></marker></defs><text class="t" x="12" y="18">Reflection: lookup at runtime</text><rect class="p" x="12" y="28" width="260" height="40" rx="6"/><text class="m" x="22" y="53">GetProperty("Name")  → metadata</text><rect class="p" x="12" y="84" width="260" height="40" rx="6"/><text class="m" x="22" y="109">Customer.Name renamed to a</text><line class="a" x1="142" y1="68" x2="142" y2="82"/><rect class="no" x="12" y="140" width="260" height="30" rx="6"/><text class="t" x="60" y="160">"Name" not found → breaks</text><text class="t" x="340" y="18">Generator: capture at compile time</text><rect class="p" x="340" y="28" width="288" height="56" rx="6"/><text class="m" x="350" y="48">PropertyName = "Name"     (literal)</text><text class="m" x="350" y="68">Getter = o =&gt; o.Name       (token)</text><rect class="p" x="340" y="98" width="288" height="40" rx="6"/><text class="m" x="350" y="123">Customer.Name renamed to a</text><line class="a" x1="484" y1="84" x2="484" y2="96"/><rect class="ok" x="340" y="152" width="288" height="30" rx="6"/><text class="t" x="366" y="172">Getter → o.a, wire name "Name" → works</text></svg></figure>

So the rule for generated code is the inverse of the rule for reflection: **the member is safe to rename; the literal is what you have to think about.** Let's look at what four common generators actually emit.

## System.Text.Json: the context captures the name and the accessor

Take a DTO with no serialization attributes at all and a source-generated context:

```csharp
public sealed class Customer
{
    public string Name  { get; set; } = "";
    public int    Tier  { get; set; }
}

[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }
```

Enable generated-file output once — `<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>` in the project — and read what the generator produced for `Customer`. The metadata initializer looks like this (trimmed):

```csharp
private static JsonPropertyInfo[] CustomerPropInit(JsonSerializerOptions options)
{
    var properties = new JsonPropertyInfo[2];

    var info0 = new JsonPropertyInfoValues<string>
    {
        IsProperty    = true,
        DeclaringType = typeof(global::Customer),
        Getter        = static obj => ((global::Customer)obj).Name,
        Setter        = static (obj, value) => ((global::Customer)obj).Name = value!,
        PropertyName  = "Name",          // captured at generation time
        JsonPropertyName = null,
    };
    properties[0] = JsonMetadataServices.CreatePropertyInfo<string>(options, info0);
    // ... Tier ...
    return properties;
}
```

Two things to notice. The getter and setter are **lambdas that touch the property directly** — in IL that's `ldftn` on a compiler-generated method whose body is `callvirt Customer::get_Name()`. The wire name is a **`ldstr "Name"`**. After Nebula renames `Customer` to `a` and `Name` to `b`, the lambda body becomes `callvirt a::get_b()` — rewritten by token, as all call sites are — while the `ldstr` still says `"Name"`. Serialize a customer and you get `{"Name":"…","Tier":3}` exactly as before.

In the fast-path serialization mode the generator does the same thing with a pre-encoded name — `private static readonly JsonEncodedText PropName_Name = JsonEncodedText.Encode("Name");` — and a hand-written `writer.WriteString(PropName_Name, value.Name)`. Same shape: literal plus direct member access.

That is strictly better than reflection, and it means the DTO needs **no** `[JsonPropertyName]` safety net and **no** `exclude` entry. The property can be renamed and the contract holds, because the contract was captured when the generator ran.

**The one way this still breaks** is a code path that doesn't use the context. `JsonSerializer.Serialize(customer)` with no options, or a resolver chain that ends in `DefaultJsonTypeInfoResolver`, resolves names from metadata at runtime and will produce `{"b":"…"}` after renaming. You don't want to discover that in the obfuscated build. Turn the reflection path off at the project level:

```xml
<PropertyGroup>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
```

Now any call that would have silently fallen back to reflection throws in development, and every serialization path that reaches production is one the generator captured.

## [GeneratedRegex]: pure type identity

```csharp
public static partial class Parsers
{
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
    private static partial Regex IsoDate();
}
```

The generator fills in `IsoDate()` to return a cached singleton of a generated class — a sealed type deriving from `Regex` whose constructor sets `pattern`, `roptions` and `factory`, with a nested `RunnerFactory` that creates a nested `Runner` implementing `Scan`, `TryFindNextPossibleStartingPosition` and `TryMatchAtCurrentPosition`. Nothing in there resolves anything by name. The runner is reached through the `factory` field the base class already expects; the user's call is `call Parsers::IsoDate()` → `ldsfld` of the instance. Rename the method, the generated class, the factory and the runner, and the regex still matches.

What's left is the **pattern literal**, assigned in the generated constructor (`base.pattern = "^\d{4}-\d{2}-\d{2}$"`). That's an ordinary string in IL and `encryptStrings` hides it at rest like any other.

The real consideration here is **performance**, not correctness. `TryMatchAtCurrentPosition` is the compiled matching loop — exactly the kind of tight, branchy code that control-flow flattening makes measurably slower. If the regex runs on a hot path, keep flattening away from it. The generated types carry compiler-mangled names, so find the exact method names with `nebula inspect --methods` and add them to `controlFlowExclude`, or — simpler — scope flattening to the methods you actually need protected with `controlFlowInclude`.

## [LoggerMessage]: the generator writes nameof() for you

```csharp
public static partial class Log
{
    [LoggerMessage(EventId = 12, Level = LogLevel.Warning,
                   Message = "Order {OrderId} rejected: {Reason}")]
    public static partial void OrderRejected(ILogger logger, int orderId, string reason);
}
```

The generated half is a cached `LoggerMessage.Define` callback:

```csharp
private static readonly Action<ILogger, int, string, Exception?> __OrderRejectedCallback =
    LoggerMessage.Define<int, string>(
        LogLevel.Warning,
        new EventId(12, nameof(OrderRejected)),            // ← your method name, as a string
        "Order {OrderId} rejected: {Reason}",
        new LogDefineOptions { SkipEnabledCheck = true });

public static partial void OrderRejected(ILogger logger, int orderId, string reason)
{
    if (logger.IsEnabled(LogLevel.Warning))
        __OrderRejectedCallback(logger, orderId, reason, null);
}
```

That `nameof(OrderRejected)` is evaluated by the C# compiler. In the IL it is `ldstr "OrderRejected"` — there is no reference left to the method, just the characters. Nebula renames `Log.OrderRejected` to `a.b`; the `EventId` still carries the name `"OrderRejected"`. Two consequences:

- **Your logging keeps working as designed.** Dashboards and alerts keyed on the event name see the same name before and after obfuscation. Good.
- **The binary now contains a map entry.** A decompiler shows a method `b` whose static initializer says `"OrderRejected"`. For a class full of log methods, that's a readable index of what every renamed method does. This is the same mechanism described in [when nameof leaks your renamed members](https://delta1labs.com/blog/nameof-callermembername-obfuscation-leak), produced here by a generator instead of your own code.

`encryptStrings` is the fix, and it's the right one: the literal is encrypted in the assembly and decrypted when the static initializer runs, so your logs still say `OrderRejected` while the file on disk doesn't. The message template gets the same treatment, which also hides the `{OrderId}`/`{Reason}` parameter names — small, but they describe the method's arguments.

## The configuration binding generator: literal keys instead of a metadata walk

The reflection-based `ConfigurationBinder` is on every "preserve this" list for a reason: `Bind` matches configuration keys to property names by reflecting over the options type at runtime. .NET 8 added a source-generated binder:

```xml
<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
```

With it on, `builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp"))` is intercepted and routed to generated code of this shape:

```csharp
public static void BindCore(IConfiguration configuration, ref SmtpOptions instance, BinderOptions? binderOptions)
{
    if (configuration["Host"] is string value0)
        instance.Host = value0;                                  // direct assignment, literal key

    if (configuration["Port"] is string value1)
        instance.Port = ParseInt(value1, configuration.GetSection("Port").Path);
}
```

`"Host"` and `"Port"` are `ldstr`; `instance.Host = …` is `callvirt SmtpOptions::set_Host`. Rename `SmtpOptions.Host` to `a.c` and the generated binder still reads `appsettings.json`'s `Smtp:Host` and still calls the renamed setter. The `exclude` entry for your settings classes that the reflection binder forced on you is no longer needed for binding.

Check what *else* reads those classes by name before you drop the exclusion: `ValidateDataAnnotations` reports member names in its error messages (cosmetic), and any leftover `Bind` call compiled without the generator still uses reflection. The generated-files folder tells you which calls were intercepted.

## A config that fits generated code

Putting it together for a typical service that uses all four generators:

```json
{
  "inputs": ["out/Acme.Service.dll"],
  "outputDirectory": "out/obf",
  "preservePublicApi": false,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "controlFlowIntensity": "normal",
  "controlFlowExclude": [
    "Acme.Service.Parsers.<RegexGenerator_g>F…__IsoDate_0.Runner.TryMatchAtCurrentPosition"
  ]
}
```

`encryptStrings` is the line doing the most work here: it covers the regex pattern, the log templates and the `nameof` literals in one setting. `controlFlowExclude` keeps flattening off the regex runner's inner loop (paste the exact mangled name from `nebula inspect --methods`). Nothing in the config preserves the DTO, the options class or the log methods, because nothing needs to.

The checklist for any generator you haven't met yet is the same one this post applied four times. Emit the generated files, open them, and ask two questions of each member access: is it a **token** (a direct call, a `typeof`, a delegate) or a **string** (`GetProperty("…")`, `Type.GetType("…")`)? Tokens are rename-safe. Then look at every literal and ask whether it's a **wire contract** you need unchanged (fine — it is unchanged) or a **copy of a name** you were hoping to hide (encrypt it). Generators that do their work at compile time are, almost by construction, the easiest code in your assembly to obfuscate. See [Keeping your app working](https://delta1labs.com/docs/nebula/keeping-your-app-working/) for the reflection-side rules these generators let you relax.

[Download Nebula.NET free](https://delta1labs.com/download?product=nebula) and run your serialization tests against the obfuscated build.

