Obfuscating an Azure Functions Isolated-Worker App Without Breaking Function Discovery
The .NET isolated worker finds your functions through a source-generated metadata provider that bakes each function's entry point into the assembly as a literal string — "Namespace.Class.Method" — at compile time. Obfuscate after that and renaming moves the method out from under the string, so the Functions host loads your app and then cannot find a single function. This post explains exactly which names the isolated model resolves at runtime, how to keep-list the trigger methods and binding DTOs with Nebula.NET, and how to string-encrypt and virtualize the business logic that is the whole reason the function exists.
An Azure Functions app written on the .NET isolated worker model looks like the easiest thing in the world to obfuscate. It is a class library — no Main, no UI, a handful of methods decorated with [Function] — that ships to someone else’s cloud and runs headless. Almost none of it is consumed by name from outside. And yet it is the one .NET app shape where a naive obfuscation pass produces the most baffling failure: the app deploys, the worker process starts, the host logs no errors, and then every function returns 404 or never fires, because the host has indexed zero functions. The code is all there. The host simply cannot find it.
The reason is specific to the isolated model, and once you see it the fix is a two-line keep-list. This post is about that mechanism — how the isolated worker discovers your functions, why obfuscation breaks the discovery and not the code, and how to protect the parts that matter with Nebula.NET without touching the parts the host reads.
Why a Functions app is worth protecting
A function is pure business logic with the plumbing deleted. There is no presentation layer, no framework glue to wade through — just the trigger and the thing it does: reprice a basket, score a loan application, validate a webhook signature, settle a transaction, decide eligibility. That logic is the product, and in a function it is nearly the entire assembly. The app also carries the sensitive constants you would expect of something that integrates with other systems — downstream endpoint URLs, partner API keys, header names, connection-string fragments — frequently as code defaults rather than configuration.
And it runs somewhere you do not administer. Whether it is Azure’s infrastructure or a customer’s own subscription running your function in a container, the deployed assembly is a DLL an attacker can pull and open in a decompiler, reading your scoring model or settlement math back as clean C# in seconds. The headlessness that makes a function feel unexposed is exactly what makes it worth reversing.
How the isolated worker finds your functions
Here is an ordinary HTTP function:
namespace Contoso.Orders;
public sealed class OrderFunctions
{
private readonly IPricingService _pricing;
public OrderFunctions(IPricingService pricing) => _pricing = pricing;
[Function("ProcessOrder")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
var order = await req.ReadFromJsonAsync<OrderRequest>();
var quote = _pricing.Reprice(order!); // proprietary — the reason this exists
var res = req.CreateResponse(HttpStatusCode.OK);
await res.WriteAsJsonAsync(quote);
return res;
}
}
In the old in-process model the host would load your assembly and scan it for [Function] at startup. The isolated model does not work that way. Your worker is a separate process, and to avoid reflecting over your assembly across that boundary the Worker SDK ships a source generator that runs at compile time and emits a metadata provider describing every function it found:
// <auto-generated/> — emitted by Microsoft.Azure.Functions.Worker.Sdk.Generators
internal class GeneratedFunctionMetadataProvider : IFunctionMetadataProvider
{
public Task<ImmutableArray<IFunctionMetadata>> GetFunctionMetadataAsync(string dir)
{
var metadata = new DefaultFunctionMetadata
{
Name = "ProcessOrder",
ScriptFile = "Contoso.Orders.dll",
EntryPoint = "Contoso.Orders.OrderFunctions.Run", // <-- the critical string
RawBindings = { /* httpTrigger + http return, as JSON */ },
};
// ...
}
}
The EntryPoint is a literal string — the fully qualified type name plus the method name — and the generator writes it into the assembly as an ldstr constant. At startup the worker reads this metadata, and for each entry it resolves the actual method by reflection, effectively:
var type = assembly.GetType("Contoso.Orders.OrderFunctions");
var method = type.GetMethod("Run"); // name comes straight from the EntryPoint string
That is the whole trap. The generator runs first and bakes "Contoso.Orders.OrderFunctions.Run" into the DLL. Obfuscation runs after compilation, on that DLL. If renaming turns OrderFunctions into a and Run into b, the baked string is untouched — it still says Contoso.Orders.OrderFunctions.Run — but the method it names no longer exists. GetType returns null, the function is skipped, and the host reports an app with no functions. Nothing throws, because an app with zero functions is a perfectly valid app.
The audit: what renames, what doesn’t
Walk every name the isolated model touches and the keep-list writes itself.
- Trigger methods and their declaring types —
OrderFunctions.Run. Resolved by the bakedEntryPointstring through reflection. Keep. This is the one seam unique to Functions, and the whole reason naive obfuscation fails. [Function("ProcessOrder")]name — the public function name, the route segment, the queue it listens on. This is a literal string argument, entirely independent of the method name, so it survives renaming untouched. No action needed. Your URL stays/api/ProcessOrdereven afterRunbecomesb.- Binding parameters (
HttpTrigger,QueueTrigger,BlobInput, output bindings). Their configuration — queue names, connection setting names, auth level — is captured as JSON in the generatedRawBindingsat compile time and is all literals. Renaming parameters does not touch it. No action needed. - Constructor injection (
IPricingService). Resolved by type from the DI container built inProgram.cs, exactly as in any host. Renaming preserves type identity. Rename freely. - The generated
GeneratedFunctionMetadataProvideritself. Registered by type through the SDK’s generated startup extension, not by name. It renames fine — what matters is the string content inside it, which points at the trigger methods you are keeping. Rename freely. - Private helpers, domain services, the algorithm. Nothing outside the process names these. Rename, encrypt, virtualize.
- Request/response POCOs (
OrderRequest, the quote you return). Serialized bySystem.Text.Jsonusing property names, which are a wire contract with your callers. Keep the property names — or pin each with[JsonPropertyName("...")]and let the member rename. This is the general serialization pitfall, not a Functions-specific one.
Two seams to keep, then — trigger methods and binding DTOs — and everything else is fair game.
The keep-list
Model the function classes by a naming convention so one rule covers all of them. With a solution split into Contoso.Orders (the functions and DTOs) and Contoso.Pricing (the algorithm), nebula.config.json reads:
{
"schemaVersion": 1,
"inputs": [
"bin/Release/net8.0/publish/Contoso.Orders.dll",
"bin/Release/net8.0/publish/Contoso.Pricing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Orders.*Functions", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Orders.Contracts.*", "action": "keep" },
{ "pattern": "Contoso.Orders.*", "action": "rename" },
{ "pattern": "Contoso.Pricing.*", "action": "rename" }
]
}
}
The first rule pins every *Functions type and its members so the trigger methods keep the names the generated strings expect; scope: "type-and-members" is what preserves Run, not just the class. The second pins the serialized DTOs. Everything else in both assemblies is renamed. If you would rather not rely on a convention, annotate the trigger methods directly — the attribute travels with the code and cannot drift out of sync with a rename pattern:
[Function("ProcessOrder")]
[Obfuscation(Exclude = true, ApplyToMembers = false)] // keep this method's name for discovery
public async Task<HttpResponseData> Run(/* ... */) { /* ... */ }
Protecting the interior
Keeping a method’s name does not mean leaving its body readable. The trigger stays discoverable while everything it does becomes opaque.
String encryption for the constants. Endpoint URLs, API keys, header names and connection fragments are what an attacker greps for first; string encryption stores them encrypted and decrypts at the point of use, so a search of the binary returns nothing. It is not a vault — the value is live in memory at runtime — but it removes the zero-effort attack.
The strongest transforms on the crown jewels. IPricingService.Reprice and whatever scoring, settlement or eligibility logic the function wraps are where you reach for code virtualization and whole-method encryption — both Pro features in Nebula.NET — applied to that handful of methods rather than the whole assembly. Virtualization rewrites a method into bytecode for an embedded VM so there is no IL left to decompile; method encryption stores the original IL encrypted and reconstructs it at runtime. You can virtualize the body of the trigger itself even while its name is kept:
public sealed class PricingService : IPricingService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public Quote Reprice(OrderRequest order) { /* ... */ }
}
Renaming and control-flow obfuscation run broadly underneath both, turning the rest of the interior into meaningless symbols and tangled branches.
Wiring Nebula into the publish
Protect the published output, and only on the build server so local debugging stays fast and readable:
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>
The protected protected/ folder is what your deployment step (zip-deploy, container image, or func azure functionapp publish against the built output) should ship. Keeping developer builds unobfuscated and obfuscating only in CI is a discipline in itself — the reasoning is in obfuscate on the build server only.
Verifying it actually works
Unit tests will not catch a discovery failure — they reference the project and never run the protected DLL. Two checks will. First, run the protected app end to end: start the Functions host against the protected output (locally with func start, or a staging slot) and confirm the host log lists your functions by name — ProcessOrder indexed, not an empty app — then call each trigger and assert the real behaviour, that a known request produces the expected quote. An app that starts clean but lists no functions is the exact signature of a missed keep rule. Second, open the shipped assembly in a decompiler and read it the way an attacker will: the *Functions type names are present (they must be), but their bodies, the pricing service, the internal helpers and the integration strings are gone or unreadable. Glass.NET is the free, static-only companion for that pass; the full procedure is in did your obfuscation actually work?.
The takeaway
The isolated worker is unusual in that it finds your functions through strings a source generator baked into the assembly at compile time, before obfuscation ever runs. Rename a trigger method and you move it out from under the string that names it, and the host — reading the stale string — quietly indexes nothing. Keep exactly two things: the trigger methods, so their names still match the generated EntryPoint strings, and the DTOs whose properties are a serialization contract. Everything else — the pricing, the scoring, the secrets, the whole interior that is the only reason the function exists — hands to Nebula.NET to rename, string-encrypt and virtualize. Then run the protected build, watch the host index your functions by name, and read the shipped DLL to confirm the logic behind them is gone.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.