A Hangfire application looks like an easy obfuscation target. The job classes are plumbing; the valuable code is what they call into. But Hangfire has one seam that a naive obfuscation pass walks straight into, and the failure looks nothing like an obfuscation bug: the app deploys, the dashboard is green, new jobs run — and then every recurring job and every job that was already sitting in the queue starts failing with Could not load type. The assembly is valid. Hangfire simply cannot find the methods it wrote down earlier.
The reason is specific to how Hangfire persists a job, and once you see it the fix is a short keep-list. This post is about that mechanism — exactly what Hangfire writes to its storage, why renaming a job class breaks resolution and not the code, and how to protect the interior with Nebula.NET without disturbing the names a worker reads back.
Why obfuscate a Hangfire app at all
The job methods are thin, but the work they orchestrate is the product: the invoicing run, the dunning logic, the risk re-scoring, the settlement reconciliation, the pricing export. That code runs headless on a server, often one your customer administers, and the deployed artifact is a DLL anyone with file access can open in a decompiler and read back as clean C#. The background tier is also where the sensitive constants cluster — downstream endpoint URLs, partner keys, queue and header names — because nobody is looking at it. Protecting your own shipped code here is exactly the legitimate case for obfuscation: raise the cost of reading your business logic back out of the binary you handed over.
What Hangfire actually writes to storage
Here is an ordinary job class and the two ways it gets scheduled:
namespace Contoso.Billing.Jobs;
public sealed class InvoiceJobs
{
private readonly IInvoiceService _invoices;
public InvoiceJobs(IInvoiceService invoices) => _invoices = invoices;
// The method Hangfire records and later resolves by reflection.
public void ProcessOrder(long orderId)
{
_invoices.Settle(orderId); // proprietary — the reason this exists
}
public void GenerateDailyReport() => _invoices.ExportDaily();
}
// Fire-and-forget: runs within seconds, on this deployment.
BackgroundJob.Enqueue<InvoiceJobs>(j => j.ProcessOrder(80421));
// Recurring: the definition lives in storage and is re-enqueued
// on its cron schedule across every future deployment.
RecurringJob.AddOrUpdate<InvoiceJobs>(
"daily-invoice-report",
j => j.GenerateDailyReport(),
Cron.Daily);
The crucial thing is what the Enqueue and AddOrUpdate calls do not store. They do not store a delegate, a reference, or a token the runtime can follow back to your method. Hangfire takes the expression j => j.ProcessOrder(80421), reduces it to a Job descriptor, and serializes that to its storage (SQL Server, PostgreSQL, Redis — the store is yours and generic) as a small JSON record called InvocationData. For the fire-and-forget call above it looks like this:
{
"Type": "Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing",
"Method": "ProcessOrder",
"ParameterTypes": "[\"System.Int64, System.Private.CoreLib\"]",
"Arguments": "[\"80421\"]"
}
Four strings. Type is the fully-qualified, assembly-qualified type name. Method is the method name as plain text. ParameterTypes is a JSON array of assembly-qualified parameter type names, used to disambiguate overloads. Arguments holds the serialized argument values. There is no metadata token, no type identity — just names.
When a worker picks the job up, it reverses the process. Conceptually:
var type = Type.GetType("Contoso.Billing.Jobs.InvoiceJobs, Contoso.Billing"); // from "Type"
var method = type.GetMethod("ProcessOrder", new[] { typeof(long) }); // name + ParameterTypes
method.Invoke(ActivatorResolve(type), DeserializeArgs(...)); // run it
That is the whole trap. Those strings were written at enqueue time and never change. Obfuscation runs after compilation, on the DLL, and if renaming turns InvoiceJobs into a and ProcessOrder into b, the stored InvocationData is untouched — it still names the originals — but the type and method it names no longer exist. Type.GetType returns null, the method lookup fails, and Hangfire throws a JobLoadException: Could not load type 'Contoso.Billing.Jobs.InvoiceJobs' or The type does not contain a method with signature 'ProcessOrder'. Nothing is wrong with the assembly; the name the stored job was told to find simply isn't there anymore.
Why recurring and delayed jobs are the sharpest edge
A fire-and-forget job is the forgiving case: it is enqueued and runs seconds later, on the same deployment, so the strings in storage and the code they name agree. You could rename across that gap and most jobs would already be done.
Recurring jobs and long-delayed (scheduled) jobs are the ones that bite. A recurring job's definition is written once to the RecurringJob hash and then lives in storage indefinitely, re-enqueued on its cron schedule by every future deployment of your app. A Schedule-delayed job sits in the scheduled set until its time arrives — which can be hours or days. Both routinely outlive the deployment that created them, so they are resolved by a worker running newer code. That is precisely the deploy-N-writes, deploy-N+1-reads situation, and it is where renaming the job class quietly turns a green release into a pile of Could not load type failures the morning after.
The audit: what renames, what doesn't
Walk every name Hangfire resolves from storage, and the keep-list writes itself.
-
The job classes —
Contoso.Billing.Jobs.InvoiceJobs. TheTypestring in everyInvocationDatanames them. Keep the type names. -
The methods Hangfire invokes —
ProcessOrder,GenerateDailyReport, and anything referenced by aRecurringJob.AddOrUpdateor anEnqueue/Scheduleexpression. TheMethodstring names each one. Keep these method names. Keeping the class is not enough; the method lookup is by name too. -
Custom parameter types — if a job method takes one of your types rather than a
longor astring, that type's assembly-qualified name is written intoParameterTypes, and the method lookup resolves it. Keep any custom type used as a job-method parameter (and keep its serialized property names, since Hangfire also deserializes the argument value). Framework types likeSystem.Int64are not yours to rename and need no rule. -
The job ID strings —
"daily-invoice-report"is a literal you pass; it is data, not a symbol, and string encryption leaves recurring-job identity intact because Hangfire compares the stored key, which your code reproduces at runtime the same way. No action needed. -
The constructor and DI — Hangfire activates the job type through its
JobActivator, resolving dependencies by type. Renaming preserves type identity. Rename freely. -
Everything the job calls into —
IInvoiceService,InvoiceService.Settle, the private helpers, the algorithm. Nothing outside the process names these. Rename, encrypt, virtualize.
Two seams to keep, then — the job types and the methods Hangfire invokes (plus any custom parameter types) — and everything else is fair game.
The keep-list
Model the job classes under one namespace so a single rule covers them, and nebula.config.json reads:
{
"schemaVersion": 1,
"inputs": [
"publish/Contoso.Billing.dll"
],
"outputDirectory": "protected",
"preservePublicApi": false,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Contoso.Billing.Jobs.*", "action": "keep", "scope": "type-and-members" },
{ "pattern": "Contoso.Billing.Contracts.*", "action": "keep", "scope": "members" },
{ "pattern": "Contoso.Billing.*", "action": "rename" }
]
}
}
The first rule pins every type under Contoso.Billing.Jobs and its members, so each invoked method keeps the name the stored InvocationData expects — scope: "type-and-members" is what preserves ProcessOrder, not just the class. The second keeps the members of the DTO types any job method takes as a parameter, so the parameter-type names in ParameterTypes resolve and the serialized argument properties still bind, while the type names themselves can rename if nothing else names them. Everything else in the assembly is renamed.
If you would rather not rely on a namespace convention, annotate the handlers directly — the attribute travels with the code and cannot drift out of sync with a pattern:
[Obfuscation(Exclude = true, ApplyToMembers = true)] // keep this job's names for Hangfire resolution
public sealed class InvoiceJobs { /* ... */ }
Keeping names vs. a custom type resolver
Hangfire does expose a resolution seam: you can register a custom type resolver with GlobalConfiguration.Configuration.UseTypeResolver(...) and, in principle, translate the old stored type strings into the renamed types. Resist it. That approach couples your running app to the obfuscator's rename map, has to stay correct across every future rename, and still has to handle the Method and ParameterTypes strings — not just the type. The keep-list is a two-line rule that never drifts and costs you only the names of a handful of orchestration types. Keeping the names is the robust path; the type-resolver seam is a last resort for a codebase that genuinely cannot keep them.
Drain the queue during deploy — as a complement, not a substitute
A good deploy practice narrows the window further: stop enqueuing new work, let the workers finish what is in flight, and only then roll the new build. For transient fire-and-forget jobs that empties the one category that could straddle the deploy. But it does not save you on its own — a recurring job definition and a job scheduled days out both persist in storage across the drain, so they are still read back by the new code. Draining reduces the transient exposure; keeping the names is what makes the persistent jobs survive. Do both.
Folding protection into the build
Protect the published output, and only on the build server so local debugging stays fast and readable:
dotnet publish Contoso.Billing -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 the protected/ folder, 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 an unprotected DLL — or worse, one that renamed a job class your storage still references.
Protecting the interior
Keeping a method's name does not mean leaving its body readable. The job stays resolvable while everything it does becomes opaque. String encryption hides the endpoint URLs, keys and queue names the background tier accumulates. The strongest transforms — code virtualization and whole-method encryption, both Pro features in Nebula.NET — go on the crown jewels: the settlement, scoring or reconciliation logic the jobs wrap, applied to that handful of methods rather than the whole assembly. You can even virtualize the body of a job method while its name is kept:
public sealed class InvoiceService : IInvoiceService
{
[Obfuscation(Feature = "virtualization")] // Pro — the algorithm, not the plumbing
public void Settle(long orderId) { /* ... */ }
}
Renaming and control-flow obfuscation run broadly underneath both, turning the rest of the interior into meaningless symbols and tangled branches.
Verifying it actually works
Unit tests will not catch this: they call ProcessOrder directly against the project, never through a stored InvocationData against the protected DLL. Two checks will. First, run it end to end against real storage — enqueue and, critically, register a recurring job on the pre-obfuscation build, deploy the protected build on top of that same storage, and confirm the worker resolves and runs the already-persisted job. A job that fails with Could not load type only after the protected deploy 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. Glass.NET is the free, static-only companion for that pass: load the protected DLL, confirm Contoso.Billing.Jobs.InvoiceJobs.ProcessOrder is still present by name — it must be, to match what is in storage — then confirm there is nothing left to read inside it or in the InvoiceService it calls.
The takeaway
Hangfire finds your background work through strings it wrote to storage earlier — the fully-qualified type name, the method name, and the parameter types — and resolves them back to a MethodInfo by reflection when a worker runs the job, often on a later deployment than the one that enqueued it. Rename the job class or method and you move it out from under those stored strings; the worker, reading the unchanged InvocationData, fails to load the type before your code runs. Recurring and long-delayed jobs make this the first thing to break, because they outlive the deploy that created them. Keep exactly what storage names — the job classes, the methods Hangfire invokes, and any custom parameter types — drain the transient queue during deploy as a complement, and hand the entire interior to Nebula.NET to rename, string-encrypt and virtualize. Then run a persisted job through the protected build, watch it resolve and complete, and read the shipped DLL to confirm the logic behind it is gone.
Top comments (0)