把载荷当成公开信息,用签名保证其完整,一旦看见过期时间已过去就果断拒绝,别让一枚重放令牌活得比它该活的更久。
编码不加密 JWT 不是靠藏来保证安全的;它由 header、claims 与签名三段用 base64url 编码,用点连起来。任何拿到令牌的人都能反解出编码,逐条读出全部 claims。这是刻意为之,却也带来一条硬规矩:里面绝不允许放任何秘密——不能放密钥、不能放只有服务端才该看的会话数据。凡是你不会写进公开日志的东西,就别塞进 claim;需要回查的值用 jti 去查表,而不是直接放在载荷里。编码只是为了便于携带,不构成任何层面的保密。
签名先行为先 令牌只有在消费方先验证后才可信。正确的顺序是:先用信任、且认得出是签发者在握的那把钥匙去验签名,然后再去读 claims、核 audience。跳过签名、或是信 header 里 alg 的验签器,是最经典的失守姿势,因为伪造者只要把 alg 改成 none,或者把自己的公钥交出去,就能被当成真货。所以应把算法白名单钉死,核对签发者,不接受陌生方,绝不让令牌自己告诉你要用哪把钥匙。密钥与链路是信任的根基,要由可配置的密钥管理来决定,而不是由 token 自己说了算。
过期即契约 签名证明的是谁写了这枚令牌,而不是它多活到什么时候。exp 是它的寿命边界,必须强制执行:哪怕签名还新鲜,exp 一旦过去也该被拒绝。真实系统会留一点有界的时钟偏差,让刚签发出来的令牌别被 NTP 的几秒时钟偏差误杀,但绝不能盖过令牌本身的生命期。凡涉及敏感数据,都要配合基于 iat 的短租期,因为否则这枚状态无关、无法单点吊销的令牌会一路活到结束。过期即契约,也是唯一能让一枚被偷或重放的令牌不活过自己窗口的保障。
坏掉的样子 最典型的失守是验签器信任令牌自带的算法头。攻击者复制一份合法载荷,把头部改成无签名算法,去掉第三段签名,跳过验签的服务端就会直接放行。这不是载荷写错,而是验签路径被令牌劫持。对策是服务端钉死算法白名单,只接受一种算法家族,直接拒绝无签名算法,不根据头部切换逻辑,可接受算法写在配置里。
第二类经典故障是公钥与对称密钥之间的混淆。当同一个验签器同时接受非对称与对称算法,攻击者就能把本就公开的公钥拿来当作对称密钥,用它签发伪造令牌。验签器用同一份公开值去计算校验值,结果恰好能对上,伪造令牌就被当成真货。防范办法是每个签发者只允许一种算法家族,两类密钥分开存放,标识只做本地查表键,不指向文件路径或外部地址。
缺时间戳的故障最安静。没有过期时间的令牌永远不会过期,偷走之后可以一直用到密钥轮换;没有签发时间就没有锚点,时钟偏差检查与寿命审计都无从谈起。即使两者都有,若寿命动辄数天,一次泄露就等于长期会话。对策是签发时必带过期与签发时间,验签时强制检查,敏感接口只给几分钟租期,时钟容忍只给几十秒。
无状态带来的注销难题必须提前承认。服务端不存会话,就无法单独吊销某一枚令牌,登出时要么等它自然过期,要么维护标识黑名单。生产做法是短过期配合黑名单,登出即拉黑,敏感操作再回查服务端。同时牢记载荷只是编码,人人可读,不放口令与敏感信息,保密值只放引用标识,内容留在服务端按需查询。
快速验证 把下面代码存为验证脚本,用命令运行。全程只用标准库手写签发与验签,让签名比对过期检查与篡改拒绝都看得见。不依赖第三方包,不联网,用负寿命模拟过期,用改动载荷首字符模拟篡改。
import base64 import hashlib import hmac import json import time SECRET = b"demo-secret-key-change-me" def b64url_encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def b64url_decode(data: str) -> bytes: padding = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(data + padding) def issue(subject: str, lifetime: int = 600) -> str: header = b64url_encode( json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode() ) now = int(time.time()) payload = b64url_encode( json.dumps( {"sub": subject, "iat": now, "exp": now + lifetime}, separators=(",", ":"), ).encode() ) signature = b64url_encode( hmac.new(SECRET, f"{header}.{payload}".encode(), hashlib.sha256).digest() ) return f"{header}.{payload}.{signature}" def verify(token: str) -> dict: header_b64, payload_b64, signature_b64 = token.split(".") header = json.loads(b64url_decode(header_b64)) assert header.get("alg") == "HS256", f"unexpected alg: {header.get('alg')}" expected = b64url_encode( hmac.new(SECRET, f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest() ) assert hmac.compare_digest(expected, signature_b64), "bad signature" claims = json.loads(b64url_decode(payload_b64)) assert "exp" in claims and "iat" in claims, "missing exp/iat" assert int(time.time()) < int(claims["exp"]), "token expired" return claims token = issue("demo-user") claims = verify(token) assert claims["sub"] == "demo-user" print(f"valid: sub={claims['sub']}") stale = issue("demo-user", lifetime=-10) try: verify(stale) raise AssertionError("expired token must not verify") except AssertionError as exc: assert str(exc) == "token expired", exc print("expired correctly rejected: token expired") head, pay, sig = token.split(".") tampered_pay = ("A" if pay[0] != "A" else "B") + pay[1:] try: verify(f"{head}.{tampered_pay}.{sig}") raise AssertionError("tampered token must not verify") except AssertionError as exc: assert str(exc) == "bad signature", exc print("tampered correctly rejected: bad signature") print("PASS: valid accepted; expired and tampered rejected") 脚本先签发正常令牌并验签通过,证明正常路径畅通。再用负寿命签发过期令牌,确认以过期原因拒绝。最后篡改载荷首字符,确认以签名不匹配拒绝。三组断言覆盖正常过期与伪造三种行为。预期输出共四行,分别是主体一行两行拒绝原因与一行汇总。
...