The AfterPack Blog
securityguideAI

How to protect JavaScript source code in production

by Nikita Savchenko9 min read

To protect JavaScript source code, stop serving source maps, keep secrets and access checks on the server, and obfuscate the code that has to ship. Every visitor downloads your bundle, and minification only shortens local names: strings, API routes, feature flags and pricing rules stay readable. AfterPack (afterpack.dev), the JavaScript obfuscator, turns your bundle into a structurally different program on every build. Its open-source CLI runs free and locally with npx afterpack; Pro adds per-region directives and anti-tamper.

To show what that looks like, I built a small demo app: four TypeScript files with seat pricing, promo codes, feature flags, a free-plan export limit and two API calls. I bundled it with esbuild 0.28.1 (--bundle --minify --sourcemap) on 2026-09-27. Everything below is real output from that build, and you can run the same checks on your own production site in about five minutes with DevTools, one curl, and the free security scanner.

Can users see your JavaScript code?

Yes. Every script your page runs is downloaded to the visitor's browser, and DevTools lists all of it.

View Page Source (Ctrl+U, or Cmd+Option+U on macOS) only shows the HTML and its script tags; DevTools shows the scripts themselves. React, Next.js and Vue apps ship the same way: your components arrive as JavaScript files anyone can open.

Open your production site, not localhost. In Chrome, open DevTools (Cmd+Option+I on macOS, F12 elsewhere) and go to the Sources tab. Your bundles are listed under your domain. Click one. If it isn't pretty-printed already, click the {} button at the bottom.

Now search across all loaded files with Cmd+Option+F (Ctrl+Shift+F on Windows and Linux). Try /api, /v1, internal, admin, free, pro, trial, promo, and the name of any feature you haven't announced yet.

What does minified JavaScript expose?

Everything except local names. The whole minified bundle of my demo is 995 bytes:

(()=>{var f={LAUNCH40:.4,PARTNER_FRIENDS:.5};function i(e,t,o){let r=e*1200;e>=25&&(r*=.9),t&&(r*=1-.2);let p=o?f[o.toUpperCase()]??0:0;return r*=1-p,Math.round(r)}function a(e,t){return e==="free"?t<3:!0}var l={newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0};function s(e,t){return e==="aiSummaries"?t==="pro"||t==="team":l[e]}var u="https://api.example.com";async function c(e){return(await fetch(`${u}/v2/billing/invoices`,{headers:{Authorization:`Bearer ${e}`}})).json()}async function m(e){return fetch(`${u}/internal/admin/trials/${e}/extend`,{method:"POST"})}var n={id:"u_1",plan:"free",exportsThisMonth:2};document.querySelector("#price").textContent=String(i(10,!0,"launch40")/100);document.querySelector("#export").addEventListener("click",()=>{if(!a(n.plan,n.exportsThisMonth)){alert("Upgrade to Pro to export more than 3 files a month");return}s("aiSummaries",n.plan)&&console.log("summaries on")});window.__debug={fetchInvoices:c,grantTrial:m};})();
//# sourceMappingURL=app.js.map

The local names are gone; everything else is still there, including the names I put on window on purpose, like grantTrial:

What the source hadStill readable after minification?Where it is in the bundle
Local function and variable namesNo, shortenedquote() became i()
Promo codes and their discountsYesLAUNCH40:.4,PARTNER_FRIENDS:.5
Per-seat price in cents and the 25-seat volume discountYese*1200, e>=25&&(r*=.9)
The free plan's limit of three exportsYese==="free"?t<3:!0
All three feature flagsYesnewCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0
API host and routes, including an admin oneYes/v2/billing/invoices, /internal/admin/trials/${e}/extend
Names attached to windowYeswindow.__debug={fetchInvoices:c,grantTrial:m}
Original files, types and constant namesOnly if the source map is publicthe sourceMappingURL line

Minification makes files smaller; it was never meant to hide anything.

Are source maps a security risk in production?

Yes, when the map is public. A source map usually embeds your original files, so serving one publishes your source.

Look at the last line of each bundle. A comment like //# sourceMappingURL=app.js.map tells the browser where the map lives. Some setups send it as a SourceMap response header instead, so check the Network tab too.

Then request the map yourself and check the body, not the status code. Many hosts answer any unknown path with 200 and your index.html. If this prints 3, you are shipping a source map:

curl -s https://example.com/assets/app.js.map | jq .version
# 3 (my demo, served locally; an HTML page gives a jq parse error instead)

A source map usually carries a sourcesContent array, an optional field of the source map format: your original files, exactly as written. Two jq calls list them and print one:

1curl -s https://example.com/assets/app.js.map | jq -r '.sources[]'
2# ../src/pricing.ts
3# ../src/flags.ts
4# ../src/api.ts
5# ../src/app.ts
6
7curl -s https://example.com/assets/app.js.map | jq -r '.sourcesContent[0]' | head -8
8# const SEAT_PRICE_CENTS = 1200;
9# const ANNUAL_DISCOUNT = 0.2;
10# const PROMO_CODES: Record<string, number> = {
11# LAUNCH40: 0.4,
12# PARTNER_FRIENDS: 0.5,
13# };
14#
15# export function quote(seats: number, annual: boolean, promo?: string) {

That is my TypeScript, with its types and its constant names, straight from the file on my disk. DevTools does the same thing silently: when the map loads, the Sources tab shows your original folder tree next to the bundle. A published map is also what turned the Claude Code npm package into a news story, although its bundle had been readable all along.

Can AI read minified or obfuscated JavaScript?

Yes. A current model explains a minified bundle from one prompt, down to the pricing formula and an exposed admin hook, and AI agents read obfuscated code too: in an earlier test, one turned two obfuscators' published demos back into clean source in 10 and 20 minutes.

On 2026-09-27 I gave the minified demo bundle above (without the map) to Claude Sonnet 5 through Claude Code 2.1.283, with no tools and this prompt, followed by the bundle in a js code fence:

This is a minified JavaScript file I downloaded from a website. Explain what it does in plain English: the pricing rules, the feature flags, the API endpoints it calls, and anything a competitor could copy or abuse. Be concise.

It recovered the full pricing formula, down to the price a 10-seat annual plan pays with LAUNCH40: $57.60. It also found the part I had planted and not pointed to:

window.__debug.grantTrial / fetchInvoices expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call __debug.grantTrial('<any user id>') from the browser console.

The full answer, unedited
1## Pricing logic (`i()`)
2- Base rate: **$12/seat/month** (`e*1200` in cents).
3- **25+ seats** → extra 10% off (stacks).
4- Annual billing (`t` truthy) → 20% off.
5- Promo codes (case-insensitive): `LAUNCH40` = 40% off, `PARTNER_FRIENDS` = 50% off. Discounts stack multiplicatively (seat discount × annual × promo).
6- Example in the code: 10 seats, annual, `launch40` → $120 → $96 (annual) → $57.60 (promo), shown as `57.6` on `#price`.
7
8## Feature gating
9- Export limit: free plan capped at **<3 exports/month**; every paid plan is unlimited (`a()`).
10- Flags: `newCheckout: true`, `aiSummaries: false`, `teamSeatsBeta: true` (`l`).
11- `aiSummaries` is force-enabled for `pro`/`team` plans regardless of the global flag — so it's effectively live for paying users even though the flag reads "off" (likely a soft-launch/A-B mechanism).
12
13## API endpoints
14- `GET /v2/billing/invoices` — bearer-token authenticated, returns invoice list.
15- `POST /internal/admin/trials/{userId}/extend` — an **internal/admin** endpoint shipped in a public client bundle, with no auth header attached in this call at all.
16
17## What a competitor/attacker could copy or abuse
18- **Hardcoded discount codes** (`LAUNCH40`, `PARTNER_FRIENDS`) are fully visible client-side, including their exact percentages — trivial to discover and use even if not publicly advertised.
19- **Exact free-tier limit** (3 exports/month) and pricing formula are exposed, handy for competitive benchmarking/undercutting.
20- **`window.__debug.grantTrial` / `fetchInvoices`** expose the admin trial-extension and invoice-fetch functions directly in the global scope of production JS. Anyone can open devtools and call `__debug.grantTrial('<any user id>')` from the browser console. If the server doesn't independently re-verify admin privileges (and the client sends no auth token here), this is a **self-service trial/privilege-extension exploit** — the single biggest risk in this file.
21- Naming leaks internal API structure (`/internal/admin/...`), which helps an attacker guess/probe other admin routes.
22- `teamSeatsBeta` flag reveals an unreleased feature (competitive roadmap intel).
23
24**Bottom line:** the real vulnerability isn't the pricing math, it's that debug/admin hooks (`grantTrial`) were left reachable from `window` in production — that should be stripped from the shipped bundle and the endpoint should require server-side admin auth regardless.

I checked it against the source: every fact in it is right. The one loose phrase is <3 exports/month, which it later states correctly as 3. That took one prompt, and anyone curious about your product can send it.

How to scan your site for exposed source code and source maps

Paste your production URL into the free AfterPack security scanner; it needs no account. It renders the page in a real browser, collects every script it loads, and reports exposed secrets, public source maps, and how protected each bundle is. The same scanner runs from a terminal:

npx afterpack@latest audit https://example.com

Audit a live site lists what it checks and the exit codes for CI.

How to protect JavaScript source code

Remove what obfuscation can't protect first, then obfuscate the rest: a public source map or a key in the bundle gives the code away however the bundle itself is written.

StepWhat it protects againstHow
1. Don't serve source mapsYour original files, types and comments going publicHidden maps, uploaded privately, .map files deleted from the deploy
2. Move secrets and authority to the serverKeys being read, limits and admin routes being bypassedServer-side checks; the client check is only UI
3. Obfuscate the logic that has to shipStrings being searched and logic being read or patchednpx afterpack@latest dist/ --preset=hard, or a framework plugin

How to disable source maps in production

Don't serve them. If you need them for error tracking, generate them without the sourceMappingURL comment:

Bundler or frameworkSettingWhat you get
esbuild--sourcemap=externalA .map file, no comment in the bundle
Vitebuild.sourcemap: "hidden"A .map file, no comment in the bundle
webpackdevtool: "hidden-source-map"A .map file, no comment in the bundle
Rollupoutput.sourcemap: "hidden"A .map file, no comment in the bundle
Next.jsproductionBrowserSourceMapsOff by default; check nobody turned it on
Create React AppGENERATE_SOURCEMAP=falseNo maps at all (they're on by default)

Upload the maps privately to your error tracker, then delete the .map files from the deploy directory. A hidden map left on the server is still one guessed URL away. Debug production traces without publishing the map has an edge rule for the case where one slips through.

Keep secrets and checks on the server

An API key in the bundle is readable by whoever runs the page, whatever you do to the file. The same goes for decisions: a free-plan limit checked only in the browser is a suggestion, promo codes belong in a server-side lookup, and the admin route in my demo is only as safe as the server's own check. Keep the client check for the UI. Enforce it on the server.

Obfuscate the JavaScript that has to ship

Some code only works in the browser, like a live price calculator or an in-browser editor. For that code, run AfterPack on the build output:

npx afterpack@latest dist/ --preset=hard

With no --preset you get light, which encodes string literals. That stops a text search for your routes and flags; it doesn't make pricing math hard to follow. For code a competitor would want, like pricing and plan gating, use hard, or paste a function into the playground first to see what each level does to it. Presets lists what each level adds. My demo still computes the same $57.60 after hard (AfterPack 0.2.0, 2026-09-29), and there are framework plugins if you'd rather have your normal build do it.

The shape matters as much as the preset. A fixed bundle is a target: a model or a script reverse engineers it once, and the result holds until your next release. AfterPack draws a new seed on every build, so each release is a structurally different program, and a deobfuscation script or annotated map made for last week's bundle doesn't apply to this week's. With @afterpack/wasm inside your own Cloudflare Worker, the code can change shape per request; the Workers tutorial has the full setup, and the launch post shows one function before and after.

Start with one search: open DevTools on your own production site and look for internal. Whatever comes back, every visitor already has.

FAQ

Can you completely hide JavaScript from users?

No. The browser has to download code to run it, so anyone can read what it receives. You can keep secrets and authority on the server, stop publishing source maps, and make the code that ships expensive to read with obfuscation.

How do I stop people copying or reverse engineering my JavaScript?

You can't stop the download; you can make the copy expensive to read and quick to go stale. Obfuscate with --preset=hard, ship a new shape every build (or every request from your Worker), and keep authority on the server, so a copied client can't do more than the original. Pro's anti-tamper makes a modified copy break instead of running.

Does obfuscation hide API keys?

No. A key in the bundle is readable by whoever runs the page, obfuscated or not. Keep keys on the server and call them from there; the threat model says what obfuscation does and doesn't protect.

Does obfuscated JavaScript still work the same?

It should, and when it can't, you find out at build time: a file the engine can't obfuscate fails the build and names the file, so a broken bundle never ships silently. Run your integration tests against the obfuscated build, not the plain one: that is what ships.

Is AfterPack free?

The local engine is free with every preset, and the CLI and framework plugins are open source. Pro adds per-region directives, anti-tamper and cloud builds billed by the megabyte; plans has the numbers.

— Nikita

Stay updated

Follow AfterPack for release notes and write-ups.