不把 API Key 写进前端代码:一个 BFF 层的鉴权实践

很多团队第一次做大模型应用,都会掉进同一个坑:前端直接调 OpenAI/Claude 的 API,Key 就写在 .env 文件里,打包后浏览器 Network 面板一览无余。这个问题没有中间地带——Key 一旦暴露到客户端,就等于公开了你的账户。

解决这个问题的标准做法是 BFF(Backend For Frontend)模式。核心思路一句话:前端不直接调大模型 API,而是调你自己的后端,由后端带上 Key 去请求模型服务。下面用一个从零可跑的实现,把鉴权链路完整走一遍。

架构:请求链路到底怎么走

这条链路里涉及三个角色:浏览器、BFF 层、大模型 API。关键点是 API Key 只存在于 BFF 层,永远不经过浏览器

用户发起一次对话的完整流程:

  1. 浏览器向 BFF 发 POST 请求,带上用户在页面输入的 prompt,以及身份凭证(JWT token 或 Session Cookie)
  2. BFF 先做身份校验——token 不合法直接返回 401,合法才继续
  3. BFF 从服务端环境变量读取 API Key,拼装请求体,转发给 OpenAI/Claude 的接口
  4. 拿到模型响应后,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 或者直接用 fetchReadableStream 来逐块渲染。这块代码是标准的前端流消费逻辑,和 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() 来中断上游请求。