带签名的 JWT 携带一组声明,接收方可以检查它们的完整性,而不必先从会话表读取整枚令牌。便利并不等于保密,也不会自动完成授权或阻止重放。审阅时应先明确令牌格式和应用的验证契约,再分别测试边界。密码学验证成功,只是访问决策的一项输入,不能代替资源本身的权限判断。

带签名令牌只是 JWT 的一种形式

本文讨论以 JWS 表示的紧凑、带签名或消息认证码的 JWT:受保护头部、载荷、签名或认证码构成三个点分段。Base64url 可以逆向解码,并不提供保密性。但 JWT 也支持加密的 JWE 表示,因此“所有 JWT 都可读而且只有三段”并不准确。即使使用加密,也应减少个人信息,避免嵌入与当前用途无关的凭据。

HS256 用共享秘密计算 HMAC。每个持有秘密的验证方也能签发令牌,因此它不能证明究竟是哪一个持有者发出了令牌。非对称签名把签发与验证能力分开。无论采用哪种设计,都不能因为令牌附带了一把陌生密钥,就把它当作信任依据。可信签发者、密钥来源、允许算法和令牌种类需要一起配置。

验证是一份应用契约

实现可以先解析少量不可信数据,以定位已经配置的密钥,但在密码学验证及策略检查全部完成前,不能根据声明执行操作。把 kid 当作可信密钥集合内的标识,不允许它任意指定文件路径或网络下载地址。每把密钥必须绑定预期算法,不能让声称使用 HS256 的令牌把 RSA 公钥变成 HMAC 秘密。

除了主体和具体操作需要的权限,还要检查签发者与预期受众。发给另一个接口的真实令牌,在这里仍然不合适。不同令牌种类可能需要互斥的验证规则,防止一种凭据被替换成另一种。生产环境应采用维护良好的 JWT 库,并显式设置验证参数;下面的小模型只实现一个封闭的演示配置。

基础 JWT 规范并没有要求每枚令牌必须包含所有注册声明;哪些字段必需,由应用规范决定。短期访问令牌应要求并执行 exp 检查,存在 nbf 时也要限制提前使用。当前时间必须早于 exp,时钟容忍则应明确配置且有界。iat 记录签发时间,本身既不让令牌过期,也不替代 nbf。示例只接受整数时间,是比完整 NumericDate 定义更窄的本地策略。

坏掉的样子

过期令牌仍可能有完全正确的签名;反过来,时间戳再新也无法挽救错误签名。两种失败应分别测试,并覆盖恰好到达过期时刻的情况。对调用者统一返回令牌无效可能合理,但内部诊断仍需区分原因,同时不能把整枚令牌写进日志。先验证正常路径,才能避免把“全部拒绝”误判成安全实现。

过期时间限制重放窗口,却不是唯一的撤销机制。服务可以查询会话状态、令牌标识拒绝列表、账号策略或内省端点。这些方案都引入一致性与可用性要求。轮换签名密钥可能影响大量令牌,不能把它当作方便的单用户退出机制。单独加入 jti 字段既不会自动保存服务端数据,也不会自动完成吊销,相关状态和查询规则必须确实存在。

不要使用 Python 的 assert 执行认证检查,因为优化模式可能删除它们。声明类型也应明确验证,不能随意把字符串或布尔值转成时间戳。示例中可读的秘密不应成为部署后的签名密钥;HS256 需要足够强的秘密。这里每次运行生成三十二个随机字节,仅用于这一轮实验,程序结束后不再保存。

快速验证

保存为 jwt_check.py,分别执行 python3 jwt_check.py 与 python3 -O jwt_check.py。固定的合成时间让边界检查可重复,同时不改变机器时钟。所有数据都在内存里,不打印、不持久化、不发送令牌和密钥,也不会读取用户现有账号或环境凭据。

import base64
import hashlib
import hmac
import json
import re
import secrets

# Ephemeral fixture key, never a production credential.
KEY = secrets.token_bytes(32)
NOW = 1_800_000_000


def encode(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')


def decode(value):
    if not re.fullmatch(r'[A-Za-z0-9_-]+', value):
        raise ValueError('invalid base64url')
    return base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True)


def issue(claims, algorithm='HS256'):
    head = encode(json.dumps({'alg': algorithm, 'typ': 'JWT'}).encode())
    body = encode(json.dumps(claims).encode())
    signed = f'{head}.{body}'
    signature = encode(hmac.new(KEY, signed.encode(), hashlib.sha256).digest())
    return f'{signed}.{signature}'


def verify(token, now):
    head, body, signature = token.split('.')
    header = json.loads(decode(head))
    if not isinstance(header, dict) or header != {'alg': 'HS256', 'typ': 'JWT'}:
        raise ValueError('unsupported header')
    expected = hmac.new(KEY, f'{head}.{body}'.encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, decode(signature)):
        raise ValueError('bad signature')
    claims = json.loads(decode(body))
    if not isinstance(claims, dict):
        raise ValueError('claims must be an object')
    if claims.get('iss') != 'fixture-issuer' or claims.get('aud') != 'fixture-api':
        raise ValueError('issuer or audience mismatch')
    if any(type(claims.get(k)) is not int for k in ('iat', 'nbf', 'exp')):
        raise ValueError('integer times required by fixture')
    if not claims['iat'] <= now or not claims['nbf'] <= now < claims['exp']:
        raise ValueError('outside time policy')
    if not isinstance(claims.get('sub'), str) or not claims['sub']:
        raise ValueError('subject required')
    return claims


def rejected(label, token, now=NOW):
    try:
        verify(token, now)
    except ValueError:
        print('rejected:', label)
        return
    raise RuntimeError('unexpected acceptance: ' + label)


claims = {'iss': 'fixture-issuer', 'aud': 'fixture-api', 'sub': 'demo',
          'iat': NOW, 'nbf': NOW, 'exp': NOW + 60}
token = issue(claims)
if verify(token, NOW)['sub'] != 'demo':
    raise RuntimeError('valid token failed')
rejected('exact expiry', token, NOW + 60)
rejected('not yet valid', issue({**claims, 'nbf': NOW + 1}))
rejected('future issued-at', issue({**claims, 'iat': NOW + 1}))
rejected('wrong audience', issue({**claims, 'aud': 'other-api'}))
rejected('wrong issuer', issue({**claims, 'iss': 'other-issuer'}))
rejected('missing expiry', issue({k: v for k, v in claims.items() if k != 'exp'}))
rejected('boolean time', issue({**claims, 'exp': True}))
rejected('algorithm mismatch', issue(claims, 'none'))
head, body, signature = token.split('.')
forged_body = encode(json.dumps({**claims, 'sub': 'admin'}).encode())
rejected('tampered subject', f'{head}.{forged_body}.{signature}')
print('PASS: valid accepted; nine negative cases rejected')

两次调用都应打印九条拒绝结果以及 PASS。正常令牌首先建立成功路径,负面用例随后覆盖恰好过期、尚未生效、未来签发、错误受众、错误签发者、缺少过期时间、布尔时间、算法不匹配,以及保留原签名但改变主体。验证和测试失败都通过显式异常表示,因此优化模式不会把关键检查移除。

这是一段可执行的机制说明,不是完整 JWT 实现。它没有实现受众数组、重复成员处理、密钥发现与轮换、嵌套令牌、时钟容忍、大小限制、吊销与具体授权。生产库和应用仍需逐项审阅这些边界。通过这里的测试,只说明列出的局部契约成立,不代表整个身份认证系统已经得到认证。

延伸阅读

来源