Obfuscation should be the default for shipping web apps
Anything in your shipped JavaScript is readable. Secrets, business logic, the algorithm you rely on: anyone can open the bundle, and a frontier LLM will explain it line by line on request. The security scanner shows exactly what comes out of a real production bundle. Run it on yours to see what you are already giving away.
Obfuscating that bundle buys concrete, checkable properties:
- A patch written against your app stops surviving your releases. A userscript, a Tampermonkey script or an extension that customizes or automates your app has to anchor on something stable: a function name, a literal string, a recognisable shape. Renaming and string encoding remove those anchors, and a new seed reshapes the output on every build. The author writes the patch against one release; it no longer matches the next one, and the users they shipped it to are left with a broken script until it is rewritten.
- A find-and-replace against your code has nothing to match. Simple text substitution, the cheapest way to automate or alter a web app, works on
isSubscribedand on"Upgrade to continue". Neither string is in the file you ship. - Your constants stop being readable text. Literals are encoded, so a
grepfor a key, an endpoint, a price table or the copy around a gate returns nothing, and so do the scanners and scrapers that read bundles at scale. - The output is not your program. AfterPack rewrites the logic into equivalent but information-destroying forms. A reader who works through the artifact recovers a working program, not the one you authored, and the algorithmic choices that made yours worth copying are gone.
- The cost recurs. There is no build-invariant structure to target, so a deobfuscator or a patch written against last release does not carry to this one, and the work someone already did on it does not transfer.
None of this is a guarantee, and it is not a substitute for server-side authority. Any client code can be reversed to some degree, because the runtime has to run it. What changes is the price and the cadence: whoever wants to read or modify your app pays for it again on every release you ship. The rest of this page is how to spend that protection where it counts.
Rotate your build, stay ahead of attackers
A single obfuscated release is a fixed target that a captcha-breaker, a cheat extension or a scraper can adapt to, given enough effort. A fresh seed on every build is the default, so each release is already structurally different from the last and whoever adapted to one starts over on the next.
If you ship rarely, that defense goes quiet between releases. Add a rebuild on a schedule, independent of code changes, so the deployed bundle still rotates; Build & CI wires it into a pipeline.
Pick a preset, then mark what matters
The default preset is light (complexity target 2). Raise it globally for a production build that needs real protection:
$ npx afterpack@latest dist/ --preset=hardThe ladder is minify (0), light (2), medium (8), hard (25), extreme (80); see Presets. --complexity=<number> overrides just the target the preset carries, leaving the preset's size budget alone. --preset=hard --complexity=40 applies hard's size limits at target 40.
Then mark the regions where being wrong costs the most, so the heavier treatment lands where it earns its size cost.
Mark a region with a directive
/* @afterpack preset=hard */
function checkLicense(token: string): boolean {
return verifySignature(token, PUBLIC_KEY);
}
/* @afterpack end */
const apiKey = /* @afterpack preset=extreme */ "YOUR_SECRET";
// only the rest of this line is coveredDirectives is the reference: the grammar, every key a directive can set, what to mark and why, and what happens to a directive in a build that cannot apply it. Region overrides take effect on Pro builds.
The two anti-tamper transforms
Anti-tamper is not part of preset=hard. It is two explicit Pro config flags, both off by default, and not applied on Free:
transforms.comparisonHardening.enabledrewrites a strict equality against a build-time string literal (x === "LIT") into a one-way digest check, so the literal is absent from the artifact entirely. Use it for any check that compares against a constant you don't want grepped out.transforms.selfIntegrity.enabledadds an integrity check over the obfuscated output. Untampered, it has no effect on behavior. A tampered file fails to decode correctly.
Set both for the whole program. See Configuration.
Protect a domain-locked license or trial
AfterPack has no built-in domain-lock or expiry transform. Write the check yourself, and AfterPack protects the JavaScript you write. For a per-domain license check with a trial expiry:
/* @afterpack preset=hard */
if (location.hostname !== "customer-domain.example.com") return;
if (Date.now() > TRIAL_EXPIRES_AT) return;
/* @afterpack end */With transforms.comparisonHardening.enabled on, the location.hostname comparison becomes a digest check, so the allowed host never appears in the artifact. A grep for the domain finds nothing. preset=hard puts the surrounding logic at complexity target 25 with the constants floor on, and a fresh seed reshapes it on every release.
This raises the cost and cadence of patching the check; it does not remove the check from the attack surface. A determined attacker who runs the code can still find and patch the comparison, but has to redo that work on every release. Treat this as trial friction, and pair it with a server-side entitlement call for any check that gates real revenue.
Don't waste protection on third-party code
Vendor bundles, such as React, Lodash, or a CSS-in-JS runtime, are worth little protection. They are already public, they inflate your bundle, and they tend to use runtime reflection that needs acknowledging.
paths.exclude skips a matching file entirely, before it is parsed, and returns it byte for byte. An excluded file is reported as DIAG_PATH_EXCLUDED. It works at the file level: when a library build concatenates your code and a dependency into one output file, there is no separate file to exclude. Use identifiers.reserved instead.
Four ways to choose what AfterPack sees:
- Exclude the file with
paths.exclude, for example["**/*.min.js", "dist/vendor/**"].*and?match inside one path segment, a whole**segment matches zero or more segments, and matching is case-sensitive. - Point the CLI at the directory you want protected. It recurses the build directory and takes every
.js/.mjs/.cjsit finds, skipping nestednode_modules/(include it withpaths.include) and its own.backup.<hash>copies. A vendor chunk outside that directory is never touched. - Split vendor code into its own chunk in your bundler, then run AfterPack against a directory that excludes it. A vendor chunk obfuscates fine if you'd rather accept the cost.
- Mark regions
skipwhere a bundled dependency's code sits in the same file as yours and needs to stay recognizable.
Your imports still resolve.
AfterPack fully supports ES2022 private class fields and methods: #field and #method() parse correctly and get renamed and protected like everything else.
Preserve what must stay readable
Some code must keep its original names for the runtime to work. Framework presets cover the common cases. For the rest, preserve narrowly. Never disable renaming globally, or you throw away the cheapest layer of protection across your whole bundle.
| Situation | What to do |
|---|---|
| Public exports callers import by name | /* @afterpack skip */ … /* @afterpack end */ around the export, or pin the names with identifiers.reserved |
| Stack traces you actually debug | Symbolicate from source maps you keep off the deploy tree. identifiers.rename=false keeps every identifier verbatim across the whole bundle |
| Reflection-based DI (Angular, NestJS, Inversify) | reflection.allow in config to acknowledge a custom pattern; an unacknowledged pattern is a build error |
instanceof / class-name switching | identifiers.reserved with the class names, or /* @afterpack skip */ around the declaration |
| Third-party API symbols bundled into the same file as your own code (e.g. a Rollup/Vite library build that inlines a dependency) | identifiers.reserved, with a plain name for the whole build or a { glob, names } entry for matching files |
| A script nothing else calls into | identifiers.globals.rename: true. Opts in to renaming the ambient global surface the engine otherwise preserves. Off by default, and only safe when nothing external reads those names |
identifiers.reserved preserves only the names you list, wherever they appear, matched against the authored source-level name before any renaming happens: exact, whole-identifier, case-sensitive. Available on Free. Reach for it when you can't mark a whole region because your code and a vendored dependency share a file.
To keep names in one place only, use identifiers.reserved or /* @afterpack skip */; see What you can set.
Each preservation lowers protection on those lines. Apply it only where the runtime requires it.
Seed strategy
The seed determines the shape of the obfuscated output. A random seed per build is the default. Keep it that way for production: a build that changes every release is the primary defense.
Pin the seed in exactly one place: CI, when you need reproducible output for cache hits or golden-snapshot tests.
$ npx afterpack@latest dist/ --seed=git--seed=git derives the seed from git rev-parse HEAD. With no repository, it falls back to a fresh random seed and prints a notice rather than failing the build. That keeps output deterministic per commit while still rotating every release.
The seed also has an environment variable, AFTERPACK_seed, with the same values as --seed. Multi-bundle builds share one seed automatically, in-process and across processes through this variable. Set it yourself only to reproduce a specific build; an explicit --seed or seed option still wins over it.
Build production once and promote the same bytes through qa → preprod → prod. Dev builds are un-obfuscated, so day-to-day development is unaffected.
Do not ship a constant seed to production. It stops the output from changing shape between releases.
Keep the artifacts out of the deploy
AfterPack writes three kinds of local artifact. Each one is more sensitive than the bundle it describes.
- The Protection Map (
protectionMap.html) embeds your original source. It is always written into a gitignored.afterpack/directory at the project root, never alongside the served build, and a production build warns when it is on. Turn it off with--protectionMap.enabled=false. - The backup of your originals. Before the CLI rewrites a file in place it copies the original into
.afterpack/backup/, alongside a manifest of what it obfuscated each file to. That directory is your source verbatim, it stays on the build machine, andnpx afterpack@latest restoreputs the originals back. Opt out with--build.backup=false. A framework plugin writes no copy by default;build.backupset totruethere writes a.backup.<hash>sibling next to the output instead, where it would ship with the build. - Source maps chain obfuscated output straight back to original source. The
.mapsibling defaults off in production, and the//# sourceMappingURL=comment defaults off in production too.
The plugins and the CLI append the guard globs (.afterpack/, *.protectionMap.html, protectionMap.html, *.backup.*, *.map) to the project root's .gitignore automatically, and warn when an artifact lands under a served path.
A source map or backup contains your full source. Never deploy either.
@afterpack/next also deletes every .js.map from the served tree after the build.
Debug production traces without publishing the map
A production build writes no .map and no //# sourceMappingURL= comment, which is the right default and leaves you symbolicating stack traces by hand. If your team needs real traces from production, build the map and keep it private instead of skipping it.
Turn the map on for the build and keep the pointer off, so nothing in the shipped JavaScript advertises that a map exists:
{
"sourceMap": { "enabled": true, "emitUrl": false, "sourcesContent": true }
}Then keep the .map files out of the deployed directory: upload them to your error tracker or keep them as a CI artifact, and symbolicate there. If your deploy copies the whole build directory, deny the files at the edge as well, so a request for one never returns it.
location ~ \.map$ {
return 404;
}The same rule has an equivalent everywhere: a CDN or WAF rule matching *.map, a proxy deny, or a storage bucket that simply never receives the files. Check it from outside your network with curl -I https://yoursite.example.com/assets/app.js.map, expecting a 404, and with npx afterpack@latest audit, which reports a reachable map as a finding.
An edge rule protects files that are on the server. Anything that bypasses it, a misconfigured route, a direct bucket URL, a preview deployment, serves the map with your original source inside it. Not deploying the maps is the version that cannot be misconfigured.
If you shipped a secret by accident
An API key, token, or password that reached your bundle is a live incident. AfterPack changes what a reader finds in the shipped file. It does not change the fact that the credential went out.
What AfterPack does to it. At the default preset, every string literal in your source is encoded, so the value is not readable in the file you deploy. Searching the bundle for the key, or for the text around it, returns nothing. Automated secret scanners and bundle scrapers find nothing either, and those are how most leaked keys get discovered.
What AfterPack does not do. The credential is still in the build, and your code still uses it. Anyone running your application in a browser they control can reach the value at the moment the code uses it. Encoding buys you time to respond. It does not make a client-side secret safe.
What to do, in order:
- Rotate the key. Treat it as compromised from the moment the build went out. This is the step that closes the incident.
- Move the authority server-side. Have your own backend hold the credential and decide whether a caller may have the result. A key the browser never receives cannot leak.
- Check what is live. Run
npx afterpack@latest auditagainst your deployed site. It reports exposed secrets, leaked source maps, and protection level for the JavaScript the site actually serves, so you learn what a reader can pull out of production right now. - Rebuild and redeploy, so the bundle carrying the old value stops being served.
A credential that shipped has to be rotated, whichever preset built it.
Test the obfuscated build
Always run your integration tests against the obfuscated build. Testing the pre-obfuscation build tells you nothing about what ships.
A failure on the obfuscated build that passed on the plain one points at either a preservation gap (a name the runtime expected got renamed) or an unrecognized reflection pattern. The diagnostic usually names the config key that fixes it. Run with --diagnostics.level=all to see every diagnostic in full instead of the rolled-up summary.
Anything that would have shipped weaker protection than you configured stops the build instead, a file the engine cannot obfuscate and a Pro build that cannot reach the cloud included. Diagnostics has the exit contract and every code behind it.
Next
- Build & CI: promote-the-same-bytes lifecycle, artifact hygiene, and periodic rebuilds.
- Threat model: the adversary ladder these directives are calibrated against.
- Configuration: every config key in one place.
- Directives: the full region-marker grammar behind the table above.
- Presets: the five-level ladder these directives override per region.
- Protection Map: verify which class actually hit the regions you marked.
- Builds in the dashboard: the same numbers, per build, for your Pro Cloud builds.