验证 JWT 不是一项检查,而是六项,签名只是其中之一。简而言之:决定如何检查令牌的是验证方,而不是令牌。在服务端固定算法;只在你已经信任的、属于该颁发者的密钥中查找密钥;要求必须有 exp;精确比较 iss 和 aud;检查 typ,防止一种令牌冒充另一种;时钟偏差最多容许一分钟;缓存 JWKS,并在遇到未知的 kid 值时以受速率限制的方式刷新。下文讲的是缺少其中某一项时会出什么问题,结果均在真实的库上测得。
本页的所有输出都来自专门为本文签发、用新生成的 ES256 密钥签名的令牌,并由以下库验证:Node.js 24.22 上的 jose 6.2.13 和 jsonwebtoken 9.0.3(配合 jwks-rsa 4.1.0)、Python 3.14.8 上的 PyJWT 2.15.1,以及 Go 1.27.3 上的 golang-jwt 5.3.1 和 keyfunc 3.8.2,均运行在各自的官方 Docker 镜像中。
只传入密钥时,库会检查什么
大多数 JWT 相关的 bug 并不出在库里。问题出在调用方式上:只验证了签名就停下,因为调用方从未告诉库应当接受哪个颁发者、受众或算法。下面是用有效公钥、不带其他任何选项调用时,各个库对三个令牌的处理结果(PyJWT 不传 algorithms 就拒绝运行,所以给它传了 ["ES256"]):
| 令牌 | jose jwtVerify | jsonwebtoken verify | PyJWT decode | golang-jwt Parse |
|---|---|---|---|---|
由正确的密钥签名,但 iss 和 aud 属于另一个服务 | 接受 | 接受 | 拒绝:Invalid audience | 接受 |
由正确的密钥签名,没有 exp 声明 | 接受 | 接受 | 接受 | 接受 |
alg: "none",签名为空 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
两点教训。第一,四个库默认都拒绝未签名的令牌,所以著名的 none 问题如今主要出在手写的验证器中。第二,在这四个库中,没有 exp 的令牌永远有效;而且四个库中有三个会接受为另一个服务签发的令牌,只要它是用同一个密钥签名的。exp、nbf、iss 和 aud 在 RFC 7519 §4.1 中都是可选声明;库会在声明存在时以及配置了相应检查时检查它们,其余的都要靠你自己。
错误 1:让令牌选择算法
alg 头部来自一个你尚未验证的令牌。读取它并据此分派的验证器,等于让发送方来定规则。RFC 8725 §3.1,即 JWT 最佳当前实践,说得很直白:库必须允许调用方指定所支持的算法,并且不得使用其他任何算法;每个密钥都必须只与一种算法配合使用。
最典型的问题是 HS/RS(或 HS/ES)混淆,RFC 8725 §2.1 对此有描述,jsonwebtoken 中的这一问题被登记为 CVE-2015-9235。某个服务持有一个公钥,验证时“头部说什么就用什么”。有人以公钥的 PEM 文本作为密钥,对一个带有 "alg": "HS256" 的头部计算 HMAC。公钥本来就是公开的,所以任何人都能做到这一点。下面这个手写的验证器正是这种结构:
import { createHmac, timingSafeEqual, verify } from "node:crypto";
import { readFileSync } from "node:fs";
// Anti-pattern: the token picks the algorithm, one key object serves both families.
function naiveVerify(token, publicKeyPem) {
const [h, p, s] = token.split(".");
const { alg } = JSON.parse(Buffer.from(h, "base64url"));
const input = Buffer.from(`${h}.${p}`);
const sig = Buffer.from(s, "base64url");
if (alg === "HS256") {
const mac = createHmac("sha256", publicKeyPem).update(input).digest();
return mac.length === sig.length && timingSafeEqual(mac, sig);
}
if (alg === "ES256") {
return verify("sha256", input, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, sig);
}
throw new Error(`unsupported alg ${alg}`);
}
const pem = readFileSync("out/public.pem", "utf8");
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
console.log("ES256 token from the issuer:", naiveVerify(tokens.ok, pem));
console.log("HS256 token keyed with the public PEM:", naiveVerify(tokens["HS256 keyed with the public PEM"], pem));
ES256 token from the issuer: true
HS256 token keyed with the public PEM: true
两次都返回 true。第二个令牌是在没有私钥的情况下生成的。注意,常量时间比较在这里无济于事:代码在时序上很谨慎,在信任上却是错的。
修复方法是在验证方使用固定的允许列表(最好只含一个值),并使用自带算法的密钥。给定 algorithms: ["ES256"] 时,四个库都拒绝了伪造的令牌,报错各不相同:jose 报 "alg" (Algorithm) Header Parameter value not allowed,jsonwebtoken 报 invalid algorithm,PyJWT 报 The specified alg value is not allowed,golang-jwt 报 signing method HS256 is invalid。它们还有第二道防线,这也是如今库代码中很少出现这种混淆的原因:
- jsonwebtoken 9.x(从2022年12月发布的 9.0.0 起)在你省略
algorithms时,会根据密钥类型推导允许的算法:EC 密钥只允许ES256、ES384和ES512。 - PyJWT 拒绝把 PEM 公钥或证书用作 HMAC 密钥:The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
- golang-jwt 检查密钥的 Go 类型:HMAC 方法要求
[]byte,所以传入*ecdsa.PublicKey会报错 key is of invalid type。 - jose 检查密钥类型是否与算法相符。即使没有
algorithms选项,用 EC 公钥验证伪造的令牌时也会报错:KeyObject instances must be of type "secret" for the HS256 algorithm。
这些机制都不能替代允许列表。它们保护的是常见情况,而不是把原始字节直接交给库的自定义密钥加载器。
错误 2:在自己写的代码路径中接受“none”
RFC 7518 §3.6 为完整性由其他机制保护的令牌注册了 "alg": "none"。在上面的表格中,每个库默认都拒绝了它。风险在于库周边的代码:跳过验证的“调试模式”、把空签名当作“尚未签名”的分支,或者以区分大小写的方式比较 alg 的拒绝列表。这份 BCP 的修订版 draft-ietf-oauth-rfc8725bis(截至2026年10月已进入 RFC Editor 队列)点名的正是这一点:头部写成 noNE 就能绕过的 none 拒绝列表。允许列表不存在这样的缺口。
错误 3:信任 kid、jku、x5u 或内嵌的 jwk
kid 的作用是从你已经信任的几个密钥中挑出一个,而不是指向令牌自带的密钥。RFC 8725 §3.10 指出,收到的头部应当视为不可信的输入:
- 在映射表中查找
kid,不要拼进查询或路径。kid一旦进入 SQL、LDAP 或文件名,就成了注入点。本文中的 JWKS 库只会拿它去比对从你配置的 URL 获取的各个密钥的kid值,这才是正确的做法。 - 忽略
jku和x5u。两者都是令牌中的 URL。去获取它们,就等于让发送方决定你的服务器连接到哪里、使用哪些密钥。应当为每个颁发者单独配置 JWKS URL;RFC 8725 建议,如果确实必须使用这类 URL,就用允许列表来匹配它们。 - 切勿用访问令牌自身头部中内嵌的
jwk或x5c来验证它。用令牌自带的密钥签名的令牌,只能证明发送方持有某个密钥。确实使用内嵌密钥的协议(例如 DPoP 证明)会把它绑定到别的东西上(访问令牌的cnf声明)。参见 DPoP 详解。 - 显式处理缺少
kid的情况。当密钥集中有多个密钥而令牌没有指明任何一个时,jwks-rsa 拒绝猜测(No KID specified and JWKS endpoint returned more than 1 key);手写的查找逻辑如果返回“第一个密钥”,在密钥轮换之后就会悄无声息地选错密钥。
错误 4:不检查 aud 和 iss,或检查得太宽松
身份提供商通常用同一个密钥为许多客户端和 API 的令牌签名。如果你的 API 不检查 aud,那么同一提供商为其他任何应用签发的令牌在你的 API 上同样可用,这就是表格的第一行。RFC 8725 §3.9 要求接收方拒绝受众缺失或受众不是自己的 JWT,RFC 9068 §4 要求 OAuth 访问令牌的 iss 必须精确匹配。
在实践中,有三个细节很重要:
- 精确的字符串比较。
aud和iss是区分大小写的字符串,https://id.example.com与https://id.example.com/并不相同。颁发者应从提供商的发现文档中复制,而不是从你手头碰巧有的某个令牌里复制。 aud可以是数组。只要其中任何一个元素匹配,库就会接受该令牌。按 RFC 7519 这是正确的,但这意味着面向["https://api.example.com", "https://admin.example.com"]的令牌在两个 API 上都能用。如果你的 API 只应接受专门为它签发的令牌,PyJWT 提供了options={"strict_aud": True};在其他库中,需要自己检查数组长度。- 密钥必须属于该颁发者。RFC 8725 §3.8:如果令牌带有
iss,验证它的密钥就必须是该颁发者的密钥。有多个颁发者时,为每个颁发者保留一个 JWKS 客户端,并依据配置来选择,绝不能仅凭令牌中未经验证的iss来选择。对于在所有租户之间共享同一个密钥集的多租户提供商(常见的例子是 Microsoft Entra ID),iss检查是把你的租户与其他所有租户区分开的唯一手段;OIDC ID 令牌验证一文对此有详细介绍。
错误 5:没有 exp 的令牌,以及过宽的容差
exp 存在时,库会检查它。不存在时,四个库都接受了该令牌。如果你的颁发者哪天漏掉了这个声明,或者有人找到了某条代码路径,能签发不含该声明的令牌,这些令牌就永不过期。要把 exp 设为必需:jose 中用 requiredClaims,PyJWT 中用 options={"require": [...]},golang-jwt 中用 jwt.WithExpirationRequired();jsonwebtoken 没有这样的选项,需要显式检查。
时钟偏差确实存在,但很小。RFC 7519 §4.1.4 允许“少量的容差,通常不超过几分钟”。对于通过 NTP 保持同步的服务器,30 到 60 秒就足够了。一小时的容差(常常是为了让不稳定的测试通过而加上的)会把每个令牌的有效期延长一小时,包括那些你本想让其失效的令牌。这个选项在 jose 和 jsonwebtoken 中是 clockTolerance(单位为秒),在 PyJWT 中是 leeway,在 golang-jwt 中是 jwt.WithLeeway。同样的容差也适用于 nbf。
如果一个“本应”有效的令牌验证失败,报的是过期或尚未生效的错误,先别动容差,而是比较服务器与颁发者的时钟。从挂起状态恢复、时钟仍停留在过去的容器或虚拟机,产生的正是下面输出中的 jwt not active 和 "nbf" claim timestamp check failed 错误。
错误 6:接受了错误类型的令牌
OpenID Connect 提供商用同一个密钥签名 ID 令牌和访问令牌,iss 往往相同,aud 有时还会重叠。只检查签名、颁发者和受众的 API,可能会把 ID 令牌当作访问令牌接受。RFC 8725 §3.11 建议显式标注类型,RFC 9068 则把它规定为 JWT 访问令牌的强制要求:typ 头部必须是 at+jwt(或 application/at+jwt),资源服务器必须拒绝其他任何值。如果你的提供商签发符合 RFC 9068 的令牌,就检查 typ;如果不是,就检查只有访问令牌才带的声明,例如 scope 或 client_id。
错误 7:该验证的地方只做了解码
每个库都有一个只解析令牌而不验证的函数:jsonwebtoken 中的 jwt.decode()、jose 中的 decodeJwt()、PyJWT 中的 jwt.decode(..., options={"verify_signature": False})、golang-jwt 中的 ParseUnverified。它们用于路由(读取 iss 来选择 JWKS 客户端)和调试。代码评审时,应把在请求路径上对它们的任何使用都视为可疑,并把基于其输出做出的任何决定(除了选择接下来调用哪个验证器)都视为 bug。
错误 8:每次请求都获取 JWKS,或者从不刷新
每次请求都获取密钥集,会给每次调用增加一次网络往返,还会让你的可用性受制于身份提供商的可用性。永久缓存则意味着第一次密钥轮换就会让所有请求失败,直到重启为止。正确的做法(下面的 JWKS 客户端都实现了)是基于时间的缓存,再加上在令牌指定了未知 kid 时刷新,并对这种刷新做速率限制,这样一连串带随机 kid 值的令牌就无法把你的 API 变成冲击提供商的负载生成器。
| 客户端 | 缓存时长(默认) | 未知 kid | 获取超时 |
|---|---|---|---|
jose createRemoteJWKSet | 10 分钟(cacheMaxAge) | 重新获取,每 30 s 最多一次(cooldownDuration) | 5 s |
| jwks-rsa 4.1.0 | 每个密钥 10 分钟,缓存 5 个密钥 | 每次未命中都重新获取;rateLimit 默认关闭(启用后每分钟 10 次) | 30 s |
PyJWT PyJWKClient | 300 s(lifespan) | 重新获取,每 30 s 最多一次(cooldown_duration) | 30 s |
keyfunc v3 NewDefault | 每小时在后台刷新 | 重新获取,每 5 分钟最多一次 | 1 分钟 |
这些值是本文开头所列版本中的默认值,读自各库的源代码。差异是可以测量的:20 个带随机 kid 值的令牌,在 jwks-rsa 默认配置下导致了 20 次密钥集获取,设置 rateLimit: true 时为 10 次(另外 10 个令牌立即报错 JwksRateLimitError),而 jose 只获取了一次。每个颁发者只创建一次客户端并复用它:在请求处理函数内部构造的 JWKS 客户端,每次的缓存都是空的。颁发者一侧如何完成轮换本身,参见零停机的 JWKS 密钥轮换。
正确的验证器:四个库的写法
下面四个验证器做的是同一件事:只允许 ES256,从颁发者的 JWKS URL 获取密钥,精确匹配 iss 和 aud,必须有 exp,要求 typ: at+jwt,容差 30 秒。每个验证器都用同样的八个令牌运行过。
import { createRemoteJWKSet, jwtVerify } from "jose";
// Created once per issuer and reused: caches the key set, re-fetches it
// when a token names an unknown kid (at most once per cooldown).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
cacheMaxAge: 600_000, // re-fetch at least every 10 minutes
cooldownDuration: 30_000, // at most one kid-miss re-fetch per 30 s
});
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["ES256"], // fixed by the verifier, never taken from the token
issuer: "https://id.example.com",
audience: "https://api.example.com",
typ: "at+jwt", // RFC 9068 access token, not an ID token
requiredClaims: ["exp", "sub"], // exp is only checked when present
clockTolerance: 30, // seconds
});
return payload;
}
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: process.env.JWKS_URL,
cache: true, // per-kid cache, 10 minutes by default
rateLimit: true, // off by default; caps JWKS fetches at 10 per minute
});
const getKey = (header, callback) =>
client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));
export function verifyAccessToken(token) {
const options = {
algorithms: ["ES256"],
issuer: "https://id.example.com",
audience: "https://api.example.com",
clockTolerance: 30,
complete: true, // also return the header, to check typ
};
return new Promise((resolve, reject) =>
jwt.verify(token, getKey, options, (err, decoded) => {
if (err) return reject(err);
if (decoded.header.typ !== "at+jwt") return reject(new Error("unexpected typ"));
if (typeof decoded.payload.exp !== "number") return reject(new Error("exp is required"));
resolve(decoded.payload);
}),
);
}
import os
import jwt
# One client per issuer, reused across requests: caches the key set and
# re-fetches on an unknown kid (at most once per cooldown).
jwks = jwt.PyJWKClient(os.environ["JWKS_URL"], lifespan=600)
def verify_access_token(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token)
decoded = jwt.decode_complete(
token,
signing_key,
algorithms=["ES256"], # required argument in PyJWT 2.x
issuer="https://id.example.com",
audience="https://api.example.com",
leeway=30,
options={"require": ["exp", "iss", "aud", "sub"]},
)
if decoded["header"].get("typ") != "at+jwt":
raise jwt.InvalidTokenError("unexpected typ")
return decoded["payload"]
package main
import (
"context"
"errors"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
jwks keyfunc.Keyfunc
parser *jwt.Parser
}
func NewVerifier(ctx context.Context, jwksURL string) (*Verifier, error) {
// Background refresh every hour; an unknown kid triggers at most
// one extra fetch per 5 minutes (keyfunc defaults).
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, err
}
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"ES256"}),
jwt.WithIssuer("https://id.example.com"),
jwt.WithAudience("https://api.example.com"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
return &Verifier{jwks: jwks, parser: parser}, nil
}
func (v *Verifier) VerifyAccessToken(raw string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
token, err := v.parser.ParseWithClaims(raw, claims, v.jwks.Keyfunc)
if err != nil {
return nil, err
}
if token.Header["typ"] != "at+jwt" {
return nil, errors.New("unexpected typ")
}
return claims, nil
}
测试令牌包括一个有效令牌,以及七个各自恰好违反一条规则的令牌。JWKS 在容器内部通过 HTTP 提供,每个库都通过自己的 JWKS 客户端获取它。
--- jose
ok ACCEPTED sub=user-42
aud for another API ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "aud" claim value
no exp ERR_JWT_CLAIM_VALIDATION_FAILED: missing required "exp" claim
expired 1 h ago ERR_JWT_EXPIRED: "exp" claim timestamp check failed
nbf in 1 h ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed
typ JWT (an ID token) ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "typ" JWT header value
unknown kid ERR_JWKS_NO_MATCHING_KEY: no applicable key found in the JSON Web Key Set
HS256 keyed with the public PEM ERR_JOSE_ALG_NOT_ALLOWED: "alg" (Algorithm) Header Parameter value not allowed
--- jsonwebtoken
ok ACCEPTED sub=user-42
aud for another API JsonWebTokenError: jwt audience invalid. expected: https://api.example.com
no exp Error: exp is required
expired 1 h ago TokenExpiredError: jwt expired
nbf in 1 h NotBeforeError: jwt not active
typ JWT (an ID token) Error: unexpected typ
unknown kid JsonWebTokenError: error in secret or public key callback: Unable to find a signing key that matches '2026-09-z'
HS256 keyed with the public PEM JsonWebTokenError: invalid algorithm
--- PyJWT
ok ACCEPTED sub=user-42
aud for another API InvalidAudienceError: Audience doesn't match
no exp MissingRequiredClaimError: Token is missing the "exp" claim
expired 1 h ago ExpiredSignatureError: Signature has expired
nbf in 1 h ImmatureSignatureError: The token is not yet valid (nbf)
typ JWT (an ID token) InvalidTokenError: unexpected typ
unknown kid PyJWKClientError: Unable to find a signing key that matches: "2026-09-z"
HS256 keyed with the public PEM InvalidAlgorithmError: The specified alg value is not allowed
--- golang-jwt v5 + keyfunc v3
ok ACCEPTED sub=user-42
aud for another API token has invalid claims: token has invalid audience
no exp token has invalid claims: token is missing required claim: exp claim is required
expired 1 h ago token has invalid claims: token is expired
nbf in 1 h token has invalid claims: token is not valid yet
typ JWT (an ID token) unexpected typ
unknown kid token is unverifiable: error while executing keyfunc: key not found "2026-09-z"
failed keyfunc: could not read JWK from storage
HS256 keyed with the public PEM token signature is invalid: signing method HS256 is invalid
上面这些库的报错消息大多在错误目录中有专门的页面,介绍原因和修复方法,例如 unexpected "aud" claim value、jwt not active 和 no applicable key found in the JSON Web Key Set。
检查清单
- 每个颁发者一份验证配置,在启动时构建:JWKS URL、颁发者、受众、算法列表。
- 算法:固定的允许列表,通常只有一个值。绝不从令牌推导,绝不包含
none。 - 密钥:只来自配置的 JWKS 或密钥文件;
kid用作映射表中的查找键;对访问令牌忽略jku、x5u和内嵌的jwk。 - 声明:必须有
exp;精确比较iss和aud;遵守nbf;容差为 30 到 60 秒。 - 令牌类型:检查
typ(RFC 9068 访问令牌为at+jwt),或检查另一个能区分访问令牌与 ID 令牌的声明。 - JWKS 缓存:一个客户端,重复使用;基于时间刷新;遇到未知
kid时刷新,并带速率限制。 - 请求路径上不使用未经验证的
decode。 - 测试:一个有效令牌,加上针对上述每条规则各一个令牌,每个都应验证失败。本文中的八个令牌可以作为起始集合。
参考资料
- RFC 7519(JSON Web Token),§4.1(注册声明)、§7.2(验证)
- RFC 7518(JSON Web Algorithms),§3.6(
none) - RFC 8725(JSON Web Token Best Current Practices),§2.1、§3.1、§3.8 至 §3.11
- draft-ietf-oauth-rfc8725bis,RFC 8725 的更新版
- RFC 9068(JWT Profile for OAuth 2.0 Access Tokens),§2.1 和 §4
- jose 文档:
jwtVerify、createRemoteJWKSet - jsonwebtoken 和 jwks-rsa 的 README
- PyJWT 文档:用法、
PyJWKClient - golang-jwt/jwt v5 和 keyfunc v3