JWT(JSON Web Token) 是一种紧凑的声明容器;身份系统通常把它签名后用作访问令牌。
能解码 payload 或验过签名,不等于认证成功;验证端还必须约束算法、密钥来源、签发者、受众、时间和令牌用途。
使用成熟的 JOSE 库和固定的验证策略,让短期访问令牌配合可撤销、可检测重放的刷新令牌。
是什么,为什么存在
JSON Web Token(JWT) 是一个以 JSON 对象表示声明的紧凑、URL 安全格式。 声明(claim) 是关于主体的一项名称和值,例如 sub 表示主体,iss 表示签发者。JWT 可以装在 JSON Web Signature(JWS)或 JSON Web Encryption(JWE)结构中;身份认证常见的是经过签名的 JWS 紧凑序列化。
JWT 只是令牌格式,不是登录协议,也不会自动完成身份认证。登录端仍要验证凭据或外部身份,签发令牌;资源服务器则依据自己的策略验证令牌,再建立请求身份。OAuth 2.0、OpenID Connect 或应用自己的协议决定令牌如何取得、放在哪里以及能做什么。
常见的签名 JWT 有三个用点号分隔的 Base64url 段:JOSE header、payload 和 signature。header 描述密码学操作,payload 携带声明,signature 保护前两段的完整性。Base64url 是编码,不是加密,所以拿到令牌的人通常能读出 header 和 payload。
JWT 适合签发者和验证者分离的系统:验证服务可持有公钥,不必拿到私钥,也不必为每次请求查询中心会话。这个性质常被称为“无状态”,但它不是免费收益。密钥发布、注销、权限变化和刷新令牌通常仍需要状态。
工作原理
一次安全的请求包含两个不同阶段。签发阶段把已确认的身份、目标 API 和短有效期写成声明,再使用受保护的密钥签名。验证阶段不能相信令牌自述的任何内容,直到密码学验证和应用策略全部通过。
验证顺序很重要。实现先限制令牌大小和结构,再解析 header;它只能从本地配置或受信任签发者的元数据中选择密钥。随后验证签名,解析 payload,并检查所需声明的类型和值。任何一步失败都应统一拒绝,不应带着部分可信的身份继续执行。
应用要为每一种令牌写出独立策略。至少要固定允许的算法、可信 iss、本 API 的 aud、必需的 sub,以及 exp 和 nbf 的时间规则。若同一签发者产生 ID token、访问令牌和其他 JWT,还要用不同的 typ、aud、声明组合或密钥,使各策略互斥。
常见注册声明的含义如下。RFC 7519 没有要求每个 JWT 都包含这些声明;具体协议必须决定哪些字段必需。
| 声明 | 含义 | 验证动作 |
|---|---|---|
iss | 签发者 | 与可信签发者精确比较,并把密钥绑定到该签发者 |
sub | 主体 | 检查类型,并在签发者范围内解释其身份 |
aud | 预期接收者 | 字符串或字符串数组中必须包含当前 API |
exp | 过期时间 | 当前时间必须早于该 NumericDate |
nbf | 生效时间 | 当前时间不得早于该 NumericDate |
iat | 签发时间 | 可用于策略和审计,但它本身不是过期检查 |
jti | 令牌标识 | 可用于重放检测或拒绝列表,但不会自动带来唯一性 |
认证和授权也必须分开。有效签名只说明持有对应密钥的一方签发了这些字节,并且字节未被改动。用户是否仍存在、角色是否仍有效、请求能否访问当前资源,属于应用的授权决策。
示例
读取令牌不等于验证令牌
下面的第一段代码构造并读取一个三段令牌。第三段故意不是有效签名;解码仍然成功,这正说明读取 payload 不能证明令牌可信。
const encode = (value) =>
Buffer.from(JSON.stringify(value)).toString('base64url');
const header = { alg: 'HS256', typ: 'at+jwt' };
const payload = {
iss: 'https://issuer.example',
sub: 'user-42',
aud: 'inventory-api',
role: 'editor'
};
const fakeSignature = Buffer.from('not-a-signature').toString('base64url');
const token = `${encode(header)}.${encode(payload)}.${fakeSignature}`;
const [headerPart, payloadPart] = token.split('.');
const decodedHeader = JSON.parse(Buffer.from(headerPart, 'base64url'));
const decodedPayload = JSON.parse(Buffer.from(payloadPart, 'base64url'));
console.log(`segments: ${token.split('.').length}`);
console.log(`algorithm: ${decodedHeader.alg}`);
console.log(`subject: ${decodedPayload.sub}`);
console.log(`role: ${decodedPayload.role}`);
console.log('signature checked: no');segments: 3
algorithm: HS256
subject: user-42
role: editor
signature checked: noBuffer.from(segment, 'base64url') 只撤销 Base64url 编码。攻击者可以自行构造同样的 JSON,甚至把 role 改成 admin。只有用受信任密钥验证完整签名,并检查上下文声明后,程序才能使用 payload 建立身份。
在日志、错误报告或浏览器调试器里解码令牌很方便,但要把结果标为“不可信”。诊断工具不应把“格式可解析”显示成“有效”,更不能把完整 bearer token 写入日志。
签发并验证一个 HS256 令牌
下例用 Node.js 内置 crypto 展示签名输入与验证策略。它固定时间和测试密钥以产生可重复输出,只用于解释机制;生产边界应使用经过维护的 JOSE 库,并从密钥管理系统加载密钥。
import { createHmac, timingSafeEqual } from 'node:crypto';
const secret = Buffer.from('8f'.repeat(32), 'hex');
const now = 1_800_000_000;
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const mac = (input) => createHmac('sha256', secret).update(input).digest('base64url');
function issue(claims) {
const header = encode({ alg: 'HS256', typ: 'at+jwt' });
const payload = encode(claims);
return `${header}.${payload}.${mac(`${header}.${payload}`)}`;
}
function verify(token, policy) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('malformed token');
const [headerPart, payloadPart, signaturePart] = parts;
const header = JSON.parse(Buffer.from(headerPart, 'base64url'));
if (header.alg !== 'HS256' || header.typ !== 'at+jwt') throw new Error('wrong header');
const actual = Buffer.from(signaturePart, 'base64url');
const expected = Buffer.from(mac(`${headerPart}.${payloadPart}`), 'base64url');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
throw new Error('bad signature');
const claims = JSON.parse(Buffer.from(payloadPart, 'base64url'));
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (claims.iss !== policy.issuer || !audiences.includes(policy.audience))
throw new Error('wrong issuer or audience');
if (!Number.isFinite(claims.exp) || policy.now >= claims.exp) throw new Error('expired');
if (Number.isFinite(claims.nbf) && policy.now < claims.nbf) throw new Error('not active');
return claims;
}
const token = issue({ iss: 'https://issuer.example', sub: 'user-42',
aud: 'inventory-api', iat: now, nbf: now, exp: now + 300 });
const claims = verify(token, {
issuer: 'https://issuer.example', audience: 'inventory-api', now
});
console.log(`segments: ${token.split('.').length}`);
console.log(`subject: ${claims.sub}`);
console.log(`valid for seconds: ${claims.exp - now}`);segments: 3
subject: user-42
valid for seconds: 300HMAC 使用同一个秘密完成签名和验证,因此每个验证者也都有签发能力。它适合信任边界一致的组件。若多个资源服务器只应验证、不应签发,可以使用非对称算法:签发者保管私钥,验证者只取得公钥。
算法不能由令牌自由决定。验证器可以读取 alg 来确认它等于策略允许值,却不能因为令牌写了某种算法就启用该算法。每把密钥也应固定用途和算法,避免把一种算法的密钥材料误用于另一种算法。
示例没有实现 JSON 重复键检测、输入大小限制、完整 JOSE 解析、密钥轮换或时钟偏差策略。这些边界条件正是生产代码应该交给成熟库的原因。库只负责实现原语,调用方仍要显式传入 issuer、audience、算法和必需声明。
用失败用例固定声明策略
密码学库通常允许调用方选择要验证的声明,但应用仍要定义精确策略。下面的函数接收已经通过签名验证的 claims,并分别测试正确上下文、错误受众和过期令牌。把这些拒绝用例放进测试,可以防止重构时意外删掉一个验证选项。
function enforcePolicy(claims, policy) {
if (typeof claims.sub !== 'string' || claims.sub.length === 0)
throw new Error('missing subject');
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (claims.iss !== policy.issuer) throw new Error('wrong issuer');
if (!audiences.includes(policy.audience)) throw new Error('wrong audience');
if (!Number.isFinite(claims.exp) || policy.now >= claims.exp)
throw new Error('expired');
return claims.sub;
}
const policy = {
issuer: 'https://issuer.example',
audience: 'inventory-api',
now: 1_800_000_000
};
const base = {
iss: policy.issuer, sub: 'user-42', aud: policy.audience, exp: policy.now + 60
};
const cases = [
['accepted', base],
['other API', { ...base, aud: 'billing-api' }],
['old token', { ...base, exp: policy.now }]
];
for (const [name, claims] of cases) {
try {
console.log(`${name}: ${enforcePolicy(claims, policy)}`);
} catch (error) {
console.log(`${name}: ${error.message}`);
}
}accepted: user-42
other API: wrong audience
old token: expired这里把 policy.now 作为输入,而不是在函数深处调用系统时钟,因此边界测试可重复。生产验证器可以注入时钟或使用库的时钟选项,但不能把固定测试时间带进真实请求路径。
这一步仍不是授权。sub 通过策略后只形成一个已认证主体;端点还要根据当前资源、操作和服务端权限数据作出允许或拒绝决定。对权限变化敏感的操作不应只依赖长期存在的 role claim。
轮换可撤销的刷新令牌
访问令牌(access token) 应只发给目标资源服务器,并保持较短生命周期。 刷新令牌(refresh token) 只发送给授权服务器,用来取得新的访问令牌;它不必是 JWT,使用不可预测的 opaque value 往往更容易撤销。
下面的内存存储展示轮换族的核心状态。示例字符串是固定测试夹具;真实刷新令牌必须由密码学安全随机源产生,只把摘要存入数据库,并在一次原子事务中完成消费旧令牌和写入新令牌。
import { createHash } from 'node:crypto';
const digest = (token) => createHash('sha256').update(token).digest('hex');
class RefreshTokenStore {
#records = new Map();
issue(family, token) {
this.#records.set(digest(token), { family, active: true });
}
rotate(oldToken, newToken) {
const record = this.#records.get(digest(oldToken));
if (!record || !record.active) {
if (record) this.revokeFamily(record.family);
throw new Error('refresh token reuse detected');
}
record.active = false;
this.issue(record.family, newToken);
}
revokeFamily(family) {
for (const record of this.#records.values()) {
if (record.family === family) record.active = false;
}
}
isActive(token) {
return this.#records.get(digest(token))?.active === true;
}
}
const store = new RefreshTokenStore();
store.issue('grant-7', 'fixture-token-a');
store.rotate('fixture-token-a', 'fixture-token-b');
console.log(`rotated: ${store.isActive('fixture-token-b')}`);
try {
store.rotate('fixture-token-a', 'attacker-token');
} catch (error) {
console.log(`replay: ${error.message}`);
}
console.log(`family active: ${store.isActive('fixture-token-b')}`);rotated: true
replay: refresh token reuse detected
family active: false轮换后,旧值再次出现意味着旧值或新值所在一方可能遭到泄露。服务器无法仅凭重放判断攻击者是谁,因此撤销整个令牌族,并要求重新授权。数据表要保留旧摘要和族关系,直到重放检测窗口结束;立即删除旧记录会失去检测依据。
并发刷新需要明确语义。若两个合法请求同时消费同一个刷新令牌,朴素的“先查再写”会签发两组后继令牌,或把第二个请求误判为攻击。数据库条件更新、行锁或单写者事务必须保证旧令牌只成功消费一次,客户端也应合并并发刷新。
刷新令牌延长了会话能力,因此必须绑定客户端和授权范围,在传输与存储时保密,并支持到期和注销。对于公共 OAuth 客户端,RFC 9700 要求使用发送者约束或刷新令牌轮换来检测重放。
陷阱
修复方法: 让一个验证入口完成结构限制、签名和声明策略,只有该入口返回的类型才能进入授权逻辑。诊断性解码函数要使用带有 untrusted 的名称,并禁止其结果流入身份上下文。
修复方法: 算法允许列表来自服务端配置,kid 只能在与可信 issuer 绑定的有限密钥集合中查找,未知值立即失败。不要跟随任意 jku 或 x5u;确需远程 JWKS 时,只访问预配置的 HTTPS 地址,并实施缓存和刷新限制。
修复方法: 精确验证 iss,要求 aud 包含当前服务,并为访问令牌设置明确的 typ 或互斥声明规则。多租户系统还要从可信 issuer 与 tenant 映射确定租户,不能只相信任意 tenant_id 声明。
修复方法: 通过 TLS,在 Authorization: Bearer header 或经过完整 CSRF 设计的安全 cookie 中传输。日志只记录不可逆指纹或 jti 等非秘密关联值,并在网关、应用和追踪系统各层做字段清洗。
修复方法: 存储方式要配合威胁模型。Cookie 使用 Secure、HttpOnly、合适的 SameSite、明确的 Path 和 CSRF 防护;脚本可读存储要接受令牌可被 XSS 窃取的事实。无论放在哪里,都要部署内容安全策略、输出编码和短期令牌。
修复方法: 让访问令牌保持短期,并根据风险选择拒绝列表、用户会话版本、内省或发送者约束。注销时撤销刷新令牌及其令牌族;密码修改、账号停用等安全事件也要触发相同策略。
密码学验证与策略验证
密码学验证回答“这些字节是否由对应密钥保护,而且未被修改”。策略验证回答“当前服务是否应在此时、为此用途接受这些声明”。前一个答案为真,后一个仍可能为假;错误受众、错误签发者、错误 token 类型和已停用用户都是例子。
验证器应先应用廉价的输入边界,例如 HTTP header 大小、紧凑序列化段数和允许的字符。随后解析最少量的 JOSE header,以便在可信密钥集合中选择候选项。header 本身仍不可信,所以选择结果只能缩小预配置集合,不能扩展信任来源。
签名成功后,解析器仍要拒绝不符合所用协议配置的 JSON 和声明类型。aud 既可以是单个字符串,也可以是字符串数组;exp、nbf 和 iat 使用 NumericDate,也就是从 Unix epoch 起算的秒数。若允许时钟偏差,应由验证策略明确设定并保持尽可能小,不能让每个调用点随意选择。
应用还要防止跨 JWT 混淆。若同一个 issuer 为多个协议签发结构相近的 JWT,仅靠有效签名无法证明当前拿到的是 access token。不同 typ、不同 aud、不同必需声明或不同密钥都可以形成互斥规则;新设计应优先给令牌显式类型。
密钥选择与轮换
HS256 等 HMAC 算法共享一个秘密。秘密必须具有足够熵,并通过密钥管理系统分发;把密码、仓库常量或默认环境变量当密钥都会削弱签名。任何能验证 HMAC 的服务也能签发 HMAC,因此共享范围就是签发信任范围。
RS256、PS256 或 ES256 等非对称方案把签发私钥与验证公钥分开。算法选择取决于协议配置、平台支持和密码学策略,不应根据未经验证的 token 临时决定。非对称并不自动更安全:私钥保护、参数验证、库版本和随机数质量仍然决定系统安全。
轮换期间,签发者用新 key 签发,同时在旧 token 最长可接受期内发布旧公钥。kid 只负责在该 issuer 已批准的 key set 中定位密钥,不是全局可信名称。验证器缓存 JWKS 时要处理正常轮换,但不能让每个未知 kid 都触发无上限远程刷新,否则攻击者可制造网络和计算压力。
移除旧验证密钥前,要确认所有依赖它的 token 都已过期或被撤销。紧急泄露处理可能要求提前移除,此时仍持有旧 token 的合法请求也会失败。轮换方案必须把这种可用性代价写进运行手册,而不是只保留一个“current key”环境变量。
撤销带回必要状态
自包含访问令牌减少了正常请求的中心查询,但也把授权状态复制进了 token。用户角色变化后,旧角色声明不会自动更新;密钥泄露后,旧签名也不会自动失效。有效期限制了陈旧窗口,却不能提供即时撤销。
拒绝列表按 jti 或 token 指纹记录撤销项,通常只需保留到 token 过期。会话版本把服务端版本写入 token,每次验证查询当前版本,能一次撤销某用户的多个 token。内省让资源服务器询问授权服务器令牌是否有效,提供更及时的控制,但恢复了网络依赖和运行时状态。
选择取决于撤销延迟目标、请求量、故障模式和账号风险,而不是“JWT 必须无状态”的口号。高风险操作还可以重新认证,或查询最新权限,而不是完全依赖较早签发的角色声明。设计文档应明确在账号停用、权限下降、设备丢失和密钥泄露时,最晚多久停止接受旧 token。
浏览器会话常把短期 access token 与长期 refresh token 组合起来。这样只缩短了 access token 泄露后的自然有效窗口;真正的会话控制来自 refresh token 的服务端记录、轮换、重放检测和撤销。刷新端点因此是身份系统的高价值边界,需要限速、审计和事务一致性。
认证结果的边界
验证结果应是窄而明确的应用类型,而不是原始 payload 字典。例如,它可以只暴露规范化后的 subject、issuer、audiences 和令牌标识,并保留审计需要的验证元数据。下游代码因此不容易把未经识别的私有 claim 当作权限。
身份中间件通常用 401 Unauthorized 表示没有可接受的认证凭据,用 403 Forbidden 表示身份已确认但权限不足。错误响应不应泄露签名比较、密钥存在性等细节;内部日志可以记录分类后的失败原因,但不能记录完整 token。
如果网关验证 JWT 后把身份通过普通 header 传给后端,后端必须只接受由可信网关覆盖或签名的 header,并阻止客户端绕过网关。否则攻击者可以直接发送同名 header,绕开所有 JWT 验证。这一信任跳转需要像 token 本身一样明确记录和测试。
延伸阅读
3个问题 · 1 道输出预测题 · 0 道找错题