Skip to main content

密钥不能进浏览器

前端直连大模型 API,是最快跑通 demo 的方式,也是最容易被判定为"没做过生产"的写法。

问题不在于"不安全"这个抽象判断,而在于一件很具体的事:写在前端的密钥,等于发布给了所有人。打包产物里的字符串、网络面板里的请求头、Source Map 里的常量——任何一个都能把它取出来。

浏览器不持有任何密钥自建 BFF / 网关校验会话 · 换发短期凭证模型服务只认服务端签名带会话凭证带 AK/SK 签名边界只有一条:签名这件事只发生在服务端进程里浏览器拿到的是自己的会话凭证,不是模型的访问密钥鉴权、限流、审计都挂在这一层
图:凭证止于服务端,浏览器侧只持有自己的会话

1. 边界只有一条​

签名这件事只发生在服务端进程里。浏览器拿到的是用户自己的会话凭证,不是模型的访问密钥。

流程是三段:

  1. 浏览器带上自己的会话凭证请求自建的 BFF 或网关
  2. 网关校验会话(这是用户身份),按策略决定放行、限流、配额
  3. 网关用自己的 AK/SK 对上游请求签名后转发

这一步转发不只是"藏密钥",它同时是三件事的落点:限流(知道是谁在用)、审计(知道谁调了什么)、配额(能按用户切断)。这三件事在前端直连的架构里一件都做不了。

2. 签名是什么​

AK/SK 这对密钥的用法是:AK 标识"是谁",SK 用于生成签名。请求方用 SK 对请求的若干要素(方法、路径、时间戳、请求体摘要)算出一段 HMAC,随请求一起发出;服务端用同样的 SK 和同样的要素重算一遍,比对一致才放行。

关键性质有三条:

SK 不上网。 传输的只有签名结果,密钥本身永远不出现在报文中。

时间戳是必要的。 签名要素里必须包含时间,服务端只接受一个时间窗内的请求。否则一条被截获的请求可以无限重放。

但时间戳只解决一半。 它把重放压缩到一个窗口内,窗口内的同一条请求仍然可以重复发——重复下单、重复扣费照样成立。所以还要加 nonce:每次请求一个随机数,服务端在窗口内记下用过的 nonce,重复的丢弃。签名里也要带上它,否则攻击者可以换 nonce 重签。

请求体要参与签名。 只对 URL 签名的话,攻击者可以改请求体而不被发现。

同理,查询参数和 host 也要参与。 少了 query,攻击者可以把 ?amount=100 改成 ?amount=1;少了 host,同一条请求可以被重放到另一个环境(比如从测试环境重放到生产)。这也是 AWS SigV4 的 canonical request 里要包含 host、query 和一组指定 header 的原因。

import { createHmac, createHash, timingSafeEqual } from 'node:crypto'

function sign({ method, path, query, host, timestamp, nonce, body, sk, version, keyId }) {
const canonicalQuery = Object.keys(query ?? {})
.sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(query[k])}`)
.join('&')
const payload = [
version, // 算法版本,便于以后升级
keyId, // 用哪把 SK,轮换时才知道该验哪一个
method,
host,
path,
canonicalQuery,
timestamp,
nonce,
createHash('sha256').update(body ?? '').digest('hex'),
].join('\n')
return createHmac('sha256', sk).update(payload).digest('hex')
}

// 比对要用常量时间,别用 ===(逐字符比较会带出计时侧信道)
function verify(a, b) {
const ba = Buffer.from(a, 'hex')
const bb = Buffer.from(b, 'hex')
return ba.length === bb.length && timingSafeEqual(ba, bb)
}

注意 version 和 keyId 两个字段:少了 keyId,服务端不知道该拿哪把 SK 验签,多组密钥并存和轮换就做不出来(第 4 节的轮换要求就悬空)。nonce 的服务端去重可以放在 Redis(SET + TTL 等于时间窗长度)。

3. 浏览器侧的三条纪律​

3.1 任何形式的密钥都不进前端​

包括:以 VITE_/NEXT_PUBLIC_ 之类前缀注入的环境变量(构建时会被替换进产物)、写在配置文件里被打包进去的常量、以及从接口下发给前端的"临时密钥"。

最后一条最容易被忽略:把 SK 当成普通接口返回值发给前端,和写死在代码里没有区别。

3.2 区分"会话凭证"和"访问密钥"​

前端可以持有的是代表当前用户身份的短期凭证(登录后拿到的会话 token)。它可以被用户看到——因为那就是用户自己的。

前端不可以持有的是代表服务端身份的长期密钥。这两者的区别决定了要不要放进前端:问一句"这个东西泄露了,泄露的是用户的权限还是系统的权限"。

3.3 环境变量不等于安全​

把密钥放进 .env 能防止它进 Git,但防不住它进产物。构建工具会把以特定前缀开头的变量内联进前端代码。服务端专用的密钥要用不带这类前缀的名字,并且只在 Node 侧读取。

4. 服务端侧的几条​

密钥从环境或密钥管理服务读取,不进代码、不进镜像层。容器镜像里的密钥会跟着镜像分发到所有能拉到镜像的地方。

最小权限。 一个应用一个 AK,只给它需要的模型和操作权限。共用一个高权限 AK,一旦泄露影响面就是全部。

可轮换。 支持多组密钥并存、平滑切换。不能轮换的密钥,等于泄露之后只能停机。

服务端凭据不落日志。 请求头、签名、上游返回的原始内容都可能被日志系统采集——这一条在《给大模型链路装仪表》里从可观测角度讲过一次:内容采集要显式开启,凭据则应该始终排除。

5. 代理转发的实现要点​

BFF 转发流式接口时,有几个点容易写错:

不要把整段响应读进内存再转发。 流式要边收边转,否则首字节延迟会被拉长到整段生成结束,流式就白做了。

透传背压。 客户端断开时,要同时取消上游请求,否则上游还在生成、token 还在计费。中断链路在《SSE 流式解析与切帧处理》那篇里讲过前端侧的解析,服务端侧对应的是把取消信号一路传上去。

超时要短于客户端。 网关的超时如果比客户端长,会出现"客户端已经放弃、网关还在等"的空转。

app.post('/api/chat', async (req, res) => {
const session = await verifySession(req) // 先认人
if (!session) return res.status(401).end()

if (!(await allow(session.userId, 'chat'))) { // 再限流与配额
return res.status(429).end()
}

// 客户端断连要自己接管:Express 的 req 继承自 http.IncomingMessage,
// 它身上没有标准的 signal(传 undefined 给 fetch 不报错,但取消会静默失效)
const ac = new AbortController()
res.on('close', () => {
// writableFinished 为 true 是正常写完,不是中途断连
if (!res.writableFinished) ac.abort()
})

const upstream = await fetch(UPSTREAM_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 服务端才持有,浏览器永远看不到
Authorization: `Bearer ${await getUpstreamToken()}`,
},
body: JSON.stringify(buildBody(req.body)),
signal: ac.signal, // 客户端断开时一起取消
})

res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('X-Accel-Buffering', 'no')
// fetch 拿到的是 Web ReadableStream,它没有 .pipe(),要先转成 Node 流
Readable.fromWeb(upstream.body).pipe(res) // 边收边转
})

上面用到的 Readable 来自 node:stream。转发这段有四个地方容易写错,逐个说:

  • 取消要靠自己接的信号。signal 传了 undefined 不会报错,只是取消永远不生效——而生成还在继续计费。
  • 响应头三件套。只给 Content-Type 的话,中间的代理可能把流攒起来,客户端看到的是"卡住不动",具体表现见《流式链路上的缓冲点》。
  • .pipe() 不是万能的。fetch 的 response.body 是 Web 流,只有 pipeTo / pipeThrough,直接 .pipe() 会抛 TypeError。
  • 正常结束 ≠ 断连。res.on('close') 在两种情况下都会触发,要用 writableFinished 区分,否则每次正常响应都会误报一次"客户端中断"。

6. 和 MCP、工具调用的关系​

这条边界在 Agent 场景里会再往外扩一层:Agent 调用的工具也需要凭据,而工具的凭据同样不能进模型的上下文。

原则一致——凭据只在执行侧。模型能拿到的是工具的描述和调用结果,永远拿不到凭据本身。这一点在《无状态 MCP Server 要点》里从服务端角度讲过:鉴权是中间件层的事,工具只管业务;句柄只是名字,每次调用都要重新校验权限。

7. 一句话的检查标准​

打开浏览器的网络面板和构建产物,搜一遍:能不能找到任何一个可以用来直接调上游的凭据?

找得到,这条边界就还没建立起来。找不到,才算做完了。