不把 API Key 写进前端代码:一个 BFF 层的鉴权实践
很多团队第一次做大模型应用,都会掉进同一个坑:前端直接调 OpenAI/Claude 的 API,Key 就写在 .env 文件里,打包后浏览器 Network 面板一览无余。这个问题没有中间地带——Key 一旦暴露到客户端,就等于公开了你的账户。
解决这个问题的标准做法是 BFF(Backend For Frontend)模式。核心思路一句话:前端不直接调大模型 API,而是调你自己的后端,由后端带上 Key 去请求模型服务。下面用一个从零可跑的实现,把鉴权链路完整走一遍。
架构:请求链路到底怎么走
这条链路里涉及三个角色:浏览器、BFF 层、大模型 API。关键点是 API Key 只存在于 BFF 层,永远不经过浏览器。
用户发起一次对话的完整流程:
- 浏览器向 BFF 发 POST 请求,带上用户在页面输入的
prompt,以及身份凭证(JWT token 或 Session Cookie) - BFF 先做身份校验——token 不合法直接返回 401,合法才继续
- BFF 从服务端环境变量读取 API Key,拼装请求体,转发给 OpenAI/Claude 的接口
- 拿到模型响应后,BFF 把结果返回给浏览器
浏览器从头到尾接触不到 API Key,甚至不知道 BFF 后面调的是哪个模型厂商。你哪天从 OpenAI 切到 Claude,前端一行代码都不用改。
BFF 层实现:一个能跑的最小后端
以 Node.js + Express 为例,给出完整的 BFF 代码。这里用 OpenAI 的 chat completions 接口做目标服务,换成其他厂商逻辑一样。
// bff-server.js
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const app = express();
app.use(cors({ origin: 'https://你的前端域名', credentials: true }));
app.use(express.json());
// 从环境变量读取,绝不硬编码
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const JWT_SECRET = process.env.JWT_SECRET;
// 身份校验中间件
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid token' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // 下游可用用户信息
next();
} catch {
return res.status(401).json({ error: 'Token expired or invalid' });
}
}
// 核心接口:转发聊天请求
app.post('/api/chat', authMiddleware, async (req, res) => {
const { prompt } = req.body;
if (!prompt || typeof prompt !== 'string') {
return res.status(400).json({ error: 'prompt is required' });
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
max_tokens: 1000,
}),
});
if (!response.ok) {
const err = await response.json();
return res.status(response.status).json({ error: err });
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error('OpenAI request failed:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(3001, () => console.log('BFF running on 3001'));
环境变量通过 .env 文件注入,这个文件加到 .gitignore 里,绝不进版本库。生产环境用 Vault、K8s Secrets 或云平台的密钥管理服务注入。
前端调用:只传 prompt,不传 Key
前端代码极其简单,和调你自己的业务接口完全一样:
// 假设已经通过登录拿到了 jwtToken
const jwtToken = localStorage.getItem('jwt_token');
async function sendMessage(prompt) {
const res = await fetch('https://你的BFF域名/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwtToken}`,
},
body: JSON.stringify({ prompt }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error?.message || 'Request failed');
}
const data = await res.json();
// data.choices[0].message.content 就是模型回复
return data.choices[0].message.content;
}
Network 面板里只会看到对 /api/chat 的请求,请求体是 { prompt: "..." },响应体是模型返回的 JSON。API Key 不出现,连请求头里都没有。
流式输出怎么处理
大模型应用几乎都要做流式输出,不然等几十秒用户早关了。BFF 对流式的处理稍微复杂一点,核心是把 OpenAI 的 SSE(Server-Sent Events)流转发给前端,而不是等完整响应再返回。
app.post('/api/chat/stream', authMiddleware, async (req, res) => {
const { prompt } = req.body;
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true, // 关键参数
}),
});
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 直接把上游的 SSE chunk 写回客户端
res.write(chunk);
}
res.end();
} catch (error) {
console.error('Stream error:', error);
res.write(`data: ${JSON.stringify({ error: 'Stream failed' })}\n\n`);
res.end();
}
});
前端用 EventSource 或者直接用 fetch 读 ReadableStream 来逐块渲染。这块代码是标准的前端流消费逻辑,和 BFF 的鉴权设计无关,就不展开了。
为什么不能只靠前端做反向代理
有人会想:我用 Next.js 的 API Routes 或者 Nuxt 的 server routes 把 Key 放服务端环境变量,前端调这些 route 不就行了?这确实是 BFF 的一种实现,完全可行。但要注意两点:
第一,这些 serverless routes 的鉴权你得自己做。 Next.js 的 API route 默认不校验调用方身份,任何人只要能访问这个 endpoint 就能用你的 Key 调模型。你必须在这个 route 里加上身份校验逻辑,否则和把 Key 放前端没有本质区别——只是换了一个暴露面。
第二,注意用量控制。 没有鉴权的模型代理接口,被人扫到之后可能被疯狂调用耗尽你的额度。即使加了鉴权,也建议在 BFF 层做用量限制:每个用户每天最多调多少次、每次最多多少 token。这个限制放在 BFF 上比放在前端可靠得多,因为前端的一切校验都是可以被绕过的。
多层安全加固
BFF 解决了 Key 暴露问题,但安全不是单一措施能搞定的。围绕这个架构,还有几道防线:
- IP 白名单:OpenAI 和 Claude 都支持 API Key 绑定 IP 范围。把你的 BFF 服务器的出口 IP 加到白名单里,即使 Key 意外泄露,从其他 IP 也无法调用。
- 请求日志与告警:BFF 层记录每一次模型调用的用户 ID、时间戳、token 消耗量。异常流量(比如某个用户突然 1 分钟内调了 200 次)触发告警。
- Key 轮换:API Key 应该定期轮换。用环境变量注入的好处是改配置重启即可,不需要改代码、不需要重新部署。频率建议每季度一次,高敏感项目每月一次。
- 最小权限:OpenAI 的 API Key 可以按项目创建,每个 Key 只给必要模型的权限。不要用一个能调所有模型、能看账单的全局 Key 跑 BFF。
这套架构跑通之后,大模型 API 的鉴权和你业务接口的鉴权是同一套体系。前端开发者不再需要关心模型 Key 的存在,只管调 /api/chat 就行。运维侧也安全得多——Key 的存储、轮换、监控都在服务端闭环。
常见问题
为什么不直接用 OAuth 代理方案,比如用 Auth0 接 OpenAI?
OpenAI 目前不提供标准的 OAuth 代理能力,它的 API Key 就是一段静态字符串,没有委托授权的机制。你想让用户用自己的 OpenAI 账号来调,那走的是 OAuth 获取用户的 token,而不是用你自己的 Key。这两个场景不同:前者是用户花自己的额度,BFF 只是转发;后者是你花自己的额度,BFF 要保护好 Key。本文讨论的是后一种。
BFF 层的身份校验用 JWT 还是 Session?
没有绝对答案,看你的整体架构。如果已经是 JWT 体系的微服务,BFF 继续用 JWT 保持一致性;如果是传统的服务端渲染应用,Session + Cookie 也没问题。关键是 BFF 必须校验身份,而不只是做请求转发。
流式输出时连接断了怎么办?
BFF 层的流式转发一旦上游或下游断开,reader.read() 会抛出错误,需要在 catch 里处理。另外注意设置合理的超时时间——OpenAI 的流式接口默认不会主动断开,如果用户关了浏览器,BFF 到 OpenAI 的连接应该及时终止,避免浪费 token。可以在 req.on('close') 事件里调用 reader.cancel() 来中断上游请求。