Obfuscate an Angular app

Angular v17+ obfuscation is a postbuild pass over dist/<app>/browser: via the CLI, or the @afterpack/angular helper.

Modern Angular (v17+) builds with @angular-devkit/build-angular:application. That builder is esbuild-based but exposes no plugin hook, so, unlike the Vite, webpack and esbuild plugins, AfterPack cannot hook into the build here.

The integration is a postbuild pass over the emitted browser bundle. @afterpack/angular is a convenience wrapper over that pass. It is not a builder plugin.

Install

$ npm install -D @afterpack/angular
// scripts/obfuscate.mjs — run after `ng build`
import { afterpackAngular } from "@afterpack/angular";

await afterpackAngular({ seed: "git" });

Or run the CLI directly instead, with nothing to install.

Build

The application builder writes the client bundle to dist/<app>/browser.

With the helper, findAngularBrowserDir(distRoot) finds the browser output. Pass browserDir in a monorepo. findAngularBrowserDir is also exported separately if you want just the locator.

With the CLI, point it at the browser output directly:

// package.json
{
  "scripts": {
    "build": "ng build && afterpack dist/my-app/browser --seed=git"
  }
}

Point the CLI at it and every emitted .js chunk is obfuscated in place. The CLI's backup lands in .afterpack/backup/ at the project root, outside dist/, so it does not ship with the deploy; npx afterpack@latest restore undoes the run. The @afterpack/angular helper is the case to watch: build: { backup: true } there writes a .backup.<hash> sibling into dist/, which is your original source inside the tree you deploy.

The builder writes the bundle before the pass can run, so your unobfuscated bundle is on disk until the pass completes, and anything that can read dist/ in that window can read it. A failed pass leaves that output in place: the build fails, so nothing ships, but the files stay until the next build overwrites them.

Fail-closed: afterpackAngular throws if the browser directory holds no .js/.mjs/.cjs, or if the engine reports a failing diagnostic.

Verify

Enable sourceMap: { enabled: true } for the build you want to inspect, so the Protection Map can render your original source. It writes to the gitignored .afterpack/protectionMap.html. It contains your full source. Never commit it or serve it.

Both the CLI and the helper flip the Protection Map off under NODE_ENV=production / CI=true; the helper accepts protectionMap: { enabled: true } to force it back on, with a warning.

Never ship source maps to your CDN

A production source map reaches your original source. Generate it for the build, then withhold it from your deployed dist/.

Options

Every configuration key can be passed here as the options object, for example afterpackAngular({ preset: "hard", complexity: 40 }). These options exist only on this package, for locating the build output:

OptionTypeDefault
cwdstringprocess.cwd(); the project root that owns .gitignore and .afterpack/
distRootstring<cwd>/dist; where to look for the browser subdirectory
browserDirstringunset. Skips auto-location entirely

There is no build.autorun option here.

Directives

directives is refused here, in every layer — the options object, AFTERPACK_directives and afterpack.json alike. Angular's application builder is sealed, so this pass only ever sees already-minified output and has no hook to capture /* @afterpack ... */ comments from. Setting it fails the build rather than being silently ignored. Hand-authored regions still work, because they name byte ranges instead of comments.

Edge cases

Legacy Angular (webpack). Projects still on the older webpack-based builder can obfuscate in-build with @afterpack/webpack via a custom builder. The postbuild path above works uniformly across both builders and is the recommended one.

Next