做AI开放接口服务快一年,要说哪些坑排在最前面,API安全肯定算一个。我们把自己训练和微调过的大模型封装成HTTP接口对外开放后,日志里开始出现各种看不懂的调用:高频请求、深夜突发、同一个密钥在多个IP之间来回切换。一开始我天真地以为只要密钥够长、够随机就行,直到某次我手动改了一个请求里的model字段再发出去,校验居然直接通过了。那一刻我意识到,大模型API的安全需求和传统Web API很不一样,光有密钥远远不够。
这篇文章想分享的并不是什么高深理论,而是我们在实际业务中落地的一套基于签名验证的AI大模型API安全校验机制。从需求分析、方案选型、核心实现到线上排坑,全部来自真实场景,适合正在搭建模型开放平台、API网关或对外提供模型能力的开发者,也适合那些还在纠结“为什么不能用Token,非要做签名”的同行。读完你至少能知道:签名串到底该怎么拼、防重放怎么设计才不漏、密钥怎么管才不会哪天被人拿去刷爆账单。
1. 项目背景与核心需求解析
1.1 开放大模型接口后遇到的三类典型风险
把大模型能力封装成API对外开放后,最先盯上你的往往不是高深攻击者,而是那些拿着脚本到处扫的“羊毛党”和内部误操作。我们总结了最头疼的三类问题:
第一类是身份冒用与配额盗刷。大模型API的计费按Token走,和普通图片接口、文本接口完全不同,一次对话可能消耗几千甚至上万Token,盗刷的成本放大效应非常明显。只要密钥泄露,别人可以在几分钟内把你的月度预算刷穿。
第二类是请求内容篡改。这才是大模型API最特殊的地方。普通API防篡改主要关心订单金额、用户ID这类业务字段,而大模型API需要防篡改的是model、system_prompt、temperature、max_tokens这类影响模型行为和计费档位的参数。攻击者如果把model从便宜档换到贵档,或者在system_prompt里塞入恶意指令绕过内容策略,损失的不只是钱,还有平台安全底线。
第三类是重放攻击。攻击者不需要破解任何东西,只要把一次合法请求原样重发几十遍,就能批量获取模型生成结果或刷爆配额。对大模型API来说,一次请求的结果本身就有价值,重放攻击的低成本特性会让它成为最常见的刷量手段。
这三类风险叠加在一起,得出的结论是:我们需要同时解决身份认证、完整性校验、时效性校验三件事,缺一不可。
1.2 为什么简单Token方案解决不了问题
可能有人会问,为什么不能用最简单的Token?也就是每个调用方分配一个固定字符串,请求时带在Header里,服务端比对一下是否一致。
Token方案在不少内部系统里够用,但它有几个天然短板。第一,Token是静态的,一旦泄露就能被无限期使用,除非手动吊销;第二,Token只证明“你知道这个暗号”,完全无法证明请求内容有没有被改过;第三,Token没有时效概念,截获一次请求就可以长期复用,防重放能力为零。
JWT看起来好一些,自带过期时间,还可以做签名防篡改,但在我们的场景里也有问题。JWT适合短时授权、一次签发多次携带的场景,比如登录态保持;而大模型API的调用方是长期接入的业务系统,他们希望每次请求都能被独立校验,出了问题能快速定位到具体某一个请求。JWT一旦签发,在有效期内无法灵活撤销,也很难做到按请求粒度绑定时间戳和随机数。
所以最终我们选了HMAC签名验证这条路线:密钥对由KeyID和Secret组成,每次请求独立计算签名,服务端用同一个Secret验签,签名内容覆盖请求方法、路径、参数、Body摘要、时间戳和Nonce。这套机制天然能对抗内容篡改,配合Nonce和时间戳又能防重放。
1.3 这套方案的创新点拆解
我们这个方案不敢说算法上有什么石破天惊的创新,但在工程落地上做了几个关键取舍,恰好解决了大模型API场景下的特殊问题。
第一个创新点是分级摘要设计。大模型API的Body往往很大,一次Prompt可能几KB甚至几十KB。如果按照传统做法把整个Body完整拼进签名串再计算HMAC,内存和耗时都很难看。我们的做法是先把Body做SHA-256摘要,得到固定长度的摘要值,再让摘要参与签名串拼接,既覆盖了Body完整性,又避免了拼接大字符串的性能开销。
第二个创新点是双窗口防重放机制。单纯给Nonce设置一个过期TTL会有边界漏洞,在一个时间窗口末尾被使用的Nonce,如果TTL太短,下一窗口初期就可能被重新接受。我们用“窗口划分+Nonce去重”替代简单的TTL方案,服务端同时校验当前窗口和上一窗口,窗口边界不会误杀也不会漏判。
第三个创新点是把签名机制和权限Scope绑定。密钥不是一把钥匙开所有锁,每个KeyID都有独立的模型白名单、Token配额、来源IP限制和生效期限。签名通过只是第一步,权限Scope检查紧随其后,这样即使密钥泄露,攻击者也无法越权调用未授权的模型能力。
2. 整体设计与方案选型
2.1 技术方案选型对比:Token、JWT与HMAC签名
在确定HMAC签名之前,我们团队内部做过一轮完整的方案对比,这里把当时的评估结论整理出来。
| 对比维度 | 静态Token | JWT | HMAC签名 |
|---|---|---|---|
| 身份认证 | 有,但不区分请求内容 | 有,适合短时授权 | 有,每次请求独立认证 |
| 内容防篡改 | 无 | 有,但需要额外设计 | 天然支持,覆盖参数和Body |
| 防重放 | 无 | 弱,依赖过期时间 | 无,配合Nonce+时间戳可防 |
| 服务端成本 | 极低 | 较低,验签有开销 | 较低,无状态验签 |
| 密钥撤销 | 手动吊销即可 | 难以灵活撤销 | 按KeyID吊销,支持轮换 |
| 细粒度权限 | 难做 | 难做 | 可绑定Scope,灵活 |
结论很明确:静态Token适合低安全要求场景,JWT适合身份认证令牌,而我们需要的是一套“每请求独立验签、支持细粒度权限、可灵活吊销”的机制,HMAC签名是最匹配的选项。
2.2 设计原则:安全与性能的平衡
整个设计方案围绕着几个原则展开,这些原则不是拍脑袋定的,而是在实际事故里一点点逼出来的。
第一,签名必须在客户端SDK内自动完成,业务代码零侵入。调用方不需要关心签名细节,拿到的SDK只要配置好密钥,请求发出时自动带上签名Header,这样才能保证接入率。如果签名流程需要业务开发手动拼装,一定会有团队图省事绕过。
第二,校验必须无状态。网关或鉴权服务不保存会话状态,每个请求独立完成验签,这样才能水平扩展。验签如果依赖共享会话,高并发下很容易成为瓶颈。
第三,签名必须覆盖核心业务参数,尤其是model和prompt。大模型API里这两个字段直接决定计费和内容安全,如果签名不覆盖它们,等于给攻击者留了后门。我们踩过这个坑:第一阶段只签了Query参数,结果Body里的model字段被改了也能通过校验,后来才补上Body摘要。
第四,失败请求要快速返回。签名校验不通过,直接返回统一的401或403错误码,不要进入后续业务逻辑,避免把错误信息泄露给攻击者。
2.3 系统整体数据流设计
整个校验流程从客户端发起请求到服务端返回响应,可以拆成六个环节:
- 客户端SDK从配置中心加载KeyID和Secret,构造请求时计算Body摘要。
- SDK生成时间戳和Nonce,按规则拼装签名串,用HMAC-SHA256计算签名值。
- 请求发出后,网关层先做标准路由和限流判断,再进入鉴权服务。
- 鉴权服务读取请求头中的KeyID、时间戳、Nonce和签名值,先查密钥库拿到Secret和相关权限配置。
- 鉴权服务执行时间窗口校验、Nonce去重、签名重算、常量时间比较,最后检查Scope权限。
- 校验通过后转发到模型推理服务,模型结果返回时同样经过网关统一封装。
这个数据流的好处是签名和业务完全解耦,模型服务本身不感知签名的存在,后续替换算法或增加校验环节都不影响模型推理逻辑。
3. 核心细节解析与实操要点
3.1 签名算法与签名串构造规则
签名算法选了HMAC-SHA256,这是目前最成熟的方案。为什么不直接对参数拼接做SHA-256加盐?因为简单的“拼接+哈希”容易受到长度扩展攻击,而HMAC有标准化的密钥扩展流程,安全性有保障。
签名串的构造是整个机制的核心,规则必须绝对统一,客户端和服务端任何一处不一致都会导致验签失败。我们的签名串格式如下:
Method + "\n" + Path + "\n" + CanonicalQuery + "\n" + BodyDigest + "\n" + Timestamp + "\n" + Nonce逐项说明:
- Method是HTTP方法,统一大写,比如GET、POST。
- Path是请求路径,只包含路径部分,不包含Host和Query,比如
/v1/chat/completions。 - CanonicalQuery是规范化后的查询参数,所有参数按字典序排列,Key和Value分别做RFC3986百分号编码,再用
=连接,最后用&拼接。 - BodyDigest是请求Body的SHA-256哈希值的十六进制小写字符串。
- Timestamp是请求发起时的Unix秒级时间戳,必须是字符串形式。
- Nonce是客户端生成的唯一随机串,通常用UUID,每次请求必须不同。
CanonicalQuery的规范化有一个特别容易踩坑的地方:URL编码必须严格遵循RFC3986,也就是空格编码为%20而不是+,~保持原样不编码,非ASCII字符统一编码为UTF-8的百分号形式。很多语言自带的URL编码库默认规则不一致,直接拿来用就会出现“本地签名没问题、服务端验签失败”的诡异现象。
3.2 时间戳、Nonce与重放防护机制
防重放是整个签名机制里最容易设计失误的环节。我们的实现分两层:
第一层是时间窗口校验。服务端读取请求头里的X-Timestamp,计算它与当前服务器时间的绝对差值,超过配置的容忍窗口就直接拒绝。公网默认窗口是300秒,内网服务之间我会放宽到600秒,因为内部系统时钟同步做不到公网那么精确。这里有一个细节:时间戳必须用请求发起时间,而不是签名生成时间,否则在处理耗时较长的场景下会出现误杀。
第二层是Nonce去重。时间窗口只解决“过期请求重放”的问题,窗口内重放还需要Nonce机制。我们的做法是Redis存储,Key格式为nonce:{KeyID}:{windowId}:{nonce},其中windowId是时间戳除以窗口大小后取整得到的窗口编号。这个设计比简单TTL更可靠:窗口编号是确定性的,服务端在请求到来时同时检查当前窗口和上一窗口,窗口边界处的请求不会被误判为过期,而每个Nonce在窗口内只能使用一次。
降级策略也要提前想好。如果Redis暂时不可用,我们是“优先生效,降级记录”,也就是暂时放行但把异常情况记录到审计日志并触发告警。这个取舍不完美,但至少不会因为鉴权组件故障导致整个API平台瘫痪。
3.3 密钥接入与权限体系设计
密钥管理决定了安全方案的下限。我们给每个调用方分配一对密钥:KeyID是公开标识,相当于用户名;Secret是签名密钥,等同于密码。KeyID的前缀带上用途标识,比如内部业务用ak_in_开头,外部开发者用ak_ext_开头,方便在日志里快速区分来源。
Secret的生成使用加密安全的随机数发生器,生成32字节随机数再做Base64编码。Secret只在创建时明文展示一次,之后任何人都看不到。服务端存储时不能直接存明文,我们采用可逆加密方式存储,密钥材料由配置中心统一管理,即使数据库泄露也不会直接暴露明文密钥。
权限Scope是密钥体系中容易忽略但至关重要的一环。每个KeyID可以绑定如下配置:
| Scope维度 | 说明 |
|---|---|
| 模型白名单 | 允许调用的模型列表,比如只允许调用特定型号 |
| Token配额 | 每分钟和每月的最大Token消耗量 |
| 来源IP白名单 | 只允许指定IP段发起请求 |
| 生效时间 | 密钥的起止有效期,过期自动失效 |
| 请求体大小上限 | 防止调用方发送超大Prompt拖垮服务 |
密钥轮换我们采用“双密钥缓冲期”策略。轮换时新旧两个Secret在配置时间内同时生效,调用方可以在一个周期内平滑切换到新密钥,避免了“切换瞬间大量验签失败”的尴尬。轮换周期建议90天左右,太短影响接入方体验,太长又给泄露留下窗口。
4. 实操过程与核心环节实现
4.1 客户端签名实现
客户端SDK的核心逻辑可以用下面的Python代码表示,这段代码也是我们给内部业务方提供的参考实现模板。
import hashlib import hmac import time import uuid import urllib.parse def build_canonical_query(params: dict) -> str: if not params: return "" sorted_keys = sorted(params.keys()) return "&".join( f"{urllib.parse.quote(str(k), safe='~')}" f"={urllib.parse.quote(str(params[k]), safe='~')}" for k in sorted_keys ) def generate_signature(secret: str, method: str, path: str, query: dict, body: str) -> tuple: timestamp = str(int(time.time())) nonce = uuid.uuid4().hex body_digest = hashlib.sha256(body.encode("utf-8")).hexdigest() canonical_query = build_canonical_query(query) string_to_sign = "\n".join([ method.upper(), path, canonical_query, body_digest, timestamp, nonce ]) signature = hmac.new( secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256 ).hexdigest() return { "X-Key-ID": key_id, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": signature, "X-Signature-Version": "v1", }, string_to_sign这里有两个容易出错的地方。一是body必须和实际发送的请求体完全一致,不能在前处理阶段被格式化或替换。如果业务框架对Body做了序列化,必须保证签名用的串和最终传输的串是同一个。二是quote函数必须指定safe='~',否则默认行为会把~编码成%7E,导致不同语言SDK之间签名不一致。
4.2 服务端校验实现
服务端校验的完整流程用Python实现大概是这个样子:
import hashlib import hmac import time import redis r = redis.Redis(host="redis.internal", port=6379) def verify_signature(config, headers, method, path, query, body): key_id = headers.get("X-Key-ID") timestamp = headers.get("X-Timestamp") nonce = headers.get("X-Nonce") signature = headers.get("X-Signature") version = headers.get("X-Signature-Version", "v1") # 1. 检查头部完整性 if not all([key_id, timestamp, nonce, signature]): raise AuthError(40001, "MISSING_SIGNATURE_HEADERS") # 2. 解析时间戳,兼容毫秒格式 try: ts = int(timestamp) if ts > 10_000_000_000: ts = ts // 1000 except ValueError: raise AuthError(40002, "TIMESTAMP_INVALID") if abs(time.time() - ts) > config.tolerance_seconds: raise AuthError(40002, "TIMESTAMP_EXPIRED") # 3. Nonce去重 window_id = ts // 120 nonce_key = f"nonce:{key_id}:{window_id}:{nonce}" if r.set(nonce_key, "1", nx=True, ex=config.nonce_ttl): pass else: raise AuthError(40003, "NONCE_REUSED") # 4. 获取密钥并检查Scope secret, scope = get_secret_by_key_id(key_id) if not secret: raise AuthError(40301, "KEY_DISABLED") # 5. 重算签名 body_digest = hashlib.sha256(body.encode("utf-8")).hexdigest() canonical_query = build_canonical_query(query) string_to_sign = "\n".join([ method.upper(), path, canonical_query, body_digest, str(ts), nonce ]) expected = hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest() # 6. 常量时间比较 if not hmac.compare_digest(expected, signature.lower()): raise AuthError(40004, "SIGNATURE_MISMATCH") # 7. 权限Scope检查 check_scope(scope, model=query.get("model") or extract_model_from_body(body)) return key_id校验顺序很关键。时间戳校验和Nonce查重要放在验签之前,因为这两个步骤的计算成本很低,可以先挡住大量无效请求。验签放在最后,HMAC计算虽然不贵,但没必要让海量重放请求都走到这一步。
额外强调一点:错误码一定要区分情况。签名缺参数是40001,时间戳异常是40002,Nonce重用是40003,签名不匹配是40004,密钥禁用和权限不足用40301和40302。生产环境的错误响应不要透露过多细节,只给错误码和一句通用描述就行,否则攻击者可以通过响应差异枚举出密钥状态。
4.3 大Payload场景下的性能优化实录
大模型API的请求体普遍偏大,这是和普通API很不一样的地方。一个带长上下文的对话请求,Body轻松超过几十KB,极端场景可能到几百KB。如果每次签名都把这些内容拼接一遍再做HMAC,性能肯定不好看。
我们的“摘要前置”优化直接把这个问题解决了。Body的SHA-256哈希计算是流式的,内存占用恒定,计算速度快。实测下来,一个100KB的Body做SHA-256摘要大约需要0.05毫秒,再做HMAC签名也就0.1毫秒级别,加上Nonce查重的网络开销,整个验签过程在本地或者同机房环境下不超过1毫秒。
还有一个更进阶的优化:如果网关支持流式读取Body,可以对请求体边读边计算摘要,不需要把整个Body先读入内存再处理。这种做法在超大Payload或者文件上传场景收益更明显,对大模型API的普通对话请求来说,摘要前置已经足够用了。
4.4 网关联调中的配置细节
签名机制上线时,网关层的配置细节很容易被忽略,但恰恰这些细节最容易导致“测试环境一切正常,生产环境偶尔验签失败”。
第一个要点是关闭网关的请求体改写功能。某些网关默认会做一些智能处理,比如自动去除空白字符、修改Content-Type、甚至压缩请求体,这些操作都会改变Body的字节内容,导致客户端签名时的摘要和服务端验签时的摘要不一致。我们生产环境曾排查过一个诡异问题:签名偶尔不通过,后来发现是网关在转发时对JSON做了重新序列化,改变了字段顺序和空白,导致Body摘要对不上。最终的解决方案是在网关配置里明确关闭Body改写。
第二个要点是确保自定义Header不被剥离。许多负载均衡器和网关默认会剥离未注册的自定义Header,X-Key-ID、X-Signature这类头必须显式添加到透传白名单里。
第三个要点是超时设置。模型推理接口的响应可能很慢,网关读超时通常要放大到45秒以上,但签名校验阶段本身极快,鉴权服务的超时设置建议控制在几百毫秒。两者要分开配置,别让慢推理拖累鉴权组件的响应。
5. 常见问题与排查技巧实录
5.1 签名校验不通过的十大典型坑
我把线上跑了大半年遇到的验签问题汇总成了一张速查表,基本覆盖了90%的“签名不通过”场景。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 返回40002 TIMESTAMP_EXPIRED | 服务器时间不同步 | 在SDK里做系统时间偏差检测,提示调用方启用NTP |
| 返回40002 TIMESTAMP_EXPIRED | 客户端传了毫秒时间戳 | 服务端兼容13位时间戳,自动转为秒级 |
| 返回40003 NONCE_REUSED | 同一请求被重放 | 确认客户端每次请求是否重新生成Nonce |
| 返回40004 SIGNATURE_MISMATCH | Query参数排序不一致 | 统一用字典序排序,不能依赖语言默认顺序 |
| 返回40004 SIGNATURE_MISMATCH | URL编码规则不一致 | 统一RFC3986编码,空格编成%20不用+ |
| 返回40004 SIGNATURE_MISMATCH | Body在不同环节被重新序列化 | 用实际发出的Body字节计算摘要 |
| 返回40004 SIGNATURE_MISMATCH | 签名后对Header大小写做了转换 | Header名读取不区分大小写,签名值做小写归一化 |
| 返回40001 MISSING_SIGNATURE_HEADERS | 网关剥离了自定义Header | 在负载均衡器里添加Header透传规则 |
| 偶发验签失败 | 客户端和服务端时钟偶尔偏差大 | 采样时间偏差分布,适当调整容忍窗口 |
| 100%验签失败 | 密钥配置错误 | 检查KeyID和Secret是否匹配,Secret是否存入了错误环境 |
最诡异的一次线上问题,是调用方在代码里用了urllib.parse.quote的默认参数,把~编码成了%7E,而我们规范要求~保持原样,两边签名不一致。后来我们在SDK里加了“签名失败debug信息”,把服务端期望的签名串返回给调用方做对比,这类编码问题几分钟就能定位。
5.2 线上安全事件复盘
有一个事件印象很深。当时我们给公司内部的智能客服业务开放了大模型API,某位业务负责人把AppSecret直接贴在了内部技术文档里,结果这份文档被一个爬虫抓走了。攻击者拿到密钥后的第一件事,就是尝试调用高价值的长文本生成模型。
好在我们有两个防线起作用了。第一道防线是IP白名单,攻击者的来源IP不在允许列表内,请求在Scope检查阶段就被拦截;第二道防线是模型白名单,这个密钥只授权了智能客服专用模型,即使IP白名单被绕过,也无法调用其他模型。
日志显示攻击者在一个小时内尝试了上万次请求,全部返回40302,但因为我们给密钥设置了单日配额,损失被控制在极低水平。复盘时我们做了三件加固:一是把密钥Scope从“允许所有模型”收紧为“仅允许客服模型”;二是增加异常告警,同一Key短时大量403错误立即通知运维;三是推动修订密钥保管规范,不允许在共享文档中粘贴明文密钥。
这件事给我的教训是:签名验证只是安全的第一环,真正的防线是密钥的最小权限边界和监控告警闭环。
5.3 长期维护建议与可扩展方向
签名机制上线不是终点,线上的威胁模型会不断变化,需要持续维护和改进。
我建议每个季度做一次“安全自检剧本”,就像红蓝对抗一样,写几个测试脚本模拟攻击行为:试图篡改model参数、重放旧请求、使用过期时间戳、更换KeyID的Secret等等。这个自检能快速暴露防线失效的地方。我们在一次自检中确实发现,新上线的网关自动开启了响应压缩,虽然没有影响签名逻辑,但暴露了配置管理的死角。
监控指标方面,至少要关注三类:签名成功率、Nonce拒绝率、时间戳偏差分布。签名成功率突然下降,大概率是SDK或网关配置变更导致;Nonce拒绝率异常上涨,说明有重放攻击;时间戳偏差持续走高,可能是调用方服务器时钟漂移。
未来演进方向上,我们规划了Ed25519非对称签名的适配,在X-Signature-Version的框架下逐步替换HMAC;另一个方向是引入设备指纹和连续异常行为检测,把单个请求的签名校验升级为“请求+调用方行为”的综合风险评分。但这些都是锦上添花,当前的HMAC签名方案已经能覆盖绝大多数安全风险。
6. 经验与心得
真要说这套方案有多“创新”,我认为不在算法,而在于把几件朴素的事做扎实了。第一,每次请求都校验内容本身,而不是只认密钥;第二,把防重放做成默认能力,而不是事后补救;第三,把权限边界和密钥绑定,让泄露的密钥影响范围可控。踩过的坑越多,我越觉得评判一个安全方案好不好,不是看用了多少新技术,而是看它在线上事故真正到来的时候,能不能把损失和影响控制住。
最后分享一个很实用的小技巧:当你控制着客户端SDK时,记得在签名失败的错误日志里加一个脱敏后的debug_id,并附上请求时间、本地时间戳、时间偏差、以及服务端期望的签名串。这个debug_id可以帮调用方在集成阶段独立排查问题,不用每次都跑到服务端来翻日志。我们上线这个功能之后,联调阶段双方来回确认问题的时间缩短了一大半。安全机制的最终目标不是让人用起来麻烦,而是让不合法的请求进不来,让合法请求的接入体验尽可能顺滑。