Design guidance
Published
Somewhere in your app there is a line like if (license.valid) { unlockEverything() }. It is the highest-leverage line you ship: flip that one boolean and the paid product becomes free. So the question “how do I protect my licence check” is really two questions, and getting them the wrong way round is what makes most licensing schemes disappointing.
Start with the uncomfortable part
Code that runs on hardware the user controls is code the user can change. They can open the debugger, edit the bundle, intercept the response, or simply set a breakpoint and change the value on its way past. No amount of transformation changes that, because the machine executing your check belongs to the person you are checking. Any client-side scheme is therefore a cost, not a lock.
That is not a reason to skip it — costs matter, and most people who would casually unlock something will not spend an evening on it. It is a reason to put the thing you actually care about somewhere else first.
Rule 1: put authority where the attacker isn’t
Before protecting the check, move what the check guards. Concretely:
- Gate data server-side. If the premium report is generated by your backend and the backend refuses without entitlement, the client-side flag decides which button is grey — not whether the customer receives value.
- Gate expensive operations server-side. Anything that costs you money to run is a natural enforcement point, because it is already a request.
- Never ship the secret that validates the licence. A signing key, a shared secret, an admin token — if it is in the bundle it is public, whatever it is wrapped in. This is the wall you cannot design around.
- Make offline grace explicit and bounded. If the app must work without a network, decide how long and accept that the offline window is the bypass window.
After this, a bypassed client gets a nicer-looking UI and nothing else. That is the outcome you want before spending anything on transforms.
Rule 2: protect the path so the bypass has to be found
Some products genuinely have to enforce on the client — a desktop app, an offline tool, a game, a widget licensed per domain. And even when the server holds authority, a readable client hands an attacker your protocol: which field to forge, which response to replay, which branch to invert. So protect the path.
What matters here is narrowness. The licence path is a small amount of code that runs rarely, which makes it the ideal place for the expensive transforms you would never apply broadly:
- Bytecode virtualization on the validation function itself. The logic is compiled to custom bytecode and executed by a shipped interpreter, so the original statements are not present as JavaScript — a reader has to understand the interpreter before they can understand the check. Slow relative to native code, which is fine for something that runs at startup and irrelevant for something that would run 60 times a second. See Maximum Mode and VM Protection.
- Control-flow flattening across the surrounding module, so the branch structure stops being visible at a glance. See Flat Transform.
- String encryption on every literal in that path — endpoint names, error messages, storage keys, plan identifiers. Searching for
"trial_expired" should not be a shortcut to the branch. See Encrypt Strings.
- Cross-file member and global renaming, so the vocabulary of your entitlement model does not survive into the bundle.
If you want the mental model behind those options, obfuscation techniques explained covers what each layer removes and what it costs.
Rule 3: design the check so one boolean isn’t the whole game
Protection buys much less when the code hands the attacker a single obvious target. A few structural habits raise the cost far more cheaply than any transform:
- Do not concentrate the decision. A lone
isLicensed read by everything is one patch away from defeat. Let the entitlement value participate in real work — deriving a value the feature needs — so removing the check breaks the feature rather than freeing it.
- Avoid the tell-tale failure message. A distinctive “Your trial has expired” string is a search index into your licensing code. Encrypt it, and keep the message far from the decision.
- Validate late and repeatedly, not once at boot. A single startup gate is a single place to skip.
- Fail closed, quietly. An informative rejection is free debugging for whoever is attacking you.
Rule 4: turn tampering into information
The failure mode of client-side licensing is not being bypassed — it is being bypassed silently, for two years, at scale. Runtime defense detects debugger attachment and modification of the shipped bundle and reports those events through a collector token, so attempts arrive as triage evidence you can export and route.
Two honest caveats. Client-side tamper signals are evidence, not proof: a determined attacker with control of the runtime can suppress them, so treat the feed as first-triage information rather than a verdict. And this is protection and evidence, not a staffed monitoring service — JSO does not replace a managed SOC or an incident-response operation. Used the right way it still changes the economics, because a bypass that gets noticed within a week is a very different business problem from one that never surfaces.
Putting it together
- Move every decision you can to the server; make the client’s copy advisory.
- Keep secrets out of the bundle entirely.
- Virtualize the validation function; flatten and encrypt the module around it.
- Spread the consequence of entitlement through real work instead of one flag.
- Instrument tampering and actually read the results.
- Smoke-test the protected build end to end — licensing paths are exactly where an over-eager rename breaks a storage key nobody thought about.
None of this makes a bypass impossible, and any vendor telling you otherwise is selling something. What it does is move the bypass from “an afternoon with the devtools open” to “a reverse-engineering project” — while making sure that the project, once completed, unlocks as little as possible.
Related reading