现在不少做后端的朋友会跟我聊:API 都上了 HTTPS,是不是就够安全了?其实有实战经验的人都会告诉你,HTTPS 解决的是传输层加密,防止数据在网络上裸奔。但到了应用层,请求从客户端发出来到服务端接收到,中间经过网关、日志、代理、甚至是接入层框架的格式化处理,数据到底有没有被动过手脚,HTTPS 是管不到的。这就是为什么很多开放平台、支付系统、对外服务的 API 都会在业务参数之外再加一道数字签名。我之前在某个项目里就是踩过这类的坑,接口被人重放加篡改参数,排查了很久才发现是缺少了应用层的完整性校验。所以这篇实战文章,我直接分享一下如何用 Python 的 cryptography 库,给 Flask 和 Django 的 API 加上数字签名,确保请求从发出到落库的整个流程里,任何一个字节被动过,服务端都能第一时间识别出来。
这篇文章适合谁看?如果你在做前后端分离项目,或者你在维护服务端到服务端的接口调用,再或者你们公司正在设计对外开放的 API,那这套签名方案可以直接借鉴。内容会覆盖签名原理、密钥对生成、签名与验签的核心代码、Flask 与 Django 的集成方式,还有我实际踩过的坑和排查思路。不废话,直接进入正题。
1. 数字签名方案设计:先搞清楚要防什么
1.1 为什么不能只依赖 HTTPS
HTTPS 很好用,但要理解它的边界。HTTPS 用 TLS 协议加密了客户端和服务端之间的信道,保证在网络上传输的报文是密文。这个保护在链路层面是有效的,可一旦请求到达服务端之后,很多事情就脱离了传输层的掌控。
举个实际例子:你的服务接入了一个统一的 API 网关,网关要记录请求日志以便审计。日志系统通常会把整个请求体打印到文本文件或者采集进日志平台。这个日志如果被无关人员拿到,里面的参数就被看光了?其实签名解决的不是这个,但更关键的问题是:如果日志被改动了呢?如果运维同学通过网关层调整了参数格式呢?这些问题在传输层根本不会被感知。签名不一样,签名基于业务参数本身。只要参数有任何变化,签名立刻校验失败。它提供的是端到端的完整性保护。
另外,HTTPS 还有一层盲区:如果客户端和服务端之间经过了企业内部的反向代理,代理有时候为了做报文解析会对请求体做重组或字符转义,这种转义并不会破坏 HTTPS 的证书链路,但可能让服务端收到的字符跟客户端发出来的不一样。数字签名恰好能补上这一层。
1.2 对称方案 HMAC 与非对称方案 RSA/ECDSA 怎么选
设计签名方案时,先要决定用的是对称密钥还是非对称密钥。对称方案的典型代表是 HMAC-SHA256,也就是客户端和服务端共享一个 App Secret,将请求参数按照约定的规则拼接,用 Secret 作为密钥计算 HMAC 值,服务端做同样的计算并比对。
非对称方案则是用 RSA 或 ECDSA:客户端持有私钥,服务端只保存公钥。请求参数先做规范化序列化,然后用私钥做签名运算;服务端用公钥验证签名是否匹配。
对称方案的优势是计算速度快、实现简单,缺点是 Secret 一旦泄露,攻击者可以随意签名任意请求,而且 Secret 必须安全地存在两端的配置中心里。非对称方案的优势是私钥只在签名端,服务端即便被拖库,攻击者也拿不到私钥,无法伪造签名;缺点是计算开销更大、实现时需要注意的细节更多,比如私钥的 PEM 加载方式、签名算法的 padding 模式。
我给一个实战上的选型建议:如果是内部系统之间的调用,或者你的客户端也是你完全掌控的服务,用 HMAC 完全够了,简单高效。但如果是开放平台、合作方接入、或者客户端代码有可能被逆向拿到密钥的场景,务必用 RSA 或者 ECDSA。下面这套实战方案我以 RSA 私钥签名、公钥验签的方式来写,并且最后会补充 HMAC 的极简实现对比。
1.3 数字签名到底防住了什么
理解签名的作用,最好的方式是明确威胁模型。加了数字签名之后,你可以防住下面这几类攻击:
- 参数篡改:中间人把订单金额从 100 改成 10000,或者把收款账号换成自己的。签名一验,分分钟失败。
- 重放攻击:攻击者截获一个合法请求,在过期时间窗口内原样重发,比如重复下单、重复扣款。配合时间戳和 nonce 之后就能挡住。
- 伪装调用方:没有私钥的人,无法生成有效的签名,无法假冒客户端。
- 日志篡改后的误信:如果服务端拿到的参数是日志里被改动过的,验签直接失败,不会继续执行业务。
当然,数字签名不是万能药。它不能防止私钥被盗,不能替代访问控制,也不负责数据的机密性加密。签名只保证完整性,这个定位务必要清楚。
2. 核心原理与 cryptography 基础:先跑通签名验签
2.1 数字签名的工作原理
数字签名的过程可以分成两步。第一步是对内容做哈希摘要,第二步是对摘要做非对称加密。
客户端侧:
- 把所有业务参数按照约定规则序列化成一个字符串。
- 使用 SHA-256 对字符串计算哈希。
- 用 RSA 私钥对该哈希值进行加密,得到签名。
服务端侧:
- 收到请求后,用同样规则序列化收到的业务参数。
- 对序列化后的字符串计算 SHA-256 哈希。
- 用公钥解签,得到客户端签名时用的哈希值。
- 比对服务端计算的哈希和解签后的哈希,如果一致,说明内容没被动过。
生活化类比一下:签名就像你在合同上盖的私章,合同内容只要改了一个字,你自己盖的章跟内容就对不上了,权威机构一比对就知道这份合同被改过。
2.2 cryptography 库的密钥对生成
cryptography 是 Python 生态里目前最靠谱的密码学库之一,比直接用 pycryptodome 要更规范、更贴近现代密码学标准。用它生成 RSA 密钥对很简单。
from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import hashes, serialization # 生成 2048 位 RSA 密钥对 private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) # 获取私钥 PEM 格式 private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) # 获取公钥 PEM 格式 public_pem = private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) print(private_pem.decode("utf-8")) print(public_pem.decode("utf-8"))这里强调几个点:
- key_size 至少 2048,1024 已经不安全了,4096 用在内部服务里性能也可接受。
- 私钥格式我习惯用 PKCS8,兼容性最好,跨语言导入不折腾。
- 公钥格式用 SubjectPublicKeyInfo,也就是通常说的 X.509 的 SubjectPublicKeyInfo 编码,OpenSSL 里用 PEM 导出的默认格式就长这样。
- NoEncryption 表示私钥文件本身不额外用口令加密。生产环境强烈建议加密,或者放到专门的密钥管理系统里,不落明文磁盘。
2.3 签名与验签的标准姿势
签名操作的代码:
def sign_message(private_key: bytes, message: str) -> str: key = serialization.load_pem_private_key( private_key, password=None ) signature = key.sign( message.encode("utf-8"), padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) return base64.b64encode(signature).decode("utf-8")验签操作的代码:
def verify_signature(public_key: bytes, message: str, signature_b64: str) -> bool: key = serialization.load_pem_public_key(public_key) try: key.verify( base64.b64decode(signature_b64), message.encode("utf-8"), padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH ), hashes.SHA256() ) return True except InvalidSignature: return False这里有个关键选择:padding 用 PSS 还是 PKCS1v15?
PSS 是 RSA 签名里更安全的填充方案,它在安全性证明上比 PKCS1v15 更让人放心。PKCS1v15 虽然兼容性极广、很多老系统还在用,但它的构造存在一些理论上的攻击面。新项目我建议直接上 PSS,如果跨语言对接,只要对方支持 RSA-PSS 就行。但说句实在话,现在主流语言和框架对 PSS 的支持已经非常普遍,Java 的 Signature、Node.js 的 crypto、Go 的 rsa,都能处理,兼容性不用担心。
另外一个细节:PSS 的 salt_length 在两端的设置必须一致。我在实际项目里发现,很多跨语言的问题就出现在这里。Java 端默认某些参数下 salt length 是 32,Python 端如果用 MAX_LENGTH,可能验签永不通过。稳妥起见,签名和验签两端都固定写死为 32,别用 MAX_LENGTH。我下面演示的代码就按这个约定来写。
PSS_SALT_LENGTH = 32 def sign_message(private_key: bytes, message: str) -> str: key = serialization.load_pem_private_key(private_key, password=None) signature = key.sign( message.encode("utf-8"), padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=PSS_SALT_LENGTH ), hashes.SHA256() ) return base64.b64encode(signature).decode("utf-8") def verify_signature(public_key: bytes, message: str, signature_b64: str) -> bool: key = serialization.load_pem_public_key(public_key) try: key.verify( base64.b64decode(signature_b64), message.encode("utf-8"), padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=PSS_SALT_LENGTH ), hashes.SHA256() ) return True except InvalidSignature: return False这样,最底层的签名验签基本能力就齐了。接下来要面对的是:实际 API 请求的参数不是单个字符串,而是一个 JSON 对象,甚至带嵌套结构,怎么规范化成被签名的内容。
3. 参数规范化与签名生成:这个环节最容易出兼容问题
3.1 为什么一定要做参数规范化
签名的核心动作是对一个字符串签名。如果客户端签名用的字符串和服务端验签用的字符串不一致,哪怕业务参数完全一样,签名都对不上。这个"字符串怎么拼"的规则,就叫规范化,是整套签名方案里最容易出坑的地方。
最常见的坑是依赖字典序。请求参数在 HTTP 传输的时候是无序的,客户端传 a=1&b=2,服务端收到之后解析成字典,再遍历出来做签名时,顺序可能就变成了 b=2&a=1,这样签名必挂。所以规范化的第一铁律:把参数名按 ASCII 码升序排序后拼接。
第二个坑是嵌套结构。JSON 里的对象和数组,如果直接套 str() 或者 json.dumps(),嵌套字典的 key 顺序在不同语言、不同库里可能都不一样,生成的字符串完全不同。比如 {"a":{"x":1,"y":2}},有的语言序列化出来是 {"a":{"x":1,"y":2}},有的可能是 {"a":{"y":2,"x":1}}。这一乱,签名必挂。
标准做法是:自己实现一个确定性的序列化函数,保证同样的 JSON 对象永远输出同样的字符串。
3.2 常见规范方案对比
我列三个实践中常用的方案:
方案一:扁平化 key=value 拼接。只适用于参数是单层结构的情况。把所有基础类型参数按 key 字典序排序,拼成 key=value&key2=value2。嵌套参数直接不参与签名或者单独做序列化。实现最简单,但遇到嵌套就只能放弃。
方案二:排序后的 JSON 序列化。把整个 JSON 结构递归排序,字典 key 按字典序排列,再用紧凑的 JSON 格式输出。这种方案通用性强,嵌套结构完全支持。问题在于序列化时要考虑中文编码、特殊字符转义、浮点数精度等细节。
方案三:引入签名专用数据格式。例如先将 JSON 转成规范化的字符串表示,采用 RFC 8785 这样的 JSON Canonicalization Scheme。这个方案最严谨,但需要引入额外依赖。
我的实际建议是:内部系统用方案二就够了,关键是序列化函数必须自己写一遍,不要用现成的 json.dumps 直接上,因为 sort_keys 参数只是让 key 排序,没有处理字典里嵌套 key 的递归排序,至少需要 ensure_ascii=False 且 separators 固定。
3.3 一个稳定可用的规范化实现
我常用的是这个实现,它来自我在几个项目里反复打磨过的版本,核心思路就是递归对 dict 排序并统一分隔符:
import json def canonical_json(data) -> str: return json.dumps( data, sort_keys=True, ensure_ascii=False, separators=(",", ":") )这个方法在大多数场景下是够用的,但有一个隐患:ensure_ascii=False 之后,中文会以 UTF-8 直接出现在字符串里。如果跨语言的另一端对非 ASCII 字符做了 unicode 转义,可能就不一致。为了防止这种不确定,我后来在跨语言对接的项目里改成了 ensure_ascii=True 的统一转义形式,效果更好,因为 ASCII 转义是唯一确定性的写法。具体说,改成这样:
def canonical_json(data) -> str: return json.dumps( data, sort_keys=True, ensure_ascii=True, separators=(",", ":") )用 ensure_ascii=True 以后,所有非 ASCII 字符都会变成 \uXXXX 形式。这个做法最大的好处是什么?不管两端用什么语言,什么默认编码,最终字符串完全一致,不会因为编码问题出现"明明签名算法一样却怎么都不通过"的情况。
但这里要提醒一句:ensure_ascii=True 对服务端 URL 传参方式有影响。因为 \uXXXX 一旦被 URL 编码,\ 会变成 %5C,u 变成 %75。所以签名串最好放在请求头里,而不是放进 URL query。如果一定放 query,记得发送前对 header 签名值做 base64,不要直接把 JSON 序列化塞进 URL。
再看一眼完整流程:
payload = { "amount": 100, "order_id": "20250101001", "timestamp": 1735689600, "nonce": "7f3b2e1a9c", } canonical_str = canonical_json(payload) signature = sign_message(private_key_pem, canonical_str)到这里,签名串已经生成了。下一步就是如何把这个东西挂到 Flask 和 Django 的请求链路上,让每个请求自动校验。
4. Flask 与 Django 集成实战:把签名校验做成通用能力
4.1 先约定请求头的传递格式
不管是 Flask 还是 Django,签名相关的信息我建议都放在请求头里,不要污染业务参数。具体约定如下:
- X-Signature: 签名的 Base64 字符串
- X-Timestamp: 客户端当前时间的 Unix 时间戳
- X-Nonce: 随机字符串,每次请求唯一,用于防重放
时间戳和 nonce 为什么不能省?原因很简单:单纯签名只能防篡改,不能防重放。攻击者拿到一个合法请求,只要签名本身有效,他可以在业务规则允许的时间窗口内重复提交。时间戳给了服务端一个判断"请求是否过期"的依据,nonce 则是为了进一步防止攻击者在时间窗口内重放同一请求。
服务端校验顺序是:先取时间戳,判断是否在允许的偏差范围内,比如前后 300 秒;再去查 nonce 是否用过,这个需要存在 Redis 或数据库里;最后才验签。顺序不能乱,因为验签是 CPU 密集操作,如果请求已经过期,就直接拒绝,没必要浪费算力去验签。
4.2 Flask 的 before_request 全局校验
Flask 里最简单的做法是注册一个 before_request 钩子。这个方法在 Flask 里会被所有视图函数共享,不管路由写到哪里都会先执行。
import time import base64 from flask import Flask, request, jsonify app = Flask(__name__) PUBLIC_KEY_PEM = b"""-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----""" VALID_WINDOW = 300 def verify_signature_from_headers(headers, body): signature = headers.get("X-Signature", "") timestamp = headers.get("X-Timestamp", "") nonce = headers.get("X-Nonce", "") if not signature or not timestamp or not nonce: return False, "missing signature headers" if abs(time.time() - int(timestamp)) > VALID_WINDOW: return False, "timestamp expired" # nonce 检查,这里假设你有一个 RedisClient,先略过 # if redis_client.get(f"nonce:{nonce}"): # return False, "nonce already used" # redis_client.setex(f"nonce:{nonce}", VALID_WINDOW, "1") try: body_data = request.get_json() except Exception: body_data = {} sign_data = { "timestamp": timestamp, "nonce": nonce, "body": body_data, } canonical_str = canonical_json(sign_data) if not verify_signature(PUBLIC_KEY_PEM, canonical_str, signature): return False, "signature invalid" return True, "ok" @app.before_request def check_signature(): if request.path.startswith("/public"): return None ok, msg = verify_signature_from_headers(request.headers, request.get_data().decode("utf-8")) if not ok: return jsonify({"code": 401, "msg": msg}), 401 return None注意这里有个细节:我把 timestamp、nonce、body 三个一起放进待签名的数据结构里,而不是只对 body 签名。这样做有什么好处?
首先,如果只对 body 签名,timestamp 和 nonce 就可以被攻击者随意改,过期重放就防不住了。其次,把 timestamp 和 nonce 纳入签名,可以防止攻击者把旧请求的 body 配到新时间戳里再签一次——没有私钥根本签不出来。所以实际项目里,签名范围一定是"请求元信息 + 业务参数"的整体,不是只对 body。
4.3 Django 中间件实现方式
Django 的做法是写一个自定义中间件。Django 中间件的执行顺序是从上往下、再返回,所以通常在 settings 的 MIDDLEWARE 列表里至少在 session 和 auth 中间件之前加入签名校验中间件,这样越早拦截越好。
中间件代码:
import time from django.http import JsonResponse class SignatureCheckMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # 跳过某些公开接口 if request.path.startswith("/healthz"): return self.get_response(request) signature = request.headers.get("X-Signature", "") timestamp = request.headers.get("X-Timestamp", "") nonce = request.headers.get("X-Nonce", "") if not all([signature, timestamp, nonce]): return JsonResponse({"code": 401, "msg": "missing signature headers"}, status=401) try: if abs(time.time() - int(timestamp)) > 300: return JsonResponse({"code": 401, "msg": "timestamp expired"}, status=401) except ValueError: return JsonResponse({"code": 401, "msg": "invalid timestamp"}, status=401) # nonce 防重放检查,略,建议放 Redis # if not check_nonce(nonce): # return JsonResponse({"code": 401, "msg": "nonce used"}, status=401) try: body_data = json.loads(request.body.decode("utf-8")) if request.body else {} except json.JSONDecodeError: return JsonResponse({"code": 401, "msg": "invalid json body"}, status=401) sign_data = { "timestamp": timestamp, "nonce": nonce, "body": body_data, } canonical_str = canonical_json(sign_data) if not verify_signature(PUBLIC_KEY_PEM, canonical_str, signature): return JsonResponse({"code": 401, "msg": "signature invalid"}, status=401) # 通过后,把验签解析出的信息挂到 request 上,方便后续视图使用 request.signed_params = body_data return self.get_response(request)需要提醒的是,Django 在收到 request.body 之后,如果后续中间件或视图还要读 body,RequestBody 在 Django 中默认只能读一次。为了让视图还能读 body,可以在读过之后把 body 重新赋值,或者自定义一个缓存 Body 的中间件。我在项目里的做法是,在中间件中读取 request.body 并缓存到 request._body_cache,然后在后续视图读取时使用 request.body 依然能拿到。具体实现要看 Django 版本,新版 Django 的 request.body 本身支持多次读取,问题不大。
4.4 视图层如何使用验签后的参数
不管 Flask 还是 Django,验签通过之后,后续视图就不要再信任外部直接传来的参数了,而应该信任经过签名校验后的参数。在实际代码中,我是这么做的:
Flask 视图里直接读 request.json,因为前置步骤已经确认它没被篡改。如果想要更严格,可以把校验后的参数放到 flask.g 或 request 的某个扩展属性上,视图只从那里取值。
Django 的中间件里已经把 body 放到了 request.signed_params,视图就统一用 request.signed_params.get("amount") 这样的方式取值。这样做的意义是,视图开发人员无需关注签名逻辑,只要在中间件之后的链路,拿到的就是"可信参数"。这算是一个约定优于配置的实践。
5. 客户端签名示例与支付级安全细节
5.1 Python 客户端如何构造签名请求
服务端写完,客户端的实现也得跟上。以 Python requests 为例,客户端流程是:
import time import uuid import json import requests import base64 PRIVATE_KEY_PEM = open("private.pem", "rb").read() def build_signed_request(private_key_pem, body: dict): timestamp = str(int(time.time())) nonce = uuid.uuid4().hex sign_data = { "timestamp": timestamp, "nonce": nonce, "body": body, } canonical_str = canonical_json(sign_data) signature = sign_message(private_key_pem, canonical_str) headers = { "X-Signature": signature, "X-Timestamp": timestamp, "X-Nonce": nonce, "Content-Type": "application/json", } return headers, json.dumps(body, ensure_ascii=False) headers, payload = build_signed_request(PRIVATE_KEY_PEM, {"amount": 100, "order_id": "123"}) resp = requests.post("https://api.example.com/v1/order", data=payload, headers=headers)这个客户端代码用到了我们上面定义的 canonical_json 和 sign_message。如果客户端不是 Python,而是 Java、Go、Node.js,核心就是把这个 normalized string 和 PSS 参数对齐,其他逻辑完全一样。
5.2 密钥管理:比签名算法更值得上心的事
签名算法本身不是安全短板,真正容易出问题的是密钥管理。我经常看到团队把私钥文件放在项目仓库里,或者写死在配置文件中,这是非常危险的。
- 私钥建议存放在专门的密钥管理服务中,比如云上的 KMS,或者自建的 Vault。应用启动时通过 SDK 获取私钥,内存中解密使用,磁盘上不落明文。
- 至少要做到:私钥文件权限 600,只允许运行用户读取;仓库 .gitignore 排除密钥文件;开发环境、测试环境、生产环境使用不同的密钥对。
- 定期轮换密钥。我所在的开发小组是每半年轮换一次,轮换期间同时支持两套公钥,旧公钥保留一个过渡期。
公钥虽然不算机密,但公钥的传递也需要防篡改,否则攻击者替换公钥后就能伪造签名了。公钥尽量通过配置中心或代码仓库里统一管理。必要场景下可以用另一套机制为公钥做签名认证,比如直接用 HTTPS 从配置中心拉取。
5.3 重放攻击与防重放机制
防重放这块我再展开说。时间戳窗口校验是最基础的,但只是限制了攻击窗口。nonce 机制可以在这个窗口内彻底防住同一请求重放。实现上可以把 nonce 写入 Redis,设置过期时间跟时间戳窗口一致。
import redis r = redis.Redis(host="localhost", port=6379, db=0) def check_and_store_nonce(nonce: str, window: int) -> bool: key = f"nonce:{nonce}" if r.set(key, "1", nx=True, ex=window): return True return False如果请求处理速度极快,同一个 nonce 在窗口期内重复提交,第二次就会命中 Redis 已有键,直接拒绝。这里有个生产级细节:Redis 的 set 需要带 nx=True 才能保证只有第一次写入成功,这个操作是原子的,在高并发下不会出现两个请求同时通过校验的情况。
nonce 的生成建议用 UUID4 或者 16 字节以上的随机数,保证不可预测。不要用自增 ID、时间戳这类可预测的值,否则攻击者可以预测并构造重复组合。
6. 常见问题排查与我的经验总结
6.1 跨语言验签失败的七大原因
我在实际对接中遇到最多的问题,就是客户端签名,服务端验签就是不通过。这里列出最常见的七个原因:
- salt_length 不一致。Java 端有些库默认 PSS salt length 是 32,Python 端如果用了 MAX_LENGTH,两端就对不上了。
- 待签名字符串不一致。这是排名第一的原因。常见于字典顺序、嵌套 JSON key 顺序、中文字符串编码、URL 编码方式不一致。
- 签名值做了 URL 编码。如果签名值是 Base64,Base64 里的 +、/、= 字符在 URL 传输时会被转义,服务端接收后必须做反转义才能 base64 解码。
- 公钥格式解析失败。很多人用 Java 的 X509EncodedKeySpec 生成公钥,Python 端load_pem_public_key 可能不支持,需要转换格式。
- Base64 解码时出现错误。签名值的 padding 被截断或者被错误地去掉,导致二进制不一致。
- 请求体为空的情况。客户端签的是空 body {},服务端解析 request.get_json() 拿到 None,两者序列化结果不一致。
- 时间戳边界问题。客户端生成签名用的 timestamp 和服务端验签时读到的 timestamp 不一致。细心看代码会发现,如果你把 timestamp 放在 header 里,签名时也用了这个 timestamp,那两边只要都用同一个 header 里的 timestamp,这块反而不会出错。
针对这七点,调试套路也很固定:先在客户端和服务端分别打印出待签名的 canonical string,比对这两个字符串是否完全一致。只要这两个字符串一致,签名百分之百能通过;不一致,就把差异点找出来。这个排查步骤在跨语言对接时能节省大量时间。
6.2 性能优化与请求体大小控制
签名运算虽然是 CPU 密集操作,但 RSA 验签一次在普通机器上大约只需要几毫秒,正常情况下没问题。但如果在高并发接口上每个请求都走 RSA 验签,再加上 PSS 的 salt length 较大,性能压力会显现出来。
建议的做法:
- 网关层做验签,应用层只做业务处理。验签放在 Nginx 的 Lua 脚本、Kong、APISIX 这类网关上,可以把计算压力从业务服务中剥离出去。
- 如果不需要非对称能力,内部服务改走 HMAC-SHA256,验签速度快一个数量级,代码也更简单。
- 限制请求体大小。签名校验通常需要在拿到完整 body 之后进行,如果请求体没有上限,攻击者可以发超大 body 把你 CPU 打满。建议在网关或应用层把 body 大小限制在 1MB 左右。
6.3 HMAC 极简替代方案
如果你的场景完全不需要非对称,就用 HMAC-SHA256 加 App Secret 的方式。代码比 RSA 简单得多:
import hmac import hashlib import base64 def generate_hmac_signature(secret: str, canonical_str: str) -> str: signature = hmac.new( secret.encode("utf-8"), canonical_str.encode("utf-8"), hashlib.sha256 ).digest() return base64.b64encode(signature).decode("utf-8")服务端验签就是重新计算一次然后比对:
def verify_hmac(secret: str, canonical_str: str, signature: str) -> bool: expected = generate_hmac_signature(secret, canonical_str) # 恒定时间比较,防止时序攻击 return hmac.compare_digest(expected, signature)注意 hmac.compare_digest 这个函数绝不能替换成 == 比较。用 == 的话,字符串长度和内容可以在时间上产生可观测差异,存在时序攻击风险。这是对称方案的加分项,务必写上。
6.4 我对这套方案的心得
最后聊点我的实际体会。项目里我用这套 RSA 签名方案接过支付回调、开放平台 API、服务间消息调用,甚至在某个金融场景的接口里验证过。回头看,最大的教训就一个:签名方案设计得再好,如果规范化规则不严谨,最后全都白搭。签名算法的选型、密钥的存储、防重放机制,这些都是在规范化稳定之后才能发挥作用的。
另外,有一个容易被忽视的点:日志记录。验签失败的请求一定要记录完整的 header 信息和待签名字符串(可以脱敏),这对线上问题排查非常关键。我遇到过生产环境某个客户端突然验签失败,就是因为对方序列表里多了一个空字段,而我们这边把空字段过滤掉了。没有日志,这种问题根本无从查起。
如果你现在就准备动手改造自己的 API,我建议按这个顺序来:先写一个独立的签名服务模块,把所有生成、校验逻辑收敛到这一个模块里,再在 Flask 和 Django 中接入中间件,最后写配套的客户端 SDK。一次封装,两端复用。等到你的 API 开始接入外部合作方或者面向更复杂的网络环境时,你会发现这套签名机制带来的价值,远不止是"请求不被篡改"这么简单。它是你的服务从"能跑"走向"可信"的关键一步。