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.
Related reading