Vite 环境变量前缀与密钥边界:为什么 `VITE_` 不是加密


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 发给浏览器。

两个容易踩的细节:

  1. 构建时 vs 运行时nuxt.config.ts 里的 process.env.X 是构建/启动时求值的。想”改配置不重新构建”,就把值放在服务器环境变量里,靠 runtimeConfig 的自动映射(key apiSecret ↔ 环境变量 NUXT_API_SECRET,规则是 key 转大写下划线、加 NUXT_ 前缀)在运行时注入。
  2. 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 的正确思路是:

  1. 公共配置使用 VITE_ 前缀
  2. 需要保密的内容下沉到后端
  3. 前端只通过接口获取结果,不直接持有 secret

按值类型分流,归宿很清晰:

值的类型 SPA 中的归宿
公开配置(API 地址、站点 URL) VITE_ 前缀,接受进 bundle
公开凭据(地图 Key 等) VITE_ 前缀 + 服务端租户侧限制(白名单/配额)
服务端秘密 不放在前端工程里——放在后端服务自己的环境变量里,前端只携带用户登录后颁发的 token 去调后端接口

一句话总结:process.env 保密方案只在”有自己服务端”的架构里成立——SSR、Nuxt 全栈、Node 后端;SPA 里秘密的唯一归宿是后端。

这也是最稳定、最符合架构分层的做法。


8. 一套可落地的检查清单

在实际项目中,拿到环境变量时可以这样判断:

  1. 是否有 VITE_ 前缀?
  2. 是否会直接在浏览器端被使用?
  3. 是否属于公开配置,还是密钥?
  4. 是否要经过后端验证?
  5. 是否存在被抓包、被查看源码、被提取的风险?

如果答案里出现“密钥”“secret”“token”“password”“private”“key”,并且还带了 VITE_,这一刻就该停下来重新设计。

除了判断,还有几条可以直接执行的动作:

  1. .env 分区VITE_ 段只放公开值和带白名单保护的公开凭据;秘密一律无前缀。
  2. SSR 项目:秘密走 runtimeConfig 非 public 段 + NUXT_* 环境变量运行时注入;部署前搜一遍 bundle,确认秘密没混进去:
grep -r "你的可疑值" .output/public
  1. SPA 项目:默认”前端没有秘密”,秘密全部下沉后端;前端鉴权靠 token,不靠内置凭据。
  2. 公开凭据(地图 Key 等):默认它会被泄露,去服务商控制台配 Referer 白名单、配额和告警——白名单才是真正的防线。
  3. .gitignore 兜底:忽略 .env* 只保留 .env.example;git 历史撤不回,已经提交过的秘密按”已泄露”处理。
  4. 命名审查:变量名同时出现 SECRET/PASSWORD/KEYVITE_ 时,停下来想十秒钟。

9. 三问回顾

把前面的内容收成三个问题,方便对照自查:

  1. 公开值能用 VITE_ 吗? 能,这就是它的用途——前提是你接受它永久暴露给所有访客。
  2. 密码这类用 process.env,打包会生效吗? SSR 项目会:Node 进程在服务器运行时读环境变量,值不进浏览器。SPA 不会:构建后没有服务端,秘密要么不存在,要么必须下沉后端。
  3. 私有仓库能兜底吗? 只能兜”源码泄露”这一条路;值一旦进了 bundle,保护它的只有服务商侧的白名单和配额,仓库权限帮不上忙。

10. 最后一句话总结

VITE_ 不是为了“保密”,而是为了“声明公开”。

真正需要保护的值,必须留在服务端;真正需要给浏览器的值,才应该走 VITE_。这个边界一旦混淆,安全问题就会在编译阶段悄悄出现。

把前端工程当作“无秘密的运行环境”,把密钥放在可信的后端与服务端环境里,这是最稳妥的长期方案。


文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
 上一篇
Electron + Vue 3 + Vite 通用 AI Agent 功能调用架构 Electron + Vue 3 + Vite 通用 AI Agent 功能调用架构
一套与具体业务无关的通用 AI Agent 功能调用架构:在 Electron + Vue 3 + Vite 应用中, 把自然语言指令转成结构化函数调用,通过 IPC 让主进程执行本地能力。不绑定音视频场景, 可无缝对接到任意桌面功能,适合桌面助手、智能客服、自动化工具等。
2026-09-19
下一篇 
Vite 迁移 Sass:从 @import 升级到 @use 的那些坑 Vite 迁移 Sass:从 @import 升级到 @use 的那些坑
记录 Sass 1.80+ 后从 `@import` 迁移到 `@use` 的实战经验:模块化依赖、命名空间、主题设计与全局注入带来的代价。
2026-09-19
  目录