JWT 是否过期由 exp 声明决定,它是一个以秒为单位的 Unix 时间戳。先解码 payload,把 exp 转成可读时间,再和服务端当前时间比较;同时检查 nbf、iat、签名算法和密钥。解码不等于验签,未验签的声明不能用于鉴权。
- exp、nbf、iat 都是秒级 Unix 时间戳,不是毫秒。
- Token 被拒绝可能是因为过期、尚未生效、签名错误或算法不匹配。
- 解码只能看到声明,不能证明 Token 真实可信。
- 分布式系统应允许 30 到 120 秒的时钟偏移。
- 不要把完整 Token 写进日志,只记录指纹或必要声明。
JWT 报错看起来只有一个,但根因通常落在四个地方:Token 真的过期、Token 还没生效、签名不匹配、算法不符合预期。修错层不仅浪费时间,还可能削弱安全性。
这篇文章先讲时间声明,再讲如何安全地查看 Token,最后按常见错误逐项排查。
三个时间声明
JWT 的 payload 是一个 JSON 对象。注册的时间声明有:
| 声明 | 含义 | 单位 |
|---|---|---|
iat | 签发时间 | Unix 秒 |
nbf | 生效时间 | Unix 秒 |
exp | 过期时间 | Unix 秒 |
Token 被接受的条件是:
当前时间 >= nbf(如果存在)
当前时间 < exp(如果存在)
这里最关键的是单位:秒,不是毫秒。exp: 1761500000000 大约对应公元 57770 年,exp: 1761500000 才是正常的人类时间。把秒和毫秒搞混,是“刚生成就过期”和“永远不过期”这两类问题的头号原因。
RFC 7519 把 NumericDate 定义为从 1970-01-01T00:00:00Z 起经过的秒数,忽略闰秒,并允许小数。多数库按数字直接比较,所以 Date.now() / 1000 不取整也能用;但日志里最好取整,不然时间差读起来很别扭。三个声明都是可选的:没有 exp 的 Token 永不过期,主流库也照样验签通过。access token 的校验端应该强制要求 exp,而不是指望每个签发方都记得加。
先解码,再验签
JWT 的 header 和 payload 只是 Base64URL 编码,并没有加密。没有密钥也能读:
function decodePart(part) {
const padded = part.replace(/-/g, "+").replace(/_/g, "/")
.padEnd(Math.ceil(part.length / 4) * 4, "=");
return JSON.parse(atob(padded));
}
const [headerPart, payloadPart] = token.split(".");
console.log(decodePart(headerPart));
console.log(decodePart(payloadPart));
快速查看可以直接用 JWT 解析工具。把 exp 和当前时间对比,可以用 Unix 时间戳转换工具。解析工具应该明确提示“未验签”。
不要用“解码成功”作为鉴权依据。在验签之前,payload 完全由攻击者控制。
解码本身还有两个坑。JWT 片段是无填充的 Base64URL,长度不是 4 的倍数时直接调用 atob 会抛 Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded;上面的辅助函数先补了 =,所以能正常工作。另一个更隐蔽:atob 返回二进制字符串,每个字符代表一个字节,直接 JSON.parse 时,中文昵称之类的非 ASCII 字段会变成乱码。平时只调 exp、alg 这些 ASCII 声明所以看不出来,真正读取用户姓名时才会踩到。需要原文时,先把二进制字符串转成字节,再用 TextDecoder 按 UTF-8 还原。
错误一:jwt expired
Token 的 exp 已经早于校验方的当前时间。
排查步骤:
- 解码 payload,读取
exp。 - 把
exp转成可读日期。 - 用服务端时间比较,不要只用本机时间。
- 检查签发方使用的是秒还是毫秒。
- 检查 access token 的有效期是否符合客户端预期。
const payload = decodePart(token.split(".")[1]);
const now = Math.floor(Date.now() / 1000);
console.log({
exp: payload.exp,
expDate: new Date(payload.exp * 1000).toISOString(),
now,
nowDate: new Date(now * 1000).toISOString(),
secondsRemaining: payload.exp - now
});
如果 Token 确实过期了,正确做法通常是完善 refresh 流程,而不是把有效期无限延长。长期有效的 access token 一旦泄露,影响面更大。
不同库的报错形状能告诉你卡在哪一层。jsonwebtoken 抛 TokenExpiredError,带 expiredAt 日期;jose 抛 JWTExpired,code 是 ERR_JWT_EXPIRED;OAuth 资源服务器通常返回 401 和 WWW-Authenticate: Bearer error="invalid_token",这是 HTTP 错误,不是解析异常。
不用登录服务器也能对时:curl -sI https://api.example.com/health 返回的 Date 头由服务端生成,如果比你本机快几分钟,多半是校验方时钟有问题,Token 本身没坏。位数也能快速判断单位:当前秒级时间戳是 10 位(一直用到 2286 年),毫秒级是 13 位。看到 13 位的 exp,基本可以确定签发代码忘了除以 1000。
错误二:jwt not active 或 TokenNotYetValid
nbf 在未来,或者 iat 晚于校验方当前时间。常见原因:
- 签发方时钟比校验方快。
- Token 被设置为未来某个时间才生效。
- 测试数据使用了硬编码的未来日期。
比较 nbf、iat 和当前时间。如果只差几秒,应该在校验库中配置一个小的时钟容差,而不是删掉声明。
边界是半开区间:当前时间正好等于 nbf 时有效,正好等于 exp 时无效。有的库用 > 比较,差一秒的差别在任何容差面前都没有意义,真正要关注的是分钟级偏差。
另一个典型场景是虚拟机从快照恢复,或者宿主机时钟源异常。某台 Pod 快 5 分钟,它签发的 Token 在集群其他节点看来 5 分钟内全部“尚未生效”,然后故障自己消失。这种自愈模式基本可以断定是时钟漂移,而不是代码缺陷。
错误三:invalid signature
签名校验失败。可能是 Token 过期后被重新签发、使用了不同的密钥、签发方用非对称密钥而校验方期待共享密钥,或者 Token 在传输中被修改。
先看 header:
{
"alg": "HS256",
"typ": "JWT"
}
然后确认:
- 校验方和签发方使用相同算法。
- 密钥或公钥与环境匹配(开发环境和生产环境不能混用)。
- Token 没有被代理或日志系统截断。
- 密钥轮换后校验方已经更新。
不要通过关闭验签来“修复”签名错误。那等于把安全控制变成摆设。
签名失败有很多是环境问题。用 JWT_SECRET=$(cat secret.txt) 读密钥会把文件末尾的换行一起带进来,签发方如果没带换行,HMAC 输入就差一个字节,所有签名都对不上。两边都打印密钥的字节长度即可确认。密钥轮换也是高频原因:签发方新增密钥后,Token 会带 kid 头;校验方如果忽略 kid、永远用第一把密钥,新密钥签发的 Token 全部失败。正确做法是从 JWKS 端点取密钥,按 kid 选择,遇到未知 kid 时刷新缓存,并限制刷新频率,避免伪造 kid 引发请求风暴。
对称签名调试时还要记住,签名输入是 base64url(header) + "." + base64url(payload) 的原始字节,不是解码后的 JSON。重新序列化 payload 会改变空白或键顺序,声明看起来一样,签名却不同。
错误四:invalid algorithm 或算法混淆
alg 告诉校验方使用哪种算法。接受 Token 自己声明的任意算法非常危险。校验方应该设置算法白名单:
jwt.verify(token, key, { algorithms: ["RS256"] });
这样可以防止攻击者把 alg 改成 none,或者在对称和非对称算法之间切换。RFC 8725 也明确建议使用算法白名单并校验密钥类型。
还有一种情况:拿到的根本不是 JWT。JWE 由五段组成,第二段是加密密钥,不是 JSON payload,按 JWT 的方式解码会在 Base64URL 或 JSON 解析阶段失败。解码前先数一下段数。非对称算法还必须和密钥类型匹配:RSA 公钥验不了 ES256,EC 密钥也验不了 RS256。JWKS 里常常有多把密钥,只按 kid 选还不够,应该同时匹配 alg,否则类型不匹配会被报成签名错误,把你引向不存在的密钥问题。
时钟偏移
分布式系统很难做到时钟完全一致。允许一个小的容差:
jwt.verify(token, key, {
algorithms: ["RS256"],
clockTolerance: 60
});
60 秒是一个合理起点。如果经常需要几分钟的容差,应该去修时钟同步,而不是继续放大容差。容差越大,过期 Token 被接受的窗口越长。
容差应该测量后再定。Linux 上用 timedatectl 或 chronyc tracking 看当前偏移和 NTP 是否同步;正常的云主机偏移在毫秒级,NTP 被封锁的机器可能每天漂移数秒。按观察到的最大值来设,不要照抄一个整数。安全成本也要算清楚:120 秒容差意味着泄露的 Token 在 exp 之后还能多用两分钟,5 分钟有效期的 Token 实际上 7 分钟内都可用。容差相对有效期不能太大,偏移开始增长时应该告警。
Access Token 与 Refresh Token
常见设计是:
- 短期 access token,通常 5 到 15 分钟。
- 长期 refresh token,通常几天到几周。
- refresh 接口校验 refresh token 后签发新的 access token。
access token 过期时,客户端应该刷新,而不是强制重新登录。refresh token 需要更小心地存储、轮换,并支持撤销。access token 过期不是安全事故;永久有效的 refresh token 才是更大的风险。
并发场景下刷新流程有一种典型故障:多个标签页或并行请求同时发现过期,一起调用 refresh 接口;轮换机制在第一次成功后作废了 refresh token,其余请求拿到 invalid_grant,用户被登出。客户端应该把刷新合并成同一个 in-flight Promise,或者在有效期到 80% 左右主动刷新,降低碰撞概率。服务端做轮换时最好加复用检测:只保存当前 refresh token 的哈希,兑换后标记已用,旧 token 再次出现就撤销整个 token family。这样被盗的 refresh token 会从“永久访问权”变成可检测事件。
JWT 也不是所有场景都合适。服务端 session 可以立即撤销,不需要黑名单;自包含 JWT 在 exp 之前一直有效,除非额外维护撤销列表。如果即时撤销是硬需求,要么把 access token 做得非常短,要么改用配合会话存储的不透明 token。
实用排查清单
- 确认 Token 由三段组成。
- 解码 header 和 payload,但不验签。
- 读取
alg、exp、iat、nbf。 - 转成日期,和服务端时间比较。
- 确认预期算法和密钥。
- 用生产环境相同的库和配置验签。
- 检查代理、网关或日志是否修改了 Token。
- 用最小请求复现问题。
- 数一下段数:三段是 JWT,五段是 JWE,处理方式完全不同。
- 用签发方的
Date响应头和iat对比校验方时钟。
调试对称签名时,可以用 JWT 签发工具 生成已知声明的测试 Token,用 HMAC 工具 查看签名输入。测试密钥不要进入生产环境,也不要写进日志。
不要做的事
- 不要把密钥放进 JWT payload。
- 不要在生产环境使用
alg: none。 - 不要因为能解码就信任 Token。
- 不要用过长的有效期掩盖 refresh 逻辑的问题。
- 不要记录完整 Token;记录短指纹和必要声明即可。
- 能用
HttpOnly、Secure、SameSite的 Cookie 承载会话时,不要把长期 Token 放进localStorage,任何 XSS 都能读走它。 - 不要把
typ头当作安全控制。它是可选的,经常缺失,也能被随意修改。
过期既是可靠性问题,也是安全边界。把时间戳当作需要检查的数据,把签名当作信任这些数据的唯一理由。
常见问题
exp、iat、nbf 有什么区别?
exp 是过期时间,iat 是签发时间,nbf 是生效时间。三者都是以 1970-01-01T00:00:00Z 为起点的秒级 Unix 时间戳。只有当前时间在 nbf 之后、exp 之前时,Token 才有效。
为什么刚生成的 JWT 就显示过期?
最常见的原因是用毫秒而不是秒设置 exp、有效期设置得太短,或者服务端时钟比客户端快。解码 payload,把 exp 和当前秒级时间戳比较即可确认。
解码 JWT 后可以直接相信里面的声明吗?
不可以。解码只是把 header 和 payload 做 Base64URL 解码,任何人都能修改 payload 后重新编码。必须先使用正确的密钥和算法验签,才能把声明用于鉴权。
时钟偏移应该允许多少?
分布式系统通常允许 30 到 120 秒。选择能覆盖实际时钟漂移的最小值,并监控漂移情况,而不是无限放大容差。
JWT 过期是否意味着用户必须重新登录?
不一定。很多系统使用短期 access token 加长期 refresh token。access token 过期后,只要 refresh token 仍然有效且未被撤销,就可以换取新的 access token,而不必强制用户重新登录。