Node.js 调用 API:为什么秘密型 Key 不能放前端,以及服务端代理怎么写
先说结论:如果一个值必须交给浏览器 JavaScript 才能完成请求,它就不能再被当作秘密。把秘密型 API Key 写进前端
.env、改变量名、压缩或混淆代码,都没有改变这条边界。
本文讨论的是厂商标注为 secret、具备账户调用权限或可能产生费用的秘密型 Key。确实也有厂商专门设计给浏览器使用的 publishable key;是否可公开,应以发行方文档和权限范围为准,不能只看"API Key"这个名字。
本文由 AI 辅助整理,技术结论、代码和边界由作者复核。示例于 2026-08-09 在 Node.js 24.14.0、127.0.0.1 本地 Mock 环境完成 13 项测试,不连接任何生产或第三方接口。
一、为什么"我已经放进 .env"仍可能泄露
.env 只是配置文件形式。关键不是文件名,而是变量最终在哪一侧运行。
在 Node.js 服务端里:
js
const apiKey = process.env.AI_API_KEY;
这段代码由服务端进程执行。只要后续没有把 apiKey 塞进 HTML、JSON 响应或日志,浏览器不需要获得它。
但在前端构建工具里,某些前缀的目的就是把值公开给客户端。例如 Vite 官方文档明确说明,VITE_* 会进入客户端源码;Next.js 官方文档说明,NEXT_PUBLIC_* 会在构建时内联到发送给浏览器的 JavaScript。
下面这种写法即使来源是 .env,也仍然处于错误边界:
js
// 浏览器端错误示意:不要放真实 Key
const key = import.meta.env.VITE_UPSTREAM_KEY;
await fetch("https://api.example.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({ model: "MODEL_ID", messages: [] }),
});
代码可以被压缩,变量可以被改名,但浏览器最终仍要拿到一个可用值,再把它放进请求。开发者工具的 Sources、Network,请求拦截器或运行时内存,都可能让这个值可观察。
更实用的判断方法不是问"它是不是放在 .env",而是问:
- 这个值有没有进入发送给浏览器的 HTML 或 JavaScript?
- 浏览器发出的请求头、URL 或请求体里能不能看到它?
- 它有没有被写进 localStorage、IndexedDB 或前端日志?
- 用户是否可以在不经过你的服务端授权规则时直接复用它?
只要前两项有一项为"是",它就不再是服务端秘密。
二、不是所有叫 Key 的值都属于同一类
这里需要避免一个过度概括:不是所有 API Key 都绝对禁止出现在前端。
例如 Stripe 官方文档区分 secret key 和 publishable key:前者只应在服务端使用,后者就是为浏览器或移动端场景设计的。很多地图、分析或支付组件也可能提供受域名、权限或用途限制的公开标识。
所以正确问题是:
这个凭证的发行方是否明确把它定义为可公开、可用于客户端,并且它的权限和滥用成本是否与公开场景匹配?
如果没有明确依据,尤其当它能以你的账户身份调用模型、消耗额度或产生费用时,应按秘密型 Key 处理。
三、为什么 CORS 解决不了 Key 保密
CORS 经常被误解成"只允许我的网页调用,所以 Key 放前端也没事"。
MDN 对 CORS 的定义是:服务器通过 HTTP 响应头告诉浏览器,哪些来源的页面可以读取跨源响应。它主要约束遵守浏览器安全模型的页面脚本。
它不是秘密存储,也不是用户身份认证。
- 如果 Key 已经进入浏览器,请求能否跨域不改变用户可以观察该 Key 的事实;
- 浏览器外的脚本、命令行或服务端程序不必按网页的同源读取方式工作;
Origin头本身不能代替应用层鉴权。OWASP 也明确提醒,不应只依赖 Origin 做访问控制。
CORS 仍然有用:如果业务确实需要跨源页面调用,可以只允许明确的可信来源。但它属于额外的浏览器访问控制层,不是把秘密放进前端的理由。
四、更合理的结构:浏览器只调用自己的后端
一个更清楚的信任边界是:
text
浏览器
│ POST /api/chat
│ 只发送业务输入与本站会话
▼
自己的 Node.js 服务端
│ 用户鉴权 / 参数校验 / 白名单 / 限流 / 超时
│ 从 process.env 读取秘密型 Key
▼
固定的上游 API 端点
这套结构的目标不是"隐藏一个字符串"这么简单,而是把几个控制点留在可信的服务端:
- 上游地址和路径由服务端固定,不接受浏览器传任意 URL;
- 上游 Key 由服务端添加,不接受浏览器传 Authorization;
- 模型、操作和参数使用固定值或白名单;
- 服务端先确认"谁在调用、能做什么、还能花多少";
- 上游错误先在服务端收敛,不把原始响应头、HTML 错误页或堆栈直接回给浏览器。
注意:服务端代理本身也是公开 HTTP 端点。没有鉴权和用量限制,它仍可能成为任何人都能调用的转发器。
五、浏览器端应该长什么样
浏览器只请求同源的 /api/chat,不接触上游地址、模型和 Key:
js
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: "请返回一句测试文本" }),
});
const data = await response.json();
console.log(response.ok ? data.content : data.error);
如果应用有登录系统,浏览器还会携带本站会话。那是"用户向你的应用证明身份"的凭据,不应与"你的服务端向上游证明身份"的秘密型 Key 混在一起。
六、Node.js 服务端的四个关键边界
下面不是"复制即可上线"的完整生产方案,而是本文本地示例中最关键的四块逻辑。
1. 启动前检查固定配置
js
const upstreamUrl = new URL(process.env.AI_UPSTREAM_URL ?? "");
const apiKey = String(process.env.AI_API_KEY ?? "").trim();
const model = String(process.env.AI_MODEL ?? "").trim();
if (!apiKey || !model) {
throw new Error("Missing required server-side configuration");
}
if (upstreamUrl.protocol !== "https:") {
throw new Error("Production upstream must use HTTPS");
}
示例把上游完整端点和模型设为服务端配置。浏览器不能在请求体里决定"转发到哪里"。
2. 只接收需要的字段
js
function validateBody(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("json_object_required");
}
const unexpected = Object.keys(body).filter((key) => key !== "prompt");
if (unexpected.length > 0) {
throw new Error("unexpected_field");
}
if (typeof body.prompt !== "string") {
throw new Error("prompt_must_be_string");
}
const prompt = body.prompt.trim();
if (!prompt || prompt.length > 2000) {
throw new Error("prompt_length_out_of_range");
}
return prompt;
}
如果产品确实允许用户选择模型,也应由服务端把用户输入映射到明确白名单,而不是把任意字符串原样交给上游。
3. 只有服务端添加 Authorization
js
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const upstream = await fetch(process.env.AI_UPSTREAM_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: process.env.AI_MODEL,
messages: [{ role: "user", content: prompt }],
}),
redirect: "error",
signal: controller.signal,
});
// 先检查状态,再按明确结构解析;不要把原始错误正文直接回传。
} finally {
clearTimeout(timer);
}
redirect: "error" 是为了避免教程里的固定端点在收到重定向后自动把认证请求带到另一个目标。真实系统还应结合上游文档、DNS、代理层和出站网络策略做更完整的 SSRF 与重定向防护。
4. 返回必要结果,不回显原始错误
js
if (!upstream.ok) {
return sendJson(502, {
error: "upstream_request_failed",
upstream_status: upstream.status,
});
}
return sendJson(200, { content });
排错日志也应遵守同样原则:可以记录方法、固定路径、代理自己的状态码和耗时,但不要默认记录 Authorization、完整请求头、提示词、模型输出或上游原始错误页。
七、为什么"代理一切"仍然危险
下面这种接口看似灵活,实际容易把自己的服务变成开放代理:
js
// 反例:不要接受用户传来的任意目标和认证头
const { url, headers, body } = requestBody;
return fetch(url, { method: "POST", headers, body });
风险不只是 Key:
- 用户可能让服务端请求内网地址或管理端点;
- 任意请求头可能覆盖你设计的身份边界;
- 没有模型和操作白名单时,成本难以约束;
- 没有大小、频率、并发和超时时间时,代理自身也可能被耗尽;
- 把上游响应完整返回,可能泄露内部字段、错误页和调试信息。
因此"服务端代理"至少要回答六个问题:
- 谁能调用?
- 他能调用哪些固定操作?
- 每次能提交多大、多少字段?
- 单位时间、并发和金额上限是多少?
- 上游多久不响应就终止?
- 哪些字段可以记录和返回?
八、一个不连接生产的本地验证方法
为了验证前文边界,作者在本地准备了一套只使用 Node.js 内置模块的测试项目,不安装第三方运行依赖。下面记录目录、运行方式和测试结果;本文没有提供完整项目下载链接,读者不能仅凭本文复现全部 13 项测试。目录结构:
text
examples/
├─ server.mjs
├─ public/index.html
├─ .env.example
├─ .gitignore
└─ package.json
作者的本地运行方式如下,仅用于说明验证环境,不代表可直接复制的完整项目:
powershell
Copy-Item .env.example .env
# 只在本机编辑 .env;不要把真实值写进文章、截图或仓库
npm start
如果你的 Node.js 版本不支持 --env-file,应按运行环境的正式方式注入变量,不要为了省事把 Key 改回源码。本文命令只在 Node.js 24.14.0 实测。
测试命令:
powershell
npm test
本地测试用两个 127.0.0.1 随机端口分别启动代理和 Mock 上游,假 Key 为 TEST_ONLY_M11_SECRET。最终结果为 13/13 passed,覆盖:
- 缺 Key 提前失败;
- 静态 HTML 不含 Key 与 Authorization;
- 方法、Content-Type、JSON、字段和大小校验;
- 客户端不能覆盖目标、模型或认证;
- 只有本地 Mock 收到服务端添加的 Authorization;
- 401 正文、假 Key 和提示词不进入浏览器响应或结构化日志;
- 超时映射为代理自己的 504;
- 重定向不自动跟随;
- 响应包含
no-store与nosniff。
这 13 项只证明 2026-08-09 的本地示例行为,不证明任何线上服务安全、稳定或兼容。
九、打开 Network 面板时应该看到什么
启动本地示例后,在浏览器开发者工具的 Network 中检查 /api/chat:
可以出现
- 本站路径
/api/chat; Content-Type: application/json;- 本次业务输入;
- 代理返回的必要结果或短错误码;
- 如果已经实现登录,本站自己的会话信息。
不应该出现
- 上游秘密型 Key;
- 上游 Authorization;
- 可由浏览器任意指定的完整上游 URL;
- 服务端
.env内容; - 上游完整请求头、原始 HTML 错误页或内部堆栈。
注意,"Network 里看不到上游 Key"只是必要检查之一。它不证明后端鉴权、日志、额度和密钥管理已经完善。
十、这个示例为什么不能直接当生产方案
为了把边界讲清楚,本地示例故意保持小而可测。它仍缺少:
- 真实用户登录、会话、权限和按用户额度;
- 多实例一致的限流、并发控制和消费预算;
- 内容安全、隐私政策、数据处理和审计规则;
- 持久化监控、告警、封禁和异常消费处置;
- SSE 流式响应、文件、图片和多模态处理;
- 秘密管理系统、最小权限、定期轮换和应急流程;
- 正式 HTTPS、反向代理、部署加固与威胁建模。
示例中的内存限流只能说明"接口需要限制",不能用于多实例或无状态函数。生产阈值也不能抄一个固定数字,应结合上游限制、单次成本、用户身份和业务峰值测量。
十一、常见问题
1. 前端 .env.local 没提交 Git,为什么仍然不安全?
"没提交 Git"只解决仓库传播。如果构建器把变量注入客户端,值仍会进入浏览器产物。需要同时检查变量前缀、运行位置和最终构建结果。
2. 把 Key 拆成几段、Base64 或混淆可以吗?
不能改变秘密边界。浏览器要完成请求,就必须在某个时刻组合出可用值。混淆可以增加阅读成本,但不是秘密管理。
3. 只允许自己的域名请求是否足够?
不够。来源限制可以作为一层控制,但不能替代用户身份、授权、额度、速率和异常检测,也不能让已经下发的 Key 恢复秘密性。
4. Next.js 的服务端组件可以读环境变量吗?
可以,但必须保持在服务端边界。不要把秘密作为 props、序列化数据或公开响应传给客户端组件;Route Handler 和 Server Action 仍应按公开端点做鉴权与输入校验。
5. Serverless 或 Edge Function 也算服务端吗?
只要秘密由受控运行时读取、不进入浏览器,并且端点实现了相应访问控制,信任边界可以相同。但不同平台的日志、冷启动、超时、出站网络和秘密存储能力不同,应按平台文档配置。
6. 发现真实 Key 已进过前端产物怎么办?
先按对应服务方流程撤销或轮换,不要只删源码里的字符串。完整的泄露处置、用量核查和历史产物清理应单独执行,不在本文展开。
十二、发布前检查表
- 浏览器 HTML、JavaScript、Source Map 和 Network 中没有秘密型 Key;
VITE_*、NEXT_PUBLIC_*等客户端公开变量只放可公开值;- 上游地址、路径、模型和 Authorization 不由浏览器任意指定;
- 自有后端已做用户鉴权、授权、字段白名单、大小/速率/并发/消费限制和超时;
- 响应与日志不回显 Key、完整请求头、用户正文或上游原始错误;
- 生产秘密使用最小权限、可撤销、可轮换的管理方式。
最后可以做一个直接检查:打开浏览器 Network,确认请求只发往自己的 /api/...,并逐项检查 Headers、Payload、Response 中是否出现上游秘密型 Key。
参考资料
- Vite:Env Variables and Modes
- Next.js:Environment Variables
- Next.js:Data Security
- Next.js:Backend for Frontend
- Node.js:Environment Variables
- OpenAI:Best Practices for API Key Safety
- MDN:Cross-Origin Resource Sharing
- OWASP:HTML5 Security Cheat Sheet
- OWASP:REST Security Cheat Sheet
- OWASP API4:2023 Unrestricted Resource Consumption
- OWASP:Secrets Management Cheat Sheet
- OWASP:Logging Cheat Sheet
- Stripe:API keys
以上资料统一于 2026-08-09 复核。框架行为和安全建议可能更新,实际项目应以使用版本与当日官方文档为准。