Engineering

How to verify an obfuscator didn’t silently break your JavaScript

Most advice about testing a protected build stops at “make sure it still parses.” That is the least useful check available, because the failures worth fearing all produce output that parses perfectly and then behaves differently. This is a practical ladder for catching them — written from the experience of building the gates that catch them in our own engine, including the ones that caught our own defects.

Why “it parses” proves almost nothing

Every transform an obfuscator applies is a rewrite, and a rewrite can be syntactically valid while being semantically wrong. Three real examples, all of which pass node --check without complaint:

// 1. An arrow loses its lexical `this`
[1].map(() => this.v)        // becomes:  [1].map(function(){ return this.v })
                             // parses fine, reads undefined

// 2. A unary minus fuses with the one in front of it
Math.abs(- -x)               // becomes:  Math.abs(--x)
                             // parses fine, returns x - 1

// 3. A tagged template stops calling its tag
tag`solo`                    // becomes:  "solo"
                             // parses fine, the tag never runs

Each of these is a wrong answer, not a crash. Your build succeeds, your bundle ships, and the failure shows up as a subtly wrong number or a component that renders nothing. Note the second one especially: it only fails loudly when the operand is itself a unary (- -(-x) becomes ---x, which will not parse). With a plain variable it is completely silent. A test suite that only checks the build succeeded would pass all three.

The verification ladder

Four rungs, weakest to strongest. Each catches a class the one below cannot see.

1. Parse the output

node --check bundle.js, or feed it to any real parser. This catches output that is outright malformed. It is worth doing because it is free, and it is worth being clear that it is the floor, not the bar.

One practical note: on a minified bundle, node --check reports the error without a position, which is useless on a single 600 KB line. Parsing with acorn instead gives you an exact character offset, which turns a needle-in-a-haystack into a two-minute fix.

2. Scan for leaked internals

Obfuscators use placeholder tokens during transformation. If one survives into the output it becomes an undeclared identifier — valid syntax, guaranteed ReferenceError at runtime. Grep the output for your tool’s internal prefixes. This is a cheap, high-signal check that a parse pass will never flag.

3. Run your existing test suite against the protected build

This is the rung most teams skip, and it is the one that pays. You already have tests. Point them at the protected output rather than the source. If your suite exercises a class hierarchy, a this-capturing callback, or a destructured API response, it will catch the silent classes above without you writing anything new.

The practical obstacle is usually that the test suite imports source modules directly. Adding a build variant that resolves imports to the protected directory is normally a small amount of config, and it converts your entire existing suite into obfuscation verification for free.

4. Differential testing on real code

The strongest rung: run the same input through the pipeline, execute both the original and the protected output, and compare the results — not against expectations you wrote down, but against each other.

This distinction matters more than it sounds. Hand-written expectations encode what you think the code does. We have watched a probe pass by coincidence because the test function returned its own input unchanged, which happened to match the broken output exactly. Diffing against the original has no such blind spot: if protection changed behaviour, the two disagree, whatever you believed.

What to test, if you only have an hour

Not all syntax is equally likely to break. Rewrites go wrong where a construct needs context that a local transformation does not have. In rough order of risk:

  • Arrow functions that use this — the arrow must not become a plain function, or the receiver changes.
  • Class inheritance with supersuper.method() in an instance method, in a static method, and a bare super.prop read are three separate paths.
  • Getters and setters — an accessor emitted as a plain method returns the function instead of the value, silently.
  • Destructuring in every position — parameters, declarations, for…of heads, and catch clauses are usually handled by different code paths. Include defaults, renames, rest elements, and array holes.
  • for…of over non-arraysSet, Map, generators and iterator methods take a different route than arrays.
  • Tagged templates — styled-components, GraphQL clients and String.raw all depend on the tag actually being invoked, and on the strings array being complete.
  • Numeric literal forms — separators (1_000_000), binary and octal (0b1010, 0o17), and BigInt are lexer-level and fail as wrong values rather than errors.

Test on real libraries, not just snippets

Hand-written test cases share an author’s habits, and those habits hide bugs. The clearest example we have seen: a transform that spliced replacement text in place worked on every hand-written sample and broke on real input, because hand-written code puts a space after const and minified code does not. The output turned a whole declaration list into undeclared globals, and the error surfaced far away at an unrelated export statement.

Running a set of real, minified libraries through your pipeline finds this class immediately. It is also the cheapest suite you will ever assemble — the libraries already exist, and you only need them to parse afterwards.

An honest word about what this buys you

Verification tells you the protected build behaves like the original. It does not tell you the protection is strong, and no amount of testing makes obfuscated code impossible to analyse — obfuscation raises the cost of reverse engineering, it does not eliminate it. Those are separate questions, and it is worth keeping them separate when evaluating any tool.

What verification does buy is the ability to ship protection without holding your breath. That is worth more than it sounds, because the alternative — discovering a silent behavioural change from a customer report — is expensive in a way that a failed build never is.

Frequently asked questions

How do I verify an obfuscator did not break my code?

Work up a ladder rather than relying on any single check. Parse the output first, because that is cheap and rules out the crudest failures. Scan it for internals that should not have survived. Then run your existing test suite against the protected build rather than the original, which is the step most teams skip and the one that catches real behavioural change. Finish with differential testing on genuine code, comparing the protected and unprotected builds on the same inputs and diffing the results.

Is it enough that the protected file parses?

No, and treating it as sufficient is the most common mistake in this area. Parsing proves the output is syntactically valid JavaScript, which is a very low bar: a transform can produce a perfectly well-formed file whose behaviour differs from the original in ways no parser can see. Silent behavioural change is the failure worth worrying about, because it does not announce itself at build time and typically surfaces later as a customer report.

Should I run my test suite against the protected build?

Yes, and it is the highest-value step on the ladder because you already own the tests. The usual arrangement runs the suite against source and then protects the artefact afterwards, which means the thing you tested and the thing you shipped are different files. Pointing the same suite at the protected output closes that gap and costs almost nothing beyond the pipeline change. Whatever your tests already cover, they cover it for the build your customers actually receive.

What is differential testing and why does it matter here?

It means running the protected and unprotected builds against identical inputs and comparing the outputs, rather than asserting a list of expected values. It matters because it needs no prediction about which transform might misbehave: any divergence in observable behaviour shows up as a difference between the two runs. Use real code rather than snippets, because the constructs that break transforms tend to appear in libraries and framework code rather than in small hand-written examples.

What should I test if I only have an hour?

Prioritise the places where a transform has the most opportunity to change meaning, and where a failure would be silent rather than loud. Anything that depends on names surviving, such as serialisation, reflective access or a framework calling your members by name, is worth checking first because those fail quietly. After that, exercise your critical paths end to end against the protected build, on the grounds that a path nobody ran before release is a path nobody verified.

Does verification tell me my protection is strong?

No, and it is worth keeping the two questions apart. Verification tells you the protected build behaves like the original, which is a correctness question. How much effort a determined reader needs to understand that build is a separate question about strength, and testing says nothing about it. What verification buys is the ability to ship protection without holding your breath, since discovering a silent behavioural change from a customer report is far more expensive than a failed build.

Related reading