A Minimal API looks like the easiest thing in the world to obfuscate. There are no controllers, no action-name conventions, no [HttpGet] attributes tying a method to a route by reflection — just app.MapGet("/orders/{id}", handler) and a lambda. The route is a string, the handler is a delegate, and neither looks like it depends on a symbol name an obfuscator would touch. So you turn on renaming, strip metadata to shrink the binary, ship it — and the endpoints keep returning 200 OK while quietly serving the wrong record. GET /orders/42 comes back with order zero.
Nothing threw. There is no 500, no 400, no log line. The assembly is valid and the route matched. What broke is one specific thing Minimal APIs do that controllers hide: they bind route and query values to your handler parameters by the parameter's reflected name, and obfuscation runs on the metadata that name lives in. This is a how-to for that seam — exactly how RequestDelegateFactory wires a request to your delegate, why stripping or renaming a parameter name turns id = 42 into id = 0 with no error, and how to protect the whole endpoint graph with Nebula.NET so binding still works.
Why obfuscate a Minimal API at all
The handler lambdas are thin, but everything behind them is the product: the pricing calculation, the eligibility rules, the fulfilment orchestration, the queries that shape your data model. A Minimal API service ships as a DLL — often to a customer's own infrastructure, a container registry they control, or an on-prem box — and anyone with file access can open it in a decompiler and read your business logic back as clean C#. The endpoint tier is also where secrets cluster: downstream service URLs, signing keys, connection-string fragments, the internal header names your gateway sets. Raising the cost of reading your own shipped code back out of that binary is the ordinary, legitimate reason to obfuscate it.
How Minimal APIs bind a request to your delegate
Here is a perfectly normal endpoint:
app.MapGet("/orders/{id}", async (
int id, // from the {id} route token
bool detail, // from ?detail=true
IOrderService svc) => // from DI, by type
{
var order = await svc.Get(id, detail);
return order is null ? Results.NotFound() : Results.Ok(order);
});
Three parameters, three different binding sources, and the framework has to work out which is which. It does that once, at startup, when RequestDelegateFactory.Create builds the RequestDelegate for this route. Conceptually, for each parameter it does this:
foreach (ParameterInfo p in handler.Method.GetParameters())
{
// 1. a registered service type? -> resolve from DI
if (serviceProviderIsService.IsService(p.ParameterType)) { /* bind from DI */ }
// 2. does the route template contain a token named p.Name?
else if (routeTemplate.HasParameter(p.Name)) { /* bind route value p.Name */ }
// 3. otherwise treat it as a query value keyed by p.Name
else { /* bind query[p.Name] */ }
}
Look at steps 2 and 3. The route token is {id} and the parameter binds to it because p.Name == "id". The query key is detail because p.Name == "detail". IOrderService binds by type, not name, so it is safe — but id and detail are matched entirely on the string that ParameterInfo.Name returns. That string is metadata, compiled into the method's parameter table.
Now compile that lambda. It becomes a method — say <Program>$.<<Main>$>b__0_0 — carrying a parameter table:
.method assembly hidebysig instance class Task
'<<Main>$>b__0_0'(int32 id, bool detail, class IOrderService svc)
// ^^^^^ ^^^^^^
// ParameterInfo.Name for #0 and #1 — the strings binding reads
Obfuscation runs here, on the DLL, after the C# compiler is done. If the pass strips parameter names (a common size/metadata reduction) or renames them, that table becomes int32 A_0, bool A_1, ... or no names at all. At startup, RequestDelegateFactory calls GetParameters(), finds no parameter named id, and falls through: there is no route value to bind, so for a non-nullable int the binder supplies default(int) — zero — rather than failing. The endpoint is wired, the route matches, and every request runs svc.Get(0, false). The one string the framework needed to connect {id} to your parameter is gone, and nothing told you.
The silent-failure trap, precisely
This is nastier than the usual obfuscation break because it does not fault. A renamed reflection target normally throws — a missing type, a MissingMethodException, a JsonException. Name-based parameter binding instead degrades to the default value. A string query parameter arrives null, an int arrives 0, a bool arrives false, a nullable stays null. The endpoint returns 200 with content computed from those defaults. Your smoke test that checks for a 200 passes. The bug surfaces as "customer says order 42 shows the wrong items," days later, with no stack trace pointing at binding. Treat any Minimal API where a route or query value silently becomes its default after an obfuscation change as a stripped-parameter-name problem until proven otherwise.
What actually depends on a name, and what doesn't
Walk the endpoint graph and the keep-list writes itself.
-
Route- and query-bound parameters —
id,detail. Bound byParameterInfo.Name. These names must survive, or binding must not depend on them (see the two fixes below). -
The route template strings —
"/orders/{id}". These are string literals your code passes toMapGet; string encryption decrypts them at runtime to the exact same value, so matching is unaffected. No action needed. -
Service parameters —
IOrderService svc. Bound by type, which renaming preserves (identity is intact even when the name changes). Rename the service and its implementation freely. -
The handler method name —
<<Main>$>b__0_0. The framework holds a delegate to it and never looks it up by name. Rename it; it does not matter. -
[AsParameters]struct members — if you bind a struct with[AsParameters], the framework binds each public property by its name exactly like a top-level parameter. Keep those property names (or annotate them). Same rule for a parameter bound from the body as JSON: the DTO's property names are the wire contract your serializer reads. -
[FromKeyedServices("cache")],[FromHeader(Name="...")]and friends — the key is a string literal, not a symbol. Survives string encryption. No action needed.
So the entire name-sensitive surface is: route/query parameter names, [AsParameters] property names, and body-DTO property names. Everything else — the handlers, the services, the interior — is fair game.
Fix one: keep the names (quick)
Annotate the handlers' owning scope to preserve parameter names, and let everything else rename. In nebula.config.json:
{
"schemaVersion": 1,
"inputs": [ "publish/Contoso.Orders.dll" ],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"stripParameterNames": false,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.*", "action": "rename" }
]
}
}
stripParameterNames: false tells Nebula to leave the method parameter tables intact, so RequestDelegateFactory still finds a parameter named id. The keep rule pins your request/response DTOs and any [AsParameters] structs under Contoso.Orders.Contracts so their property names still match the route tokens and the JSON wire. Everything else in the assembly — service names, internal types, method bodies — is renamed. This is a two-line change and it works, but it leaves binding coupled to metadata you would otherwise want gone.
Fix two: make binding rename-proof (robust)
Better: move the binding key out of the parameter name and into a string the obfuscator cannot break. Explicit binding attributes do exactly that:
app.MapGet("/orders/{id}", async (
[FromRoute(Name = "id")] int id,
[FromQuery(Name = "detail")] bool detail,
IOrderService svc) =>
await svc.Get(id, detail) is { } o ? Results.Ok(o) : Results.NotFound());
Now RequestDelegateFactory reads the binding key from "id" and "detail" — string literals carried in the attribute, which string encryption reproduces at runtime and renaming never touches. The parameter's metadata name is now irrelevant to binding, so you can strip it:
{
"stripParameterNames": true,
"encryptStrings": true,
"controlFlowObfuscation": true
}
The comparison is the whole decision: keeping names is one config line and zero code change, but keeps metadata an aggressive pass wants gone and is easy to forget on a new endpoint; explicit attributes are more typing up front but let you strip every parameter name in the assembly and never think about binding again. For a service you are hardening seriously, annotate. For [AsParameters] structs the same move applies — put [FromRoute(Name = "...")] / [FromQuery(Name = "...")] on the public properties, then their names can rename too.
If you compile through the Request Delegate Generator (Native AOT, or EnableRequestDelegateGenerator), the generated binding code wires arguments without a runtime ParameterInfo.Name lookup, so top-level route/query parameters are already rename-proof — but [AsParameters] and body DTOs still bind by property name, so keep those.
Folding protection into the build
Protect the published output, and only on the build server so local dotnet run stays fast and debuggable:
dotnet publish Contoso.Orders -c Release -o publish
nebula protect --config nebula.config.json
Hook it after publish in CI so the deployed artifact is always the protected one:
<Target Name="NebulaProtect" AfterTargets="Publish"
Condition="'$(CI)' == 'true'">
<Exec Command="nebula protect --config nebula.config.json" />
</Target>
Ship protected/, not the raw publish folder. nebula protect returns a non-zero exit code on failure, so a broken protection step fails the build instead of shipping a binary whose endpoints silently bind everything to zero.
Protecting the interior
Keeping a parameter name readable does not mean the handler's work is. The binding surface stays legible; everything behind it becomes opaque. String encryption hides the downstream URLs, keys and connection-string fragments the endpoint tier accumulates. The strongest transforms — code virtualization and whole-method encryption, both Pro features in Nebula.NET — go on the crown jewels: the pricing, eligibility or settlement logic the handlers call into, applied to that handful of methods rather than the whole assembly:
public sealed class OrderService : IOrderService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public Task<Order?> Get(int id, bool detail) { /* ... */ }
}
Renaming and control-flow obfuscation run broadly underneath, turning the rest of the interior into meaningless symbols and tangled branches while the route still finds its parameter.
Verifying it actually works
Unit tests will not catch this: they call the handler lambda directly with real arguments and never go through RequestDelegateFactory against the protected DLL. Two checks will. First, run the protected build and hit a real route with a real value — GET /orders/42?detail=true — and assert the response reflects 42 and true, not 0 and false. A 200 that returns the zero-id record is the exact signature of a stripped parameter name; assert on the bound values, not the status code. Do this for a route parameter, a query parameter, and an [AsParameters] or body-bound endpoint, since each binds by a different name.
Second, open the shipped assembly in a decompiler and read it the way an attacker will. Glass.NET is the free, static-only companion for that pass: load the protected DLL, confirm the binding surface you meant to keep is present — the route templates, the DTO property names, or the [FromRoute(Name=...)] literals — and then confirm there is nothing left to read inside OrderService.Get and the logic it calls.
The takeaway
A Minimal API connects a request to your handler through one string per parameter — the reflected ParameterInfo.Name that RequestDelegateFactory matches to a {route} token or a query key — and that string is metadata an obfuscation pass will strip or rename. When it goes, binding does not throw; it hands your handler default(T), so GET /orders/42 quietly serves order zero with a 200. Decide the binding surface deliberately: either keep the parameter and DTO names (stripParameterNames: false plus a keep rule on your contracts), or — better — move each binding key into a [FromRoute(Name = "...")] / [FromQuery(Name = "...")] attribute string so you can strip every name in the assembly and never break a route again. Then hand the entire interior to Nebula.NET to rename, string-encrypt and virtualize, send a real value through the protected build to prove it binds, and read the shipped DLL to confirm the logic behind the endpoint is gone.
Top comments (0)