Skip to content
← All posts
· Delta1 Labs Nebula.NETObfuscation.NETGuide

Obfuscating a multi-project .NET solution: one consistent protection strategy

A solution with a dozen class libraries and an app is not a dozen obfuscation problems — it is one. Protect each project in isolation and you double-protect transitive copies, break InternalsVisibleTo across your own assemblies, and end up with a pile of rename maps that do not line up. Here is the playbook: decide what you actually ship and protect only that, run one pass over the publish output, drive every assembly from a single shared nebula.config.json, rename across the whole solution so friend access and cross-references stay in sync, keep one seeded map per release, and wire it into CI so every shipped build is protected identically.

A solution with ten class libraries and an app looks like ten obfuscation problems. It is one. The projects reference each other, expose types to each other, share internals through [InternalsVisibleTo], and ship together as a single unit a customer installs or restores as one package. Protect them one at a time and you get the worst of every world: transitive copies obfuscated twice, cross-assembly references that no longer resolve, friend access broken between your own DLLs, and a drawer of rename maps that do not line up when a crash report walks through three of them. This post is the playbook for doing it as the single problem it is — deciding what to protect, where to run the pass, how to drive every assembly from one policy, and how to prove the result across the whole set in Nebula.NET.

Step 1 — Protect what you ship, and only that

Before any config, settle the scope. Your solution builds far more than it ships: test projects, benchmark harnesses, source-generator helpers, sample apps. None of those go to a customer, so none of them need protecting — obfuscating a test assembly you never distribute is pure cost. Start from the list of assemblies that actually leave the building, which is exactly what your dotnet publish (or your .nupkg) contains.

The subtler trap is transitive copies. When your app publishes, every dependency it pulls in lands in the output folder — including third-party packages and, if you are not careful, copies of libraries from other solutions. You protect the assemblies you authored and ship; you do not re-obfuscate a dependency someone else already shipped (you have no map for it, no right to alter it, and nothing to gain). So the inputs list names your own assemblies by name, not “everything in the folder.” Enumerate the ones with your namespace and leave the rest alone.

Step 2 — Run the pass once, over the publish output

The single most common multi-project mistake is running obfuscation per project, in each project’s bin/Debug, as part of every developer build. That is wrong on two axes. It protects the wrong artifact — Debug binaries nobody ships — and it protects them in isolation, before the solution has been assembled into one coherent output. Developers also lose the thing they need most: a readable, debuggable build.

Run the pass once, late, over the assembled publish output, on the build server only. Publish the whole solution, then hand Nebula the published assemblies as a single run. This is the same discipline as obfuscating only on your build server: developer machines build clean and debuggable; the agent that produces the shippable package is the one place protection happens, so the binary you ship is the only binary that is ever obfuscated.

solution projectsAcme.AppAcme.CoreAcme.RulesAcme.Plugins.AbstractionsAcme.Tests (not shipped)one Nebula run4 shipped assembliescrossAssemblyRenameone shared configprotected assemblies(publish/protected)one map setper release

Step 3 — One shared policy, not a config per project

The transforms you run, what you keep public at the boundary, where the seed map lives — these are properties of the product, not of any single project. So they belong in one solution-level nebula.config.json, read by the one publish-output run. A config fragment per project is how a solution drifts into being protected inconsistently: one library ends up with string encryption, the next without, and nobody notices until a decompiler does.

Here is the whole policy for a four-assembly product, in one file. It names the shipped assemblies as inputs, renames across them as a set, keeps the one namespace that is a public contract, and seeds from last release’s map:

{
  "schemaVersion": 1,
  "inputs": [
    "publish/Acme.App.dll",
    "publish/Acme.Core.dll",
    "publish/Acme.Rules.dll",
    "publish/Acme.Plugins.Abstractions.dll"
  ],
  "outputDirectory": "publish/protected",
  "crossAssemblyRename": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "obfuscateConstants": true,
  "seedMapFile": "maps/Acme-prev.symbols.json",
  "renaming": {
    "rules": [
      { "pattern": "Acme.Plugins.Abstractions.*", "action": "keep" },
      { "pattern": "Acme.*", "action": "rename" }
    ]
  }
}

crossAssemblyRename, unlimited string encryption, control-flow obfuscation and constant masking are Pro features; identifier renaming is in every edition. The rename rules are evaluated top to bottom, first match wins — the narrow keep for the plugin contract sits above the broad Acme.* catch-all, exactly as in building a Nebula keep-list. One file, one policy, every assembly treated the same.

Step 4 — Rename across the solution, not per project

crossAssemblyRename: true is the line that makes a solution work. Your assemblies reference each other constantly: Acme.App calls a type in Acme.Core, which calls one in Acme.Rules. When Nebula renames Acme.Core.PricingEngine to a, it must rewrite the call sites in Acme.App and Acme.Rules to a as well, or those references dangle. A single run with cross-assembly renaming on does exactly that: it treats the input set as one namespace to rename and rewrites every reference across it.

The sharp edge is [InternalsVisibleTo] between your own assemblies — the internal types one of your projects exposes to another (most often a test project, but also a split-for-layering sibling). Those internal names are a compile-time contract; rename the producer alone and the consumer still references the old names and fails to bind. Because every assembly that references the renamed internals is an input to the same run, cross-assembly renaming keeps both sides in step — the internal type gets the same new name at its definition and at every friend reference. The full mechanics, including the runtime-reflection variant that no compiler warns you about, are in obfuscating InternalsVisibleTo and friend assemblies. The rule that falls out of it is the rule of this whole post: if two of your assemblies share names, they share a run.

Step 5 — One seeded map per release

A per-project obfuscation leaves you with a per-project map, generated against that project’s isolated rename decisions, and nothing guarantees they agree. A stack trace from the field rarely stays inside one assembly — it walks from Acme.App through Acme.Core into Acme.Rules — and decoding it means every frame’s map was produced by the same run. Running the whole solution once gives you exactly that: one coherent set of .symbols.json maps for the release, all generated against the same decisions.

Then carry the names forward. Point seedMapFile at the previous release’s maps so members that did not change keep the obfuscated name they already had; only new code gets new names. Across a release series that means one archived map keeps decoding the same method, and a binary diff shows your actual change instead of a global renumber. The full treatment — why stable names make crash symbolication and delta patching tractable, and why seeding is an operational tool rather than a security knob — is in keeping obfuscated names stable with a seed map. Archive a version-stamped copy of the maps for every release; they are what decode that build’s crash reports forever.

Step 6 — Keep the solution boundary, rename freely inside it

A multi-project product has exactly one surface that must survive renaming: whatever the outside world binds to by name. For a shipped SDK that is the public API; for an app with a plugin model it is the plugin contract — the abstractions assembly a third party compiles their plugin against. Everything inside the solution boundary, no matter how many assemblies it spans, is yours to rename freely, because nothing outside references it.

That is why the config above keeps Acme.Plugins.Abstractions.* whole and renames everything else, rather than flipping on preservePublicApi for the entire set. preservePublicApi keeps every public type across every input — but inside your own solution, “public” is just the access modifier your assemblies use to see each other; it is not an external contract. Keeping it all would surrender most of your protection for no reason. Scope the keep to the one assembly that is genuinely a boundary, let cross-assembly renaming rename the public-but-internal-to-the-product surface, and you protect the maximum while keeping the contract your plugin authors depend on intact.

Step 7 — Wire it into CI

Assemble the whole thing into a pipeline step that runs on the agent and nowhere else: publish the solution, fetch the previous release’s maps to seed from, run the single protection pass, then archive this build’s maps so the next release can seed from them.

# Release pipeline — runs on the build agent only.
steps:
  - run: dotnet publish Acme.sln -c Release -o publish
  # Seed from last release's maps (a no-op on the very first release).
  - run: fetch-artifact Acme-latest.symbols/ -> maps/Acme-prev.symbols.json
  - run: nebula --config nebula.config.json      # one run, all shipped assemblies
  # Archive this build's maps for seeding the next release and for decoding crashes.
  - run: publish-artifact publish/protected/*.symbols.json as Acme-latest.symbols/
  - run: publish-artifact publish/protected/*.symbols.json as Acme-${VERSION}.symbols/
  - run: pack-and-sign publish/protected

Gate it on a signal that exists only on the agent — the NEBULA_OBFUSCATE environment variable from the build-server-only setup — so a developer who runs dotnet publish locally still gets a clean, debuggable build. The pack-and-sign step points at publish/protected, never at the raw publish output, so the artifact that leaves the pipeline is the protected one. Every shipped build now runs the identical policy over the identical input set: protection is a property of the release, not of whoever happened to build it.

Verify across assemblies in Glass.NET

The failure mode this playbook guards against is invisible at build time — the app runs, the tests pass, and one assembly quietly shipped in the clear or one cross-reference quietly dangles. So check the output, and check all of it. Glass.NET is the free, static-only companion for this: it reads metadata and IL and never runs the binary, so open each protected assembly from publish/protected in turn.

Confirm three things across the set. First, every shipped assembly is actually protected — internals read as a, b, c and your strings are encrypted in each one, not just the first. Second, a type renamed in one assembly is renamed to the same name where another assembly references it — cross-assembly renaming is consistent, with no dangling reference to an old name. Third, the boundary survived: your plugin-contract assembly still reads with its real public names. Then run the integration suite against the protected output, because name-resolution breakage across assemblies is only visible once a path executes. The full before/after procedure is in did your obfuscation actually work?.

Treat the solution as one unit and the whole thing gets simpler, not harder: one config, one run, one map set per release, one boundary kept, one CI gate. Download Nebula.NET free and run it over your publish output as a single pass.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.