LLM API 前端鉴权设计:密钥托管、代理转发与防泄露的三重防线

LLM API 前端鉴权设计:密钥托管、代理转发与防泄露的三重防线

一、LLM 接入前端的致命盲区:硬编码 API Key 的泄露事故复盘

大模型应用正在前端化。流式对话、组件内嵌、可交互的可视化,都要求把模型调用能力下沉到浏览器。最直接的写法,是把 API Key 放进前端代码,直接 fetch 模型接口。这条路最短,也最危险。

硬编码的 Key 有三条泄露路径。第一,构建产物明文。Webpack、Vite 把环境变量打进 bundle,反混淆即可还原。公开站点用 any bundle analyzer 都能扫到。第二,网络抓包。HTTPS 也挡不住浏览器扩展拦截 fetch。开发者工具的 Network 面板直接可见 Authorization 头。第三,浏览器扩展与供应链。恶意扩展能读取页面内任意变量。

真实事故不止一次。某团队把 Key 写在前端,上线两小时被盗刷数万美元账单。另一团队用 .env.production 配置,误把 Key 暴露在 source map。还有团队的前端被注入脚本,Key 被定时回传。

正确原则只有一条。密钥永不下发到前端。前端拿到的,必须是短时效、可吊销的令牌。真正的 Key 只存在服务端。本文拆解一套生产可用的鉴权链路:代理网关加短时令牌,配合配额与限流。

二、密钥托管链路:从前端到模型网关的信任边界划分

鉴权设计的核心是划分信任边界。前端不可信。它运行在用户设备上,所有代码与变量都可被审视。网关可信。它运行在受控服务器,密钥不外泄。Provider 可信。它是密钥的最终消费者。

基于这个划分,链路分三层。前端持短时令牌,向网关发请求。网关校验令牌、扣配额、限流,再用真 Key 调 Provider。Provider 返回结果,网关透传回前端。前端全程不接触真 Key。

text 复制代码
┌────────────┐    短时令牌(JWT)     ┌──────────────┐   真 Key    ┌───────────┐
│   前端      │ ──────────────────► │  BFF / 网关   │ ──────────► │   LLM     │
│ (不可信)    │                     │  (可信)      │             │  Provider │
│            │ ◄───── SSE 流 ───── │              │ ◄── 流式 ── │           │
└────────────┘                     └──────────────┘             └───────────┘
       ▲                                  │
       │  令牌签发 (/auth/llm-token)        │ 密钥读取
       │                                  ▼
       └──────────  Secret Manager (Vault / AWS SM / 环境变量)

密钥托管位置有三档。最简方案是环境变量。适合小型项目,但密钥随进程存在,需配合受限的部署环境。进阶方案是 Secret Manager,如 HashiCorp Vault、AWS Secrets Manager。密钥集中管理,支持轮换与审计。最高档是 KMS 加密。密钥加密存储,运行时由 KMS 解密,访问需 IAM 授权。

短时令牌是前端的唯一凭证。它通常用 JWT 实现。有效期短,五到十五分钟。携带用户标识与配额快照。网关验签后,据此限流与扣额。令牌泄露的窗口被压到分钟级,且可即时吊销。

鉴权环节 实现方式 防御目标
前端认证 短时 JWT,附用户标识 防止匿名调用,可追溯
网关鉴权 校验签名 + 过期检查 拒绝伪造与过期令牌
配额控制 令牌内嵌配额,网关 Redis 扣减 防止单用户超额滥用
限流 IP + 用户 + 设备指纹三维度 防爬虫、防暴力枚举
密钥隔离 真 Key 仅存网关与 Secret Manager 前端泄露不影响主密钥

流式响应的鉴权有个细节。SSE 连接建立后,前端与网关保持长连接。令牌可能在传输过程中过期。网关应在连接建立时校验,并在长连接期间不重复校验。否则中途断流,用户体验极差。令牌刷新走独立短连接,与流式连接解耦。

三、代理网关与短时令牌:生产级鉴权链路的工程实现

下面给出网关与前端两段实现。网关用 Node.js,覆盖令牌签发、流式转发、超时、重试与配额扣减。前端覆盖令牌缓存、并发合并与 SSE 消费。

javascript 复制代码
// === 网关侧:Node.js 实现 ===
const express = require('express');
const jwt = require('jsonwebtoken');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');

const app = express();

// 密钥从 Secret Manager 读取,绝不硬编码到代码仓库
// 这里用环境变量示意,生产应接入 Vault / AWS SM
const LLM_API_KEY = process.env.LLM_API_KEY;
const JWT_SECRET = process.env.JWT_SECRET;
if (!LLM_API_KEY || !JWT_SECRET) {
  throw new Error('密钥未配置,拒绝启动。请检查 Secret Manager 注入。');
}

// 短时令牌签发端点
// 前端用业务登录态换取,有效期 10 分钟
app.post('/auth/llm-token', async (req, res) => {
  // 实际应校验业务登录态(如 session cookie)
  const userId = req.headers['x-user-id'];
  if (!userId) {
    return res.status(401).json({ error: '未登录' });
  }
  const token = jwt.sign(
    { sub: userId, quota: 1000 }, // quota: 该令牌周期内允许的 token 上限
    JWT_SECRET,
    { expiresIn: '10m' }
  );
  res.json({ token });
});

// 令牌校验中间件
function authMiddleware(req, res, next) {
  const auth = req.headers.authorization || '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null;
  if (!token) return res.status(401).json({ error: '缺少令牌' });
  try {
    req.user = jwt.verify(token, JWT_SECRET);
    next();
  } catch (e) {
    return res.status(401).json({ error: '令牌无效或已过期' });
  }
}

// 限流:按用户 ID 限流,防止单用户高频调用
const userLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 20, // 每分钟每用户 20 次
  keyGenerator: (req) => req.user.sub,
  handler: (req, res) => res.status(429).json({ error: '请求过于频繁' }),
});

// 流式代理:透传 SSE,避免缓冲整个响应导致首字延迟
// 关键:selfHandleResponse 必须为 false,否则流被吞掉
app.post(
  '/llm/chat',
  authMiddleware,
  userLimiter,
  createProxyMiddleware({
    target: 'https://api.llm-provider.com',
    changeOrigin: true,
    selfHandleResponse: false, // 透传,让网关不解析响应体
    onProxyReq: (proxyReq, req) => {
      // 注入真实密钥,前端永远看不到
      proxyReq.setHeader('Authorization', `Bearer ${LLM_API_KEY}`);
      // 透传流式参数
      proxyReq.setHeader('Accept', 'text/event-stream');
    },
    onError: (err, req, res) => {
      // 上游超时或断连,给前端明确错误,而非挂起
      if (!res.headersSent) {
        res.status(502).json({ error: '模型服务暂时不可用' });
      } else {
        // 响应已开始流式输出,只能终止连接
        res.end();
      }
    },
    proxyTimeout: 30000, // 上游 30s 超时
    timeout: 30000,
  })
);

app.listen(3000, () => console.log('网关监听 3000'));
javascript 复制代码
// === 前端侧:令牌缓存与并发合并 ===
class LLMTokenManager {
  constructor() {
    this.token = null;
    this.expiresAt = 0;
    this.refreshPromise = null; // 并发合并:多个请求共用一次刷新
  }

  // 提前 60s 判定过期,避免临界态请求被网关拒绝
  isValid() {
    return this.token && Date.now() < this.expiresAt - 60_000;
  }

  async getToken() {
    if (this.isValid()) return this.token;
    // 并发合并:同一时刻多个请求只触发一次刷新
    if (this.refreshPromise) return this.refreshPromise;
    this.refreshPromise = this._refresh().finally(() => {
      this.refreshPromise = null;
    });
    return this.refreshPromise;
  }

  async _refresh() {
    const resp = await fetch('/auth/llm-token', {
      method: 'POST',
      credentials: 'include', // 携带业务登录态
    });
    if (!resp.ok) {
      throw new Error(`令牌刷新失败: ${resp.status}`);
    }
    const { token } = await resp.json();
    // 解析 exp,缓存到本地
    const payload = JSON.parse(atob(token.split('.')[1]));
    this.token = token;
    this.expiresAt = payload.exp * 1000;
    return token;
  }
}

const tokenManager = new LLMTokenManager();

// 流式对话调用:消费 SSE,逐字渲染
async function chatStream(prompt, onChunk) {
  const token = await tokenManager.getToken();
  const resp = await fetch('/llm/chat', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    }),
  });

  if (!resp.ok) {
    const errText = await resp.text();
    throw new Error(`对话失败: ${resp.status} ${errText}`);
  }

  // 手动解析 SSE,避免依赖第三方库带来的体积
  const reader = resp.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      // SSE 以双换行分隔事件
      const events = buffer.split('\n\n');
      buffer = events.pop(); // 最后一段可能不完整,留到下次
      for (const evt of events) {
        const line = evt.split('\n').find((l) => l.startsWith('data:'));
        if (!line) continue;
        const data = line.slice(5).trim();
        if (data === '[DONE]') return;
        try {
          const json = JSON.parse(data);
          const delta = json.choices?.[0]?.delta?.content;
          if (delta) onChunk(delta);
        } catch {
          // 单帧解析失败不应中断整个流,跳过继续
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

几个细节展开。第一,selfHandleResponse 设为 false,让网关透传流。如果开启,http-proxy-middleware 会缓冲整个响应,流式语义被破坏,首字延迟飙到秒级。第二,令牌刷新并发合并。多个组件同时发起对话,只触发一次刷新请求。refreshPromise 充当单例锁。第三,SSE 解析保留 buffer。流可能从任意字节断开,必须按双换行切分,末尾不完整段留到下次拼接。第四,上游错误区分两种状态。响应头未发送时返回 JSON 错误。已开始流式输出时只能断开连接,否则 HTTP 状态码无法再改。

四、鉴权方案的代价:网关延迟与配额精度的取舍

这套链路并非零成本。它引入了网关一跳、配额管理、令牌刷新三重开销。

第一,网关增加延迟。每个请求多一跳服务器转发。流式响应的首字延迟会上升。实测同区域网关增加 30 到 80 毫秒。跨区域部署时可能到 200 毫秒。对延迟敏感的实时对话场景,需把网关部署在离用户与 Provider 都近的区域。或采用边缘计算节点承载网关逻辑。

第二,流式响应在网关的内存压力。每个 SSE 连接占用一个 socket 与一段缓冲。并发上千时,网关内存吃紧。需要做连接数上限与超时清理。超长生成的对话必须设硬超时,防止连接长期占用。

第三,配额扣减的精度问题。LLM 按 token 计费,但 token 数要等响应结束才知道。流式过程中无法精确扣额。两种处理思路。一是预扣额度。请求前按 prompt 长度估一个上限,预扣。响应结束后按实际用量结算差额。二是事后扣减。请求时不扣,响应结束后扣。但这样无法阻止超额用户继续发请求。生产实践通常用预扣加事后修正。

第四,令牌刷新的额外请求。短时令牌每十分钟刷新一次。低频用户也会触发。这增加了网关的鉴权负载。权衡是短有效期换来的安全收益。若用户活跃度高,刷新开销被摊薄。若用户极低频,可考虑延长有效期至一小时,配合刷新令牌机制。

禁用场景如下。纯客户端应用、无任何后端的场景,无法用网关。此时只能用 Provider 提供的客户端限流方案,如临时 Key、域名白名单。但这是 Provider 信任前端的妥协,安全性弱。对延迟要求极低、可接受风险的内部工具,可直接用 Provider 的客户端 SDK。但仍应限制 Key 的额度与权限,缩小泄露影响面。

维度 网关代理方案 直连 Provider
密钥安全 高,Key 不出服务端 低,Key 进前端
首字延迟 增 30 到 200ms 最低
配额控制 精细,用户级 粗,仅 Key 级
审计能力 强,全链路日志 弱,仅 Provider 侧
适用场景 对外产品 内部受信工具

五、总结

LLM 前端鉴权的核心原则是密钥不下发。前端持短时令牌,网关持真 Key。三层信任边界把泄露面压缩到分钟级。

落地分四步。第一,密钥托管到 Secret Manager 或受限环境变量,代码仓库不存 Key。第二,网关实现令牌签发与流式代理端点。关键点是把 selfHandleResponse 关掉,透传 SSE。第三,前端实现令牌缓存与并发合并刷新。提前 60 秒判定过期,避免临界态失败。第四,加配额预扣与限流。按用户限流,按 token 预扣额度。

性能与安全的权衡要前置评估。网关增加 30 到 200 毫秒延迟,换取密钥安全与精细配额。延迟敏感场景考虑边缘部署。纯客户端无后端的场景无法用网关,只能依赖 Provider 的客户端限流,并严格限制 Key 额度。

工程上把握三个易错点。流式代理不要缓冲响应体。SSE 解析要保留跨帧 buffer。上游错误要区分响应头是否已发送,分别处理。这三点踩中任意一个,体验都会显著劣化。

相关推荐
新芒1 小时前
【无标题】
大数据·人工智能
HackTwoHub1 小时前
解锁 AI 红队全新玩法!Claude-Red 攻防 Skill 库,内置 SQLi、XXE、文件上传等 Web 专项 Skill,一键导入快速落地渗透实战
前端·人工智能·web安全·网络安全·自动化·系统安全
石小石Orz1 小时前
如何设计一个优秀的 Skills
前端·人工智能
BOTTLE_平1 小时前
工具篇:用什么AI与选型避坑指南
大数据·人工智能
xd1855785551 小时前
落枕缓解指南 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙
天真小巫1 小时前
2026.7.21总结
人工智能
齐齐大魔王2 小时前
机器学习(三)
人工智能·机器学习
To_OC9 小时前
大模型蒸馏是啥?说白了就是大厨带徒弟的学问
人工智能·llm·agent
新手来了@click9 小时前
JAVA+AI 简化开发操作|文章被 AI Agent 技术社区收录分享
人工智能