Engineering
Published
Most advice about obfuscation quietly assumes you ship one bundled file. Increasingly people don't — they publish .mjs, or serve modules directly to the browser, or distribute a library whose consumers import individual entry points. That changes the rules, and the change is not a matter of tool configuration. It is a hard boundary in the language.
The boundary: export names cannot be renamed
An identifier renamer works by proving that every reference to a binding is visible in the code it can see. Inside one file that proof is easy. Across a module boundary it is impossible, because the other side is a different file that the tool may never be given.
So a module's exported names are fixed. This is not a limitation anyone can engineer around — the names are the interface:
// before
export function computeQuote(seed) { ... }
// if a tool renames this...
export function _0x25963(seed) { ... }
// ...then every consumer breaks, at link time, loudly:
// SyntaxError: The requested module './quote.mjs'
// does not provide an export named 'computeQuote'
This applies to all six inline forms — export function, export async function, export function*, export class, export const/let/var — and to the separate clause form export { computeQuote }. The one exception is export default: its local binding name is not part of the interface, so it can and should be renamed.
Imports are the mirror image and are worse. import { helper } from './other.js' names a binding that lives in another module. Renaming it is not under-obfuscation, it is simply wrong — the name has to match what the other file exports, and the other file may not even be yours.
So what do you actually get?
More than the above makes it sound. The public surface of a well-designed module is small; the implementation behind it is not. Here is a real before/after, minus the string table for readability:
// input
const privateHelper = (n) => n * 3;
function alsoPrivate(n){ var localTemp = n + 1; return privateHelper(localTemp); }
export function compute(seed){ var scratch = seed; return alsoPrivate(scratch); }
export default function secretDefault(){ return compute(4); }
// output
var _0x241BA=function(_0x242DA){return _0x242DA*3};
function _0x23F7A(_0x242DA){var _0x2433A=_0x242DA+1;return _0x241BA(_0x2433A)}
export function compute(_0x243FA){var _0x2439A=_0x243FA;return _0x23F7A(_0x2439A)};
export default function _0x2421A(){return compute(4)}
Six names in, one survives. compute stays because it must; its parameter, its local, both private functions, their parameters and their locals all go. Everything the string transforms do — extraction, encoding, table lookup — is unaffected by the module boundary and applies normally. So does control-flow obfuscation.
The practical implication for how you structure code: a narrow export surface obfuscates better than a wide one. If you export forty helpers because it was convenient, forty names are now permanently legible. That is good module design anyway; protection just gives you a second reason.
Why this specific bug is so easy to ship
Worth saying plainly, because we shipped it: renaming an export produces output that passes every check a tool normally runs. It parses. It contains no dangling references. It executes fine on its own. The only thing that notices is an importer — and a tool testing its own output in isolation has no importer.
The fix on our side was to make module samples actually link: the test harness now imports the emitted .mjs and asserts the resulting namespace has exactly the expected export names, then calls through them and compares the values. That turns a parse-only check into a real one. If you are evaluating any tool for module output, this is the specific question to ask — not “do you support ESM” but “does your test suite import the output?”
Test it yourself in two files
Protect a module, then link against it:
// check.mjs -- point it at the PROTECTED output
import * as m from "./protected/mod.mjs";
const expected = ["Engine", "VERSION", "compute", "default", "loadIt"];
const actual = Object.keys(m).sort();
console.log(JSON.stringify(actual));
if (JSON.stringify(actual) !== JSON.stringify(expected.sort()))
throw new Error("export surface changed");
// and prove the exports still WORK, not just that they exist
console.log(m.compute(5), await m.loadIt(), new m.Engine().run());
Run the same file against the original and the protected build and diff the two outputs. Checking the names alone is not enough — names can survive a transform that wrecked the bodies — so always call through at least one export.
Three more module-specific things worth knowing
- Modules are always strict. There is no sloppy mode in a module, so a
"use strict" directive in one is redundant and losing it changes nothing. The reverse matters more: code that worked as a sloppy script may break the moment it becomes a module, and that is a property of your code, not of the obfuscator.
- Whole-program wrapping and modules do not mix.
import and export are only legal at the top level of a module, so any transform that wraps the program in a function — self-compression, eval packing, some self-defending modes — cannot be applied to module output. If a tool offers those options alongside ESM, check what it actually does when you enable both.
- Bundling first is a legitimate strategy. Running a bundler before protection collapses the module graph into one file, and internal cross-module names stop being a public contract at that point — so they become renameable. If your export surface is wide and you control the consumers, bundle-then-protect gives you strictly more obfuscation than protect-per-module. It costs you tree-shaking granularity for your consumers, which may or may not matter.
The honest summary
Protecting an ES module is real protection with one published seam: the names you chose to export stay readable, along with anything an importer can reach through them. That is not a weakness a better tool would fix — it is what the module system means. What you should verify is that a tool respects the seam exactly, rather than renaming across it and breaking your consumers, or refusing to rename anything nearby and giving you less protection than you paid for.
Frequently asked questions
Can an obfuscator rename exported names in an ES module?
No, and it should not try. An export name is a public contract: another module resolves it by that exact string at link time, so renaming it breaks the import unless every consumer is rewritten in the same pass. When you ship separate module files rather than one bundle, the consumers are outside the scope of your build, which makes the export surface a hard boundary rather than a configuration choice.
What about imported names - can those be renamed?
The local binding can be renamed, because that name only exists inside your module. The name being imported cannot, because it belongs to the module you are importing from and has to match what that module exports. So an import statement has one half that is fixed by somebody else's contract and one half that is yours to change.
If export names must survive, what protection do I actually get?
Everything inside the module, which is usually the great majority of it. Internal functions, local variables, private helpers, control flow, string literals and the structure of your implementation are all in scope. What remains visible is the shape of your public interface, which your consumers were entitled to see anyway because they have to call it.
Why is this particular bug so easy to ship?
Because it does not fail in the file you protected. The module itself is valid and loads fine; the failure appears in a consumer that asks for an export which no longer exists, and only when that consumer runs. If your smoke test loads the module without importing anything specific from it, everything looks correct right up until an integration breaks.
How do I test that a protected module still links?
Write a second file that imports from the protected one exactly the way a real consumer does, and run it. Two files and a single command are enough to catch every renaming mistake in the export surface, because linking either resolves or it does not. Make that the standard check whenever you protect something distributed as modules rather than as a bundle.
Does bundling first change any of this?
Substantially, and it is the simpler path where it is available. Once a bundler has resolved the module graph into a single artifact, the internal export names have become ordinary internal references and are free to be renamed - only the bundle's own entry points remain a contract. Shipping separate module files is the case that imposes the boundary, and it is a distribution decision rather than a protection one.
Related reading