用户反馈刚登录就 401。token 三秒前才签的,校验方却咬定它过期了。问题几乎从来不在 token 本身——是签发 iat/exp 的 auth 服务和校验 exp 的资源服务时钟对不上。同样的偏移在 nbf(not before)和 iat(issued at)上表现为:刚签发就被拒。
最快的修法: 用 NTP(chrony)把校验方的时钟同步好,再给校验器加 5 秒的 clockTolerance/leeway。两步都做完,这类误判就消失了。下面是诊断流程和各语言库的具体代码。
先对上你的错误信息
同一个时钟偏移,不同库抛的信息不一样。先找到你的那条,再跳到对应修法。
| 错误信息 / 类名 | 库 | 含义 | 偏移方向 |
|---|---|---|---|
TokenExpiredError: jwt expired | jsonwebtoken(Node) | 校验方这边 exp < now | 校验方时钟快了 |
ERR_JWT_EXPIRED | jose(Node) | exp < now | 校验方时钟快了 |
NotBeforeError: jwt not active | jsonwebtoken(Node) | nbf > now | 校验方慢了,或签发方快了 |
ERR_JWT_CLAIM_VALIDATION_FAILED("nbf" claim timestamp check failed) | jose(Node) | nbf > now | 校验方慢 / 签发方快 |
ExpiredSignatureError | PyJWT / python-jose | exp < now | 校验方快 |
ImmatureSignatureError: The token is not yet valid (iat) | PyJWT | iat > now + leeway | 签发方快于校验方 |
ImmatureSignatureError: The token is not yet valid (nbf) | PyJWT | nbf > now | 校验方慢 / 签发方快 |
token has invalid claims: token is expired | golang-jwt/jwt/v5 | exp < now | 校验方快 |
token has invalid claims: token is not valid yet | golang-jwt/jwt/v5 | nbf > now | 校验方慢 / 签发方快 |
信息里带 “expired” 的,多半是校验方时钟快于签发方;带 “not yet valid” / “not active” 的,是校验方慢于签发方(或签发方走快了)。有一个 PyJWT 专属的坑:截至 2026 年 6 月,PyJWT 默认仍然会校验 iat(verify_iat: True),所以签发方一旦走快,刚签的 token 在校验方看来就像”在未来签发的”,PyJWT 会直接抛 The token is not yet valid (iat) 把它拒掉。同一个 leeway 参数就能盖住这种情况。
你属于哪一类?
按踩坑频率排序。
| 序号 | 原因 | 怎么判断 |
|---|---|---|
| 1 | 资源服务(校验方)时钟往前漂了 | chronyc tracking 的 System time 偏移很大,或 timedatectl status 显示 System clock synchronized: no |
| 2 | 容器/VM 没做时钟同步 | 两个 pod/主机里 date -u 差几秒;VM 暂停恢复或快照还原后偏移越来越大 |
| 3 | JWT 库默认 leeway 是 0 | 错误集中在 token 生命周期的最后一秒 |
| 4 | 签发方略快,nbf 被设成签发时间 | 刚登录就报 “not yet valid” / NotBeforeError / ImmatureSignatureError |
| 5 | Serverless 冷启动时间没同步 | 错误高峰跟长时间空闲后的冷启动相关 |
第 1、2、5 类是时钟问题——直接修时钟。第 3、4 类加 leeway 也能压住,但 leeway 只是创可贴;主机偏差超过几秒,根因还是时钟没同步。
最短修复路径
Step 1: 先量化偏差
# 对照一个可信的远程源(HTTP Date 头是 UTC)
curl -s --head https://www.google.com | grep -i ^date
date -u
# 宿主机
timedatectl status
chronyc tracking
健康的 chronyc tracking 长这样:
Reference ID : A9FEA97B (time.cloudflare.com)
Stratum : 3
System time : 0.000031 seconds slow of NTP time
Leap status : Normal
要看的就两行:System time 应该是几毫秒级别,Leap status 应该是 Normal(而不是 Not synchronised)。System time 一旦超过 100 ms 左右,先修时钟,别碰 JWT 代码——主机差了好几秒,再大的 leeway 也救不回来。
Step 2: 所有机器跑 NTP
systemd 宿主机用 systemd-timesyncd(简单的 SNTP 客户端)或 chrony(完整 NTP,更适合会暂停/恢复的 VM)。chrony 在时钟跳变后做 step 校正更稳,而这正是本问题的典型场景。
# Ubuntu/Debian 装 chrony
sudo apt install -y chrony
sudo systemctl enable --now chrony # Debian/Ubuntu 上服务名是 'chrony',RHEL/Fedora 上是 'chronyd'
chronyc sources -v
chronyc tracking
# /etc/chrony/chrony.conf(Debian/Ubuntu) 或 /etc/chrony.conf(RHEL)
pool time.google.com iburst
pool time.cloudflare.com iburst
makestep 1.0 3 # 前 3 次更新里若偏差 > 1s,直接 step(而非缓慢 slew)
rtcsync # 同步硬件 RTC
makestep 1.0 3 是本问题的关键一行:它让 chrony 在启动时直接跳到正确时间,而不是慢慢 slew,这样刚开机或刚恢复的主机几秒内就准了,而不是几分钟。
Kubernetes 里,NTP 跑在节点上,不在 pod 里——pod 读的是节点时钟,没有 per-pod NTP 可配。确保每个节点都在跑 chrony/systemd-timesyncd。快照还原后的 VM,手动 step 一次:
sudo chronyc makestep
Step 3: JWT 校验加 leeway
leeway 是规范本身认可的做法:RFC 7519 §4.1.4 写道,实现”可以(MAY)留一点小的 leeway,通常不超过几分钟,以应对时钟偏移”。5 秒是生产环境常见的默认值,足以吸收常见的网络和调度抖动。要小——30 秒是合理上限,再往上就明显削弱 exp 的时间约束了。
Node(jsonwebtoken):
import jwt from 'jsonwebtoken';
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 5, // iat/exp/nbf 容忍 5 秒
});
Node(jose,更现代):
import { jwtVerify } from 'jose';
const { payload } = await jwtVerify(token, key, {
clockTolerance: '5s', // 字符串('5s')或秒数(5)都行
});
Python(PyJWT——目前在维护的首选;leeway 是顶层参数,不在 options 里)。同一个 leeway 对 exp、nbf 以及默认开启的 iat 校验都生效,所以能同时止住 ExpiredSignatureError 和两种 ImmatureSignatureError:
import jwt # PyJWT(截至 2026 年 6 月为 2.13.0)
jwt.decode(token, key, algorithms=['RS256'], leeway=5)
# 失败时:ExpiredSignatureError(exp)/ ImmatureSignatureError(nbf 或 iat)
Python(python-jose,如果你已经在用——注意它基本处于无人维护状态,新代码优先选 PyJWT;这里 leeway 确实要放进 options):
from jose import jwt
jwt.decode(token, key, algorithms=['RS256'], options={'leeway': 5})
Go(golang-jwt/jwt/v5):
parser := jwt.NewParser(jwt.WithLeeway(5 * time.Second))
token, err := parser.Parse(tokenStr, keyFunc)
注意:clockTolerance/leeway 对 exp、nbf、iat 是对称生效的,所以一个设置同时修好 “expired” 和 “not yet valid” 两种变体。
Step 4: 时钟可靠后再缩短 TTL
NTP 上了、leeway 加了,就可以把 access token TTL 缩到 5-15 分钟,长线靠 refresh token。token 泄漏的影响面就被框死了。
// 10 分钟 access、30 天 refresh
const access = jwt.sign({ sub }, key, { algorithm: 'RS256', expiresIn: '10m' });
const refresh = jwt.sign({ sub, typ: 'refresh' }, key, { algorithm: 'RS256', expiresIn: '30d' });
Step 5: 监控偏移,防止它悄悄回来
校验方算 now() - iat,打到 metric。分布一漂,就说明哪里又出现新偏移了。
const ageSeconds = Math.floor(Date.now() / 1000) - payload.iat;
metrics.histogram('jwt_age_at_verify_seconds').record(ageSeconds);
如果 p99 出现负值(校验方时钟比签发方慢)且超过 2 秒就告警——token 年龄为负在物理上不可能,除非有偏移。
怎么确认修好了
- 校验方和签发方上
chronyc tracking都显示Leap status : Normal,System time在 50 ms 以内。 - 签发方和校验方上
date -u秒级一致。 - 签一个 token 立刻循环校验;加了 leeway 后,应该看到零条
jwt expired/not yet valid错误。
# 同机快速 sanity 循环(Node 示例包成脚本):期望 0 次失败
for i in $(seq 1 50); do node verify-fresh-token.js || echo "FAIL $i"; done
循环还是失败,就说明两台机器的时钟仍然对不上——回到 Step 1,在签发方和校验方两边都重新查一遍,别只查一台。
预防
- 每台机器都跑 NTP(
chrony),至少列两个 pool 做冗余。 - 每个 JWT 校验器都显式设
clockTolerance/leeway为 5 秒——绝不依赖库默认的 0。 - 把
chronyc makestep写进 VM 恢复钩子和大维护窗,让暂停过的主机立刻校正。 - 把时钟偏移当 SLO:任何节点偏移超过 100 ms 就告警。
- 多 region 部署里,同一 region 用同一 NTP 源,避免时钟在不同参考源之间来回抖。
常见问题
为什么只有一台服务器拒 token,其他都正常?
那台机器的时钟漂了——exp 错误是快了,nbf 错误是慢了。token 本身没问题;在那台拒绝的机器上跑 chronyc tracking,把它的 System time 偏移跟可信源对一下。
加 clockTolerance 有安全风险吗?
小值(5 秒,最多 ~30 秒)是标准做法,安全——它只是把有效窗口放宽这么几秒。大值(几分钟)确实会削弱 exp 保证,所以该修时钟就修时钟,别一味把 leeway 调大。
时钟同步了,但 token 快到期时还是偶发 jwt expired,为什么?
亚秒级偏移加上网络延迟,仍可能在 exp 边界恰好把 leeway 为 0 的校验器顶翻。这是第 3 类:把 clockTolerance/leeway 设成 5 秒就好了。
Serverless(Lambda / Cloud Run / Cloud Functions)会受影响吗? 会——有些运行时在冷启动时才懒同步时间,长时间空闲后第一次请求就可能看到偏移。函数里没法跑 NTP,所以靠校验器的小 leeway 加短 TTL 顶住;平台稍后会把时钟校正过来。
刚登录就报 nbf/not yet valid——是另一个原因吗?
同根异向:签发方时钟快于校验方,导致 token 的 nbf(通常设成签发时间)在校验方看来还在未来。同步时钟,同样的 5 秒 leeway 也能覆盖。
只有笔记本从睡眠唤醒后才坏——为什么?
Docker Desktop 的 Linux 虚拟机(任何 VM 也一样)会在宿主睡眠时漂移,唤醒后读到的还是旧时钟。重启虚拟机,或在容器所在宿主上手动 step 一次(sudo chronyc makestep,或直接重启 Docker Desktop),偏移就清掉了。这其实就是第 2 类(暂停过的 VM),只不过出现在开发机而非生产环境。
直接去掉 exp/nbf 校验让它消失行不行?
不行。关掉 claim 校验等于放弃 token 过期的意义。同步时钟、加小 leeway——这样既消症状又不丢安全控制。如果是签发方走快、专门顶翻 PyJWT 的 iat 校验,符合规范的修法也是加 leeway,而不是关校验。