
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。┌────────────┐ 短时令牌(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 消费。// 网关侧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));// 前端侧令牌缓存与并发合并 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。上游错误要区分响应头是否已发送分别处理。这三点踩中任意一个体验都会显著劣化。