AfterPack 博客
securityguideAI

如何在生产环境中保护 JavaScript 源代码

由 Nikita Savchenko15 分钟阅读

要保护 JavaScript 源代码,就停止对外提供 source map,把密钥和访问校验留在服务端,并对必须发到浏览器的代码做混淆。每个访客都会下载你的 bundle,而压缩只会缩短局部变量名:字符串、API 路由、功能开关和定价规则依然清晰可读。AfterPack(afterpack.dev)是一款 JavaScript 混淆器,每次构建都会把你的 bundle 变成一个结构不同的程序。它的开源 CLI 可以通过 npx afterpack 免费在本地运行;Pro 版增加按区域的指令和防篡改。

为了演示实际情况,我做了一个小 demo 应用:四个 TypeScript 文件,包含按席位定价、优惠码、功能开关、免费版的导出次数限制和两个 API 调用。我在 2026-09-27 用 esbuild 0.28.1 打包(--bundle --minify --sourcemap)。下文所有内容都是这次构建的真实输出;用 DevTools、一条 curl 命令和免费的安全扫描器,大约五分钟就能在你自己的生产站点上做同样的检查。

用户能看到你的 JavaScript 代码吗?

能。页面运行的每一个脚本都会下载到访客的浏览器里,DevTools 会把它们全部列出来。

「查看网页源代码」(Ctrl+U,macOS 上是 Cmd+Option+U)只显示 HTML 和其中的 script 标签;DevTools 显示的是脚本本身。React、Next.js 和 Vue 应用也是同样的发布方式:你的组件以 JavaScript 文件的形式到达浏览器,任何人都能打开。

打开你的生产站点,不是 localhost。在 Chrome 里打开 DevTools(macOS 上按 Cmd+Option+I,其他系统按 F12),切到 Sources 标签页。你的 bundle 会列在你的域名下面。点开一个。如果还没有格式化,点底部的 {} 按钮。

然后用 Cmd+Option+F(Windows 和 Linux 上是 Ctrl+Shift+F)在所有已加载的文件里搜索。试试 /api、/v1、internal、admin、free、pro、trial、promo,以及任何你还没公布的功能名称。

压缩后的 JavaScript 会暴露什么?

除了局部变量名,什么都会暴露。我这个 demo 压缩后的完整 bundle 只有 995 字节:

(()=>{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

局部变量名没了,其它东西都还在,包括我故意挂到 window 上的名字,比如 grantTrial:

源码里有什么压缩后还能读懂吗?在 bundle 里的位置
局部函数名和变量名不能,已缩短quote() 变成了 i()
优惠码及其折扣能LAUNCH40:.4,PARTNER_FRIENDS:.5
以美分计的单席位价格和 25 席起的批量折扣能e*1200、e>=25&&(r*=.9)
免费版每月三次的导出上限能e==="free"?t<3:!0
全部三个功能开关能newCheckout:!0,aiSummaries:!1,teamSeatsBeta:!0
API 主机和路由,包括一条管理员路由能/v2/billing/invoices、/internal/admin/trials/${e}/extend
挂到 window 上的名字能window.__debug={fetchInvoices:c,grantTrial:m}
原始文件、类型和常量名仅当 source map 公开时sourceMappingURL 那一行

压缩的作用是让文件变小,它从来就不是为了隐藏什么。

生产环境中的 source map 有安全风险吗?

有,只要 map 是公开的。source map 通常内嵌了你的原始文件,所以对外提供它就等于公开你的源码。

看每个 bundle 的最后一行。像 //# sourceMappingURL=app.js.map 这样的注释会告诉浏览器 map 文件在哪。有些配置改用 SourceMap 响应头来传递,所以 Network 标签页也要看一下。

然后自己去请求这个 map,检查响应体,而不是状态码。很多托管平台对任何未知路径都会返回 200 加上你的 index.html。如果下面这条命令输出 3,说明你正在对外提供 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)

source map 通常带有一个 sourcesContent 数组,这是 source map 格式中的一个可选字段:也就是你的原始文件,一字不差。两条 jq 命令就能列出文件并打印其中一个:

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) {

这就是我的 TypeScript,连类型和常量名都在,直接来自我磁盘上的文件。DevTools 也在悄悄做同样的事:map 一加载,Sources 标签页就会在 bundle 旁边显示你原始的目录结构。让 Claude Code 的 npm 包登上新闻的,也正是一份公开的 map,尽管它的 bundle 一直都是可读的。

AI 能读懂压缩或混淆过的 JavaScript 吗?

能。现在的模型用一条提示词就能讲清楚一个压缩后的 bundle,连定价公式和暴露的管理员入口都不放过;AI 智能体同样能读混淆过的代码:在之前的一次测试里,一个智能体分别用 10 分钟和 20 分钟,把两款混淆器官方公开的 demo 还原成了干净的源码。

2026-09-27,我把上面那个压缩后的 demo bundle(不带 map)交给 Claude Code 2.1.283 里的 Claude Sonnet 5,不开任何工具,用的是下面这段提示词,后面跟着放在 js 代码块里的 bundle:

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.

它还原出了完整的定价公式,一直算到 10 席年付套餐用 LAUNCH40 之后的价格:$57.60。它还找到了我埋进去、但没有提示过的那处问题:

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.

(大意:window.__debug.grantTrial / fetchInvoices 把管理员延长试用和拉取发票的函数直接暴露在生产 JS 的全局作用域里。任何人都能打开 devtools,在浏览器控制台里调用 __debug.grantTrial('<任意用户 id>')。)

完整回答,未经编辑
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.

我对照源码核对过:里面每一条事实都是对的。唯一不严谨的表述是 <3 exports/month,它在后文又正确地写成了 3。这只用了一条提示词,任何对你产品好奇的人都能发出同样的一条。

如何扫描网站中暴露的源代码和 source map

把你的生产 URL 粘贴到 AfterPack 免费的安全扫描器里,不需要注册账号。它会在真实浏览器里渲染页面,收集页面加载的每一个脚本,并报告暴露的密钥、公开的 source map,以及每个 bundle 的保护程度。同一个扫描器也可以在终端里运行:

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

审计线上网站列出了它检查的内容和用于 CI 的退出码。

如何保护 JavaScript 源代码

先去掉混淆保护不了的东西,再对其余部分做混淆:公开的 source map 或 bundle 里的密钥,无论 bundle 本身写成什么样,都会把代码交出去。

步骤防的是什么怎么做
1. 不对外提供 source map原始文件、类型和注释被公开使用隐藏式 map 并私下上传;从部署产物中删除 .map 文件
2. 把密钥和权限判定挪到服务端密钥被读取,限额和管理员路由被绕过在服务端校验;客户端的检查只用于 UI
3. 对必须发到浏览器的逻辑做混淆字符串被搜索,逻辑被读懂或被篡改npx afterpack@latest dist/ --preset=hard,或使用框架插件

如何在生产环境中关闭 source map

不要对外提供。如果错误追踪需要它,就生成不带 sourceMappingURL 注释的 map:

打包工具或框架设置结果
esbuild--sourcemap=external生成 .map 文件,bundle 中没有注释
Vitebuild.sourcemap: "hidden"生成 .map 文件,bundle 中没有注释
webpackdevtool: "hidden-source-map"生成 .map 文件,bundle 中没有注释
Rollupoutput.sourcemap: "hidden"生成 .map 文件,bundle 中没有注释
Next.jsproductionBrowserSourceMaps默认关闭;确认没有人把它打开
Create React AppGENERATE_SOURCEMAP=false完全不生成 map(默认是开启的)

把 map 私下上传到你的错误追踪服务,然后从部署目录里删掉 .map 文件。一个「隐藏」的 map 只要还留在服务器上,离暴露就只差一次猜对 URL。不公开 map 也能调试生产环境的调用栈给出了一条边缘规则,专门应对 map 意外漏出去的情况。

把密钥和校验留在服务端

bundle 里的 API 密钥,无论你对文件做什么处理,运行页面的人都能读到。决策逻辑也一样:只在浏览器里检查的免费版限额只是一个建议,优惠码应该放在服务端查询,而我 demo 里那条管理员路由的安全程度,完全取决于服务端自己的校验。客户端的检查留给 UI 用,真正的限制要在服务端执行。

对必须发到浏览器的 JavaScript 做混淆

有些代码只能在浏览器里运行,比如实时价格计算器或者浏览器内的编辑器。对这类代码,在构建产物上运行 AfterPack:

npx afterpack@latest dist/ --preset=hard

不加 --preset 时默认是 light,它会编码字符串字面量。这能挡住对路由和功能开关的文本搜索,但不会让定价计算变得难以理解。对竞争对手想要的代码,比如定价和套餐限制,请用 hard;也可以先把一个函数粘贴到 playground 里,看看每个级别会把它变成什么样。预设页列出了每个级别额外增加了什么。经过 hard 处理后,我的 demo 算出来的仍然是 $57.60(AfterPack 0.2.0,2026-09-29);如果你更希望在常规构建里自动完成,也有框架插件可用。

形态和预设同样重要。一个固定不变的 bundle 就是一个靶子:模型或脚本只需对它做一次逆向工程,结果就能一直用到你的下一个版本。AfterPack 每次构建都会抽取一个新的种子,所以每个版本在结构上都是一个不同的程序,针对上周 bundle 写的反混淆脚本或做好标注的映射,套不到这周的 bundle 上。把 @afterpack/wasm 放进你自己的 Cloudflare Worker,代码甚至可以在每次请求时都换一个形态;Workers 教程有完整的配置,发布公告则展示了同一个函数混淆前后的样子。

先从一次搜索开始:在你的生产站点上打开 DevTools,搜索 internal。搜出来的东西,每个访客早就拿到了。

常见问题

能把 JavaScript 完全对用户隐藏吗?

不能。浏览器必须先下载代码才能运行它,所以任何人都能读到它收到的内容。你能做的是把密钥和权限判定留在服务端,停止公开 source map,并用混淆让发出去的代码难以读懂、代价高昂。

怎样防止别人复制或逆向工程我的 JavaScript?

下载拦不住;但你可以让复制品读起来代价高昂,而且很快过时。用 --preset=hard 做混淆,每次构建(或者在你的 Worker 里每次请求)都发出新的形态,并把权限判定留在服务端,这样复制来的客户端也做不了比原版更多的事。Pro 版的防篡改会让被修改过的副本直接失效,而不是继续运行。

混淆能隐藏 API 密钥吗?

不能。bundle 里的密钥,不管有没有混淆,运行页面的人都能读到。把密钥留在服务端,从服务端去调用;威胁模型说明了混淆能保护什么、不能保护什么。

混淆后的 JavaScript 行为还一样吗?

应该一样;万一做不到,你会在构建时就知道:引擎无法混淆的文件会让构建失败并报出文件名,所以坏掉的 bundle 永远不会悄无声息地发出去。请针对混淆后的构建而不是原始构建运行集成测试:真正发出去的是它。

AfterPack 免费吗?

本地引擎免费,所有预设都能用;CLI 和框架插件都是开源的。Pro 版增加按区域的指令、防篡改,以及按兆字节计费的云端构建;具体数字见套餐。

— Nikita

保持关注

关注 AfterPack,获取发布说明和技术文章。