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 strings stop being readable text. String literals are encoded, so a
grepfor a key, an endpoint or the copy around a gate returns nothing, and so do the scanners and scrapers that read bundles at scale. Numbers stay readable atlight; frommediumup most integers are encoded. - The output is a reshaped program. AfterPack rewrites the logic into equivalent forms that stock tools cannot fold back. A reader who works through the artifact can recover a working program, but only after real effort, and a fresh build means a tool written against the last one no longer matches.
- The cost recurs. Every build has a different shape, so a tool or a patch written against last release's patterns breaks on this one. Values someone already read from the code stay read.
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 you raise is the cost of modifying it: a patch or tool written against one build's patterns breaks on the next. 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 every build has a different shape, and tools written against one build's patterns break on the next. Someone who runs your code can still read the values it uses.
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 5): it encodes string literals, renames identifiers and rewrites syntax, and adds no structural layers. Raise it globally for a production build that needs real protection:
$ npx afterpack@latest dist/ --preset=hardThe ladder is minify (0), light (5), medium (7), hard (12), extreme (25); see Presets. --complexity=<number> overrides the target the preset carries: --preset=hard --complexity=40 is hard at target 40. A value set this way applies to every token, even on light.
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 a check over its own check table. Untouched, it has no effect on behavior. It detects an edit to that table only; an edit elsewhere in your code is not detected. It needs code atmediumor above to attach to, so alightbuild carries it only inside a region raised to one. From engine 0.2.3, a build with no such code fails withDIAG_SELF_INTEGRITY_INERTinstead of shipping without the check.
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 12 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/backups/, 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 keep .afterpack/ out of git with a .gitignore inside it, never edit yours, and warn when an artifact lands under a served path. A .map or .backup.<hash> file beside the build is yours to keep out of git.
A source map or backup contains your full source. Never deploy either.
A production build also deletes the source maps your bundler wrote: .map files, hidden and inline maps, sourceMappingURL comments and CSS maps. The CLI and every plugin do this, and a map that can't be removed fails 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, string literals are encoded, so a key held in one is not readable in the file you deploy. The few literals that always stay readable are listed under Presets. 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.