Localization is a by-name subsystem, and by-name is the one word that should make anyone reach for an obfuscator carefully. Every translated string your app shows was resolved at runtime by matching a key against a resource set, and resource sets are found by name — a base name the runtime turns into an embedded .resources stream and, for non-default cultures, into a satellite assembly it probes from disk. Obfuscation renames things. The question is whether any of the names it renames are ones the localization pipeline looks up.
The answer is genuinely split. The old, boring .resx designer accessor is safe for a reason worth understanding. The modern IStringLocalizer<T> is fragile for a reason worth understanding better, because it fails silently: no exception, no log, just your English keys showing through in a French build. Here is what each one emits, why they diverge, and the exact keep rules that make both safe under Nebula.NET.
Where the resource name actually comes from
Start with the classic path — a Strings.resx with its generated Strings.Designer.cs. The part that matters is how the accessor builds its ResourceManager:
internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { var temp = new global::System.Resources.ResourceManager( "MyApp.Resources.Strings", // base name — a string literal typeof(Strings).Assembly); // assembly — a token reference resourceMan = temp; } return resourceMan; } } The base name is a literal. The designer bakes "MyApp.Resources.Strings" into the IL as ldstr, and that literal is what ResourceManager appends the culture to — MyApp.Resources.Strings.fr.resources for French, found in the embedded manifest for the neutral culture and in fr/MyApp.resources.dll for the satellite. Rename the Strings type to a and the literal does not move; it still says "MyApp.Resources.Strings". The typeof(Strings) is rewritten by token like every other type reference, so it still points at the renamed type and still yields the right assembly. Both halves survive. Even string encryption leaves this working, because the literal is decrypted before it reaches the constructor — ResourceManager receives the plaintext base name exactly as written.
Now the modern path. IStringLocalizer<HomeController> resolves through ResourceManagerStringLocalizerFactory, and the factory does not get a literal. It computes the base name from the marker type:
// Program.cs builder.Services.AddLocalization(o => o.ResourcesPath = "Resources"); // HomeController.cs public HomeController(IStringLocalizer<HomeController> loc) => _loc = loc; // ... ViewData["Title"] = _loc["PageTitle"]; Behind that, the factory reads typeof(HomeController).FullName, strips the assembly's root namespace, and splices in ResourcesPath to produce a base name like MyApp.Resources.Controllers.HomeController. That base name is the lookup key — the thing a .resources stream has to be named after. It is derived from the type's identifier at runtime, which is precisely the identifier obfuscation rewrites.
The failure is silent, which is the real hazard
If IStringLocalizer threw when it could not find a resource set, this would be a loud bug you fixed in the first smoke test. It does not throw. The contract of IStringLocalizer is that a missing resource is a recoverable condition: it returns a LocalizedString whose ResourceNotFound flag is true and whose Value is the key you passed:
var s = _loc["PageTitle"]; // After a renamed marker type: // s.ResourceNotFound == true // s.Value == "PageTitle" // the key, shown verbatim in the UI So a French user sees PageTitle where the title should be, every satellite you shipped is still on disk and perfectly valid, and nothing in the logs points at it. The neutral-culture build often looks fine too, because many teams put the English text in the key or in a neutral fallback — so the regression only shows in the exact cultures a reviewer is least likely to click through. This is the strongest argument for treating localization as a protection target with its own keep rules rather than hoping auto-detection covers it.
The keep rules
Nebula auto-detects common ResourceManager usage, so the classic .resx accessor is generally handled for you — and even a renamed accessor type is fine because, as shown above, its base name is a literal. The path that needs an explicit decision is IStringLocalizer<T>: the marker types must keep their full names, because the factory turns FullName into the lookup key.
Pin exactly those marker types and nothing more. In nebula.config.json, put the narrow keep rules above the broad rename catch-all — first match wins:
{ "schemaVersion": 1, "inputs": [ "bin/Release/net8.0/MyApp.dll" ], "outputDirectory": "protected", "preservePublicApi": true, "encryptStrings": true, "controlFlowObfuscation": true, "renaming": { "rules": [ { "pattern": "MyApp.Controllers.*", "action": "keep" }, { "pattern": "MyApp.Resources.*", "action": "keep" }, { "pattern": "MyApp.*", "action": "rename" } ] } } Two things to notice. First, keep pins the name, not the body: a kept controller still has its private fields renamed, its method bodies control-flow-flattened, and its strings encrypted — you gave up renaming one type identifier, not the protection on its code. Second, keep the leaf, not the branch. If only a handful of types are used as IStringLocalizer<T> markers, pin those types rather than a whole namespace. For a surgical, code-local alternative, mark the exact marker type with an attribute that lives next to it:
[Obfuscation(Feature = "renaming", Exclude = true)] // used as IStringLocalizer<T> marker public class HomeController : Controller { /* ... */ } A subtler keep applies if you localize data annotations through resource types — [Display(Name = "Field_Email", ResourceType = typeof(Strings))]. The typeof(Strings) is a safe token reference, but Name = "Field_Email" is resolved by reflecting for a static property of that name on the resource type. That makes the generated accessor members load-bearing: keep the public members of any resource accessor type you reference from a ResourceType, not just the type name. IViewLocalizer needs nothing — it derives its base name from the view path, which no renaming touches.
Satellite assemblies need no work at all. A satellite is a resource-only assembly — culture .resources streams and no IL of yours — so there is nothing in it to rename or flatten. Leave fr/MyApp.resources.dll and friends out of your Nebula inputs and ship them as built. They keep resolving because Nebula preserves the main assembly name (so the runtime probes the right culture subfolder) and because, on the classic path, the base name the main assembly asks for is unchanged.
Verify in the culture, not just in English
Localization breakage hides from a default-culture run, so the acceptance test has to switch cultures and assert on a translated value, not merely that a string came back:
[Theory] [InlineData("fr")] [InlineData("de")] public void Localizer_returns_translation_not_key(string culture) { CultureInfo.CurrentUICulture = new CultureInfo(culture); var s = _loc["PageTitle"]; Assert.False(s.ResourceNotFound); // the real assertion Assert.NotEqual("PageTitle", s.Value); // guards against the silent key fallback } Run that suite against the protected build, not the debug build — the whole point is to exercise the renamed assembly. Pair it with a static check in Glass.NET, the free static-only companion: open the protected MyApp.dll, confirm your internal engine reads as a, b, c, and confirm your IStringLocalizer<T> marker types kept their full names and namespaces so the factory derives the base name the satellites were built against. For the general discipline behind minimal keep-lists, see keep-lists and rename exclusions; for the broader catalogue of by-name breakage, what obfuscation breaks.
Takeaway
Localization resolves by name, so the only question obfuscation raises is which names are the lookup keys. On the classic .resx path they are not identifiers at all — the base name is a baked literal — so the accessor type renames freely and even string encryption leaves it working. On the IStringLocalizer<T> path the base name is derived from the marker type's full name at runtime, so renaming that type moves the lookup off every .resources stream you shipped and the localizer silently serves the key. Keep the handful of marker types (and the public members of any resource type you reach through a data-annotation ResourceType), leave satellite assemblies out of the inputs and untouched, protect everything else in full, and prove it with a culture-switching test that asserts on the translation — not on whether a string came back.
