Compatibility

Does Obfuscation Break WebAssembly Interop?

If part of your application is already WebAssembly, protecting the JavaScript around it raises a question the usual compatibility checklists do not cover. The wasm module and the JavaScript that loads it are joined by a set of names, and only one side of that join is JavaScript. The other side is a string table inside a binary that a JavaScript obfuscator does not read, cannot parse and will never rewrite.

What was measured

The sample is a hand-encoded WebAssembly module, small enough to state completely: it imports one function as env.log, exports one function as run, and run(n) calls the imported function with n and returns n * 2. The JavaScript side compiles it with new WebAssembly.Module(bytes), instantiates it with an import object { env: { log: fn } }, reads instance.exports.run and calls it.

That covers the four things the join is made of: the module namespace name, the imported field name, the exported function name, and the JavaScript callback that wasm reaches back into. The sample also records what a genuinely wrong import key does, so the failure mode has a baseline to be compared against.

Protected in five configurations -- the default target, the modern target, both gate profiles and the string-transform profile -- the output was identical every time. The module compiled, the instance linked, the imported callback was invoked with the argument wasm passed it, run(21) returned 42, and Object.keys(instance.exports) still read run.

This is the expected result and it is worth stating plainly, because none of the transforms have anything to bite on here. The wasm bytes are a Uint8Array of numbers; the loader is ordinary function calls. There is no syntax to downlevel and no control flow that the module boundary depends on.

The name that is not in your source code

The interesting property of a wasm import is that the name is not written where you might expect. When you write { env: { log: fn } } in JavaScript, you are not choosing those names. You are matching names that were fixed when the module was compiled, and they live in the import section of the binary as UTF-8 strings.

That makes the import object a contract with a file the obfuscator never opens. It is a sharper version of a pattern this site keeps arriving at: a name is safe to rename when it is only ever reached by code the tool can see, and unsafe when something outside that file reads it. Here the outside reader is not a library or the language -- it is a second artifact in a different format.

Point member renaming at those keys and the result is immediate. With a member pattern matching env and log, instantiation threw TypeError: WebAssembly.Instance(): Import #0 "env": module is not an object or function. The object handed to the instantiation call no longer has a property called env; it has a generated name, and the binary is still asking for env.

The same applies on the way out. With a pattern matching exports and run, the read failed with TypeError: Cannot read properties of undefined, because instance.exports is a property the platform defines and the protected file was asking the instance for a generated name instead.

The good news about this failure

Both failures are loud, and they happen at load time. A wasm module links when you instantiate it, which in most applications is during startup, long before a user does anything specific. You do not get a build that works for a week and then fails on an unusual code path.

That puts wasm interop in the better half of the failure taxonomy this series keeps building. Compare it with renaming an error's message, which produces a crash reporter that silently ships nothing, or renaming a stream's underlying-source callbacks, which produces a process that exits successfully with no output at all. A LinkError or a TypeError during startup is the failure you want.

The sample also pins the difference between a renamed key and a genuinely wrong one. Passing { env: { logger: fn } } -- the right namespace, the wrong field -- throws LinkError, which is the platform telling you precisely which import it could not satisfy. Renaming produces a slightly different message because the whole namespace object goes missing, but both land in the same place: a stack trace at instantiation naming the import.

Your own JavaScript is unaffected. The array the imported callback wrote into was renamed along with every reference to it and measured identical, because that name never leaves the file.

Generated glue is the practical problem

Almost nobody hand-writes a wasm loader. Emscripten, wasm-bindgen and the toolchains built on them emit a JavaScript glue file, and that file is dense with exactly the names that must not move: import namespaces, mangled export names, and internal helpers the module calls back into.

The mitigation is scoping rather than avoidance. Member renaming is driven by a pattern, and the useful discipline is to write a pattern that matches the members your own application code owns rather than one that matches everything and then tries to carve out exceptions. If the glue file is a separate artifact, the cheapest option is to leave it out of the protected set entirely and protect the application code that calls into it.

This is the same shape as the advice on the platform surfaces elsewhere in this series, with one difference worth keeping in mind: with a DOM API you can usually reason about which names belong to the platform. With generated wasm glue the names are chosen by a compiler, they change when you rebuild, and some of them are neither obviously yours nor obviously the platform's.

Identifier renaming is the safer transform here. Local variables and function declarations inside the glue can be renamed without touching the join, because the join is made of property names and string literals. It is member renaming specifically that reaches the contract.

What this does and does not protect

It is worth separating two questions that get asked together. This page is about what happens to the JavaScript when part of your application is already WebAssembly. Whether moving logic into WebAssembly is itself a protection strategy is a different question, and one this site answers elsewhere at some length.

The short version of that other answer, which matters here because it sets expectations: a JavaScript obfuscator protects JavaScript. Running it over a project that contains a .wasm file does not transform the module. The binary ships as it was compiled, and wasm binaries decompile to readable pseudo-code with widely available tools.

So the honest description of a protected wasm application is that the JavaScript half received the transforms and the wasm half received none of them. If the logic you care about is in the module, the protection you applied is not where your risk is. If the logic you care about is in the JavaScript that orchestrates the module, it received exactly what you asked for.

Neither half changes the rule that anything shipped to a browser has been given to the person running it. WebAssembly raises the effort of casual reading; it does not change who has the bytes.

How to check your own build

The check is short because the failure is loud. Protect your build, load the page or run the entry script, and watch for a thrown error during instantiation. If the module links and your first exported call returns the value you expect, the join survived.

If you want something that fails more precisely, log Object.keys(instance.exports) in the protected build and compare it with the unprotected one. Those strings come from the binary, so they should be byte-identical; if they are not, the read itself is being renamed rather than the export list changing.

For a build that uses generated glue, the fastest triage is to protect the application code and the glue separately and see which one moves the failure. That distinguishes a member pattern that is too broad from a genuine incompatibility, and in every case measured here it was the former.

Frequently asked questions

Does obfuscation break WebAssembly interop?

Not in anything measured here. A module compiled, instantiated, called its imported JavaScript callback and returned the same value in all five configurations, and the export list was unchanged. The loader is ordinary function calls and an array of bytes, so none of the transforms have syntax to change.

Why does my protected build fail with a LinkError or an import error?

Almost certainly because member renaming matched the keys of your import object. Those keys are not names you chose; they are strings compiled into the wasm binary, and the obfuscator never reads that file. With a pattern matching env and log, instantiation failed immediately with an error naming the missing import.

Is the wasm module itself obfuscated when I protect my JavaScript?

No. A JavaScript obfuscator transforms JavaScript. A .wasm file passes through your build untouched, and wasm binaries can be decompiled to readable pseudo-code with common tooling. If the logic you want to protect lives in the module, protecting the JavaScript around it does not reach it.

Can I use member renaming at all in a project that loads WebAssembly?

Yes, with a pattern scoped to members your own code owns. The failures measured here all came from patterns that reached the import namespace, the imported field or the exports object. Identifier renaming is safer in glue code, because the join between JavaScript and wasm is made of property names and strings rather than local bindings.

What about the glue file that Emscripten or wasm-bindgen generates?

Treat it as third-party code. Its names are chosen by a compiler and change when you rebuild, which makes it a poor fit for a hand-maintained exception list. The simplest arrangement is to leave the generated glue out of the protected set and protect the application code that calls into it.

How much harder does moving code into WebAssembly make analysis?

It raises the effort of casual reading, because the shipped artifact is bytecode rather than source text. It does not change who holds the bytes: anything delivered to a browser has been given to the person running it, and decompilers for wasm are freely available. Treat it as a change in effort, not a change in the trust boundary.

Related reading