JWT 刚签发就报 jwt expired:修时钟偏移

JWT 校验报 jwt expired 或 token not yet valid,连刚签发的也中招。修法:NTP 同步时钟、校验器加 5 秒 leeway。

用户反馈刚登录就 401。token 三秒前才签的,校验方却咬定它过期了。问题几乎从来不在 token 本身——是签发 iat/exp 的 auth 服务和校验 exp 的资源服务时钟对不上。同样的偏移在 nbf(not before)和 iat(issued at)上表现为:刚签发就被拒。

最快的修法: 用 NTP(chrony)把校验方的时钟同步好,再给校验器加 5 秒的 clockTolerance/leeway。两步都做完,这类误判就消失了。下面是诊断流程和各语言库的具体代码。

先对上你的错误信息

同一个时钟偏移,不同库抛的信息不一样。先找到你的那条,再跳到对应修法。

错误信息 / 类名含义偏移方向
TokenExpiredError: jwt expiredjsonwebtoken(Node)校验方这边 exp < now校验方时钟快了
ERR_JWT_EXPIREDjose(Node)exp < now校验方时钟快了
NotBeforeError: jwt not activejsonwebtoken(Node)nbf > now校验方慢了,或签发方快了
ERR_JWT_CLAIM_VALIDATION_FAILED"nbf" claim timestamp check failedjose(Node)nbf > now校验方慢 / 签发方快
ExpiredSignatureErrorPyJWT / python-joseexp < now校验方快
ImmatureSignatureError: The token is not yet valid (iat)PyJWTiat > now + leeway签发方快于校验方
ImmatureSignatureError: The token is not yet valid (nbf)PyJWTnbf > now校验方慢 / 签发方快
token has invalid claims: token is expiredgolang-jwt/jwt/v5exp < now校验方快
token has invalid claims: token is not valid yetgolang-jwt/jwt/v5nbf > now校验方慢 / 签发方快

信息里带 “expired” 的,多半是校验方时钟于签发方;带 “not yet valid” / “not active” 的,是校验方于签发方(或签发方走快了)。有一个 PyJWT 专属的坑:截至 2026 年 6 月,PyJWT 默认仍然会校验 iatverify_iat: True),所以签发方一旦走快,刚签的 token 在校验方看来就像”在未来签发的”,PyJWT 会直接抛 The token is not yet valid (iat) 把它拒掉。同一个 leeway 参数就能盖住这种情况。

你属于哪一类?

按踩坑频率排序。

序号原因怎么判断
1资源服务(校验方)时钟往前漂了chronyc trackingSystem time 偏移很大,或 timedatectl status 显示 System clock synchronized: no
2容器/VM 没做时钟同步两个 pod/主机里 date -u 差几秒;VM 暂停恢复或快照还原后偏移越来越大
3JWT 库默认 leeway 是 0错误集中在 token 生命周期的最后一秒
4签发方略快,nbf 被设成签发时间刚登录就报 “not yet valid” / NotBeforeError / ImmatureSignatureError
5Serverless 冷启动时间没同步错误高峰跟长时间空闲后的冷启动相关

第 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 里)。同一个 leewayexpnbf 以及默认开启的 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/leewayexpnbfiat 是对称生效的,所以一个设置同时修好 “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 年龄为负在物理上不可能,除非有偏移。

怎么确认修好了

  1. 校验方和签发方上 chronyc tracking 都显示 Leap status : NormalSystem time 在 50 ms 以内。
  2. 签发方和校验方上 date -u 秒级一致。
  3. 签一个 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,而不是关校验。

标签: #后端 #排查 #jwt