Vite 环境变量前缀与密钥边界:为什么 VITE_ 不是加密
起因是一次项目审查:发现腾讯地图 Key 被编译进了客户端 bundle,
VITE_NUXT_API_SECRET这个名字也躺在.env里。这两个问题背后其实是同一件事——很多人(包括曾经的我)把VITE_前缀当成”Vite 环境变量的格式要求”,而没意识到它是一个安全边界声明。这篇文章把这件事讲透。
很多前端工程里,VITE_ 前缀被当成“Vite 约定的一种命名规则”,但它实质上代表的是一条非常明确的安全边界:这个值会被写进浏览器端的代码里,并对任何访问页面的人可见。
这篇文章不讨论技巧,而是把边界讲清楚。先说结论:
VITE_不是“加密前缀”,而是“公开值前缀”。
如果你把真正的秘密放进了 VITE_XXX,它就已经从“服务端配置”变成了“客户端产物的一部分”。
1. 先纠正一个常见误解
在 Vite 中,凡是以 VITE_ 开头的变量,构建时都会被扫描并替换进客户端代码中。具体来说,Vite 会对客户端代码里所有 import.meta.env.VITE_XXX / process.env.VITE_XXX 做静态字符串替换。也就是说:
VITE_API_BASE_URL=https://api.example.com
VITE_MAP_KEY=some-public-key
一旦打包完成,这些值很可能会直接出现在最终的 JS bundle 里,而且是被替换成的字面值本身——不是引用,是值。它不是“运行时读取”,而是“静态写入”。你现在打开自己项目的 bundle 搜一下,一定能搜到。
因此,VITE_ 的语义实际上是:
我接受这个值进入浏览器环境,任何访问页面的人都可以读取。
这和“隐私保护”是相反的方向。它并不是通过前缀隐藏了什么,而是明确告诉构建工具:这个值是公开的。
2. VITE_ 与 process.env 的区别
从运行时角度看,Vite 会区分两类变量:
# 公开值:会进入浏览器产物
VITE_API_BASE_URL=https://api.example.com
# 服务端秘密:不会被内联到浏览器
APP_SECRET=XXX
DATABASE_PASSWORD=XXX
这并不是因为“Vite 不认识某个变量”,而是因为它刻意按命名规则划分边界:
VITE_开头:允许被客户端使用;构建时内嵌- 非
VITE_:默认留在 Node/服务端上下文,不会注入到浏览器
这意味着:
VITE_更适合放 API 地址、站点配置、前端展示开关- 真正的密钥、签名、数据库凭证,应该放在服务端环境变量中,并且不要带
VITE_前缀
3. 为什么“私有仓库”也不能救场
很多人会觉得:
代码在私有仓库里,没什么问题。
这个判断只对“源代码泄露”成立,对“客户端代码泄露”不成立。
因为一旦值进入 bundle,真正暴露对象就变成了:
- 任何访问网站的人
- 任何打开 DevTools 的用户
- 任何拦截到静态资源的人
换句话说,仓库权限能保护的是“代码是否被提交”,但不能保护“浏览器端是否能读取”。
这也是为何把敏感信息放到前端工程中非常危险:
| 配置类型 | 是否能被浏览器读取 | 是否适合放在客户端 |
|---|---|---|
| API 地址 | 是 | 可以 |
| 公开参数 | 是 | 可以 |
| 地图/统计类凭据 | 是 | 需审慎,通常要有域名白名单控制 |
| 服务端密钥 | 否 | 不允许 |
这里要分清两条完全不同的泄露路径,很多人把它们的防线混为一谈:
| 泄露路径 | 谁能拿到 | 私有仓库能否保护 |
|---|---|---|
源码 / .env 入库 |
只有仓库可见的人 | ✅ 能 |
| 编译进客户端 bundle | 所有访客(打开 DevTools 或看网页源码即可提取) | ❌ 完全不能 |
也就是说,仓库设成私有,只能保护”源码是否被提交”,保护不了”值进了 bundle 后谁能读”。一旦值进了 bundle,对手就从”仓库可见者”变成了”整个互联网”。
同时,值该不该隐藏,要按类型分开看,而不是一刀切:
- 真正的公开值(API 地址、站点 URL、版本号):本来设计为公开,进 bundle 天经地义。
- 公开凭据(地图 Key、统计 ID 这类前端必须持有才能工作的凭据):暴露是常态,防线不在隐藏,而在服务端的租户侧限制(见下文白名单 + 配额)。
- 服务端秘密(数据库密码、JWT secret、第三方 API secret):绝不允许进前端工程,它们被偷走没有白名单可兜底。
一个现成的反面教材式命名:VITE_NUXT_API_SECRET。哪怕当前是空值,这个名字也是陷阱——将来谁往里填真值,真值就会随 bundle 发给全世界。秘密变量的名字里出现 VITE_,就应该警铃大作。
4. 真正的安全实践:把密钥留在服务端
如果一个值是以下类型,那么它绝不能出现在前端工程中:
- JWT secret
- OAuth client secret
- 数据库密码
- 第三方 API secret
- 内部签名串
- 访问控制令牌
正确方式是:
# 服务端配置
APP_SECRET=XXX
DATABASE_PASSWORD=XXX
前端只拿到:
- 公开 API 地址
- 可公开的配置项
- 用户级 token(由后端颁发并校验)
这样能保证业务逻辑真正发生在可信环境里,而不是由浏览器直接持有密钥。
5. 地图类”公开凭据”:隐藏不是唯一防线
如果你的”秘密”其实是地图 Key、统计 ID 这类前端必须持有才能工作的公开凭据,那它大概率是躲不掉的——要么进 bundle,要么被请求带出去。对于这类值,真正的防线不在”藏”,而在服务端租户侧限制:
- Referer / 域名白名单:在服务商控制台(如地图平台)把允许授权的域名名单配上,白名单之外的域名携带该 Key 的请求会被拒绝。
- 配额与告警:给 Key 设置额度上限和超限告警,即便被恶意盗刷,也能第一时间发现并止损。
这类凭据通常免费额度有限,最坏情况是被人刷掉配额,而不是数据被拖走。所以对公开凭据的态度是:接受它公开,但用白名单和配额把风险圈起来。
6. SSR 场景下是怎样工作的
在 SSR、Nuxt 或其他 Node 运行时场景中,服务端环境变量仍然是有效的。关键点在于:
- 构建阶段:代码可以读取配置
- 运行阶段:服务端进程才能访问真实的环境变量
- 浏览器:只拿到需要暴露的 public 配置
也就是说,服务端秘密是可以存在于 Node 运行时中的,只是它不能走到客户端产物。
这是一个非常重要的设计原则:
服务端秘密可以存在,但必须保留在可信环境,不允许被前端工程打包。
具体到 Nuxt 项目,完整链路是:VITE_ 前缀的值进 bundle,服务端秘密走 SSR 运行时真实的 process.env——打包后部署的是一台活的 Node 进程,环境变量在服务器运行时确实存在。
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 非 public 段:只存在于服务端上下文,不会进入 payload
apiSecret: "", // 留空,运行时从环境变量自动映射
public: {
apiBase: "/", // public 段会发给浏览器,只放公开值
},
},
});
# 服务器环境变量(或部署平台的环境变量配置)
NUXT_API_SECRET=XXX
// 服务端代码(server/、SSR 阶段)
const config = useRuntimeConfig();
config.apiSecret; // ✅ 服务端能读到
在客户端组件里调用 useRuntimeConfig(),非 public 段的值不存在——Nuxt 只把 public 段序列化进 payload 发给浏览器。
两个容易踩的细节:
- 构建时 vs 运行时:
nuxt.config.ts里的process.env.X是构建/启动时求值的。想”改配置不重新构建”,就把值放在服务器环境变量里,靠runtimeConfig的自动映射(keyapiSecret↔ 环境变量NUXT_API_SECRET,规则是 key 转大写下划线、加NUXT_前缀)在运行时注入。 NUXT_前缀自动映射只对runtimeConfig里已有的 key 生效:runtimeConfig 里没声明的 key,环境变量不会自动出现,需要先在配置里占位。
7. SPA 的现实结论
在纯 SPA 场景中,浏览器端本身没有保密能力。把同样的“process.env 存秘密”思路搬过来试试:
// 纯 SPA(ssr: false / nuxi generate)
const secret = process.env.NUXT_API_SECRET; // ❌ 运行时是 undefined
原因很直接:SPA 打包产物是一堆静态文件(HTML/JS/CSS/资源包)扔到 CDN 或静态服务器上,没有任何 Node 进程跟着浏览器一起运行。process.env 只存在于你执行打包命令的那台构建机上,打包结束它的使命就结束了——不带前缀的变量在浏览器里不是“加密了”,是压根不存在。
这些静态文件都会下载到浏览器里。任何“前端保密”的方案,本质上都只能做到“降低曝光”,而不能做到“真正隐藏”。
所以 SPA 的正确思路是:
- 公共配置使用
VITE_前缀 - 需要保密的内容下沉到后端
- 前端只通过接口获取结果,不直接持有 secret
按值类型分流,归宿很清晰:
| 值的类型 | SPA 中的归宿 |
|---|---|
| 公开配置(API 地址、站点 URL) | VITE_ 前缀,接受进 bundle |
| 公开凭据(地图 Key 等) | VITE_ 前缀 + 服务端租户侧限制(白名单/配额) |
| 服务端秘密 | 不放在前端工程里——放在后端服务自己的环境变量里,前端只携带用户登录后颁发的 token 去调后端接口 |
一句话总结:process.env 保密方案只在”有自己服务端”的架构里成立——SSR、Nuxt 全栈、Node 后端;SPA 里秘密的唯一归宿是后端。
这也是最稳定、最符合架构分层的做法。
8. 一套可落地的检查清单
在实际项目中,拿到环境变量时可以这样判断:
- 是否有
VITE_前缀? - 是否会直接在浏览器端被使用?
- 是否属于公开配置,还是密钥?
- 是否要经过后端验证?
- 是否存在被抓包、被查看源码、被提取的风险?
如果答案里出现“密钥”“secret”“token”“password”“private”“key”,并且还带了 VITE_,这一刻就该停下来重新设计。
除了判断,还有几条可以直接执行的动作:
- 给
.env分区:VITE_段只放公开值和带白名单保护的公开凭据;秘密一律无前缀。 - SSR 项目:秘密走
runtimeConfig非 public 段 +NUXT_*环境变量运行时注入;部署前搜一遍 bundle,确认秘密没混进去:
grep -r "你的可疑值" .output/public
- SPA 项目:默认”前端没有秘密”,秘密全部下沉后端;前端鉴权靠 token,不靠内置凭据。
- 公开凭据(地图 Key 等):默认它会被泄露,去服务商控制台配 Referer 白名单、配额和告警——白名单才是真正的防线。
.gitignore兜底:忽略.env*只保留.env.example;git 历史撤不回,已经提交过的秘密按”已泄露”处理。- 命名审查:变量名同时出现
SECRET/PASSWORD/KEY和VITE_时,停下来想十秒钟。
9. 三问回顾
把前面的内容收成三个问题,方便对照自查:
- 公开值能用
VITE_吗? 能,这就是它的用途——前提是你接受它永久暴露给所有访客。 - 密码这类用
process.env,打包会生效吗? SSR 项目会:Node 进程在服务器运行时读环境变量,值不进浏览器。SPA 不会:构建后没有服务端,秘密要么不存在,要么必须下沉后端。 - 私有仓库能兜底吗? 只能兜”源码泄露”这一条路;值一旦进了 bundle,保护它的只有服务商侧的白名单和配额,仓库权限帮不上忙。
10. 最后一句话总结
VITE_不是为了“保密”,而是为了“声明公开”。
真正需要保护的值,必须留在服务端;真正需要给浏览器的值,才应该走 VITE_。这个边界一旦混淆,安全问题就会在编译阶段悄悄出现。
把前端工程当作“无秘密的运行环境”,把密钥放在可信的后端与服务端环境里,这是最稳妥的长期方案。