JWT 刚签发就报 token expired——其实是时钟偏移

JWT 校验时不时报 token 已过期,连刚签发的也中招。修法:NTP 同步时钟、JWT 校验加 leeway、之后再缩短 TTL。

用户反馈刚登录就 401。token 三秒钟前才签的,校验方咬定它过期了。问题几乎从来不在 token 本身——是签发 iat/exp 的 auth 服务跟校验 exp 的资源服务时钟对不上。nbf(not before)刚签发就被拒也是同一回事。修法:所有机器跑 NTP、校验器加一点 leeway,等时钟可靠了再把 token TTL 缩短。

常见原因

按踩坑频率排序。

1. 资源服务时钟快了

校验方机器快了 8 秒。在签发方时钟里还没到期的 token,在校验方看来已经过期。

怎么判断chronyc tracking 看 System time offset;或 timedatectl status 显示 System clock synchronized: no

2. 容器没跑 NTP

Docker 容器的时钟是宿主机内核的——物理机一般没事,但虚拟机和 kubelet 节点会漂;VM 暂停恢复后时钟会跳。

怎么判断:两个 pod 里执行 date -u,差几秒。

3. JWT 库默认 leeway 是 0

jsonwebtoken(node)、python-josegolang-jwt 默认 leeway 都是 0。网络上一百毫秒的偏差就能让 token 在 exp 边界偶发失败。

怎么判断:错误集中在 token 生命周期的最后一秒。

4. 签发方时钟略快,导致 nbf “在未来”

签发方时钟快了,校验方看来 nbf 还没到。

怎么判断:报错是 “token not yet valid” 或 NotBeforeError

5. Serverless 冷启动时时间没同步

有些 FaaS 运行时在冷启动时才懒同步时间。长时间空闲后第一次请求,可能要等 OS 追上来才正常。

怎么判断:错误高峰跟冷启动相关。

最短修复路径

Step 1: 先量化偏差

# 对照一个可信源
curl -s --head https://www.google.com | grep -i ^date
date -u

# 宿主机
timedatectl status
chronyc tracking

System time offset 超过 100 ms 就先修时钟。

Step 2: 所有机器跑 NTP

systemd 宿主机优先 systemd-timesyncd(简单)或 chrony(功能丰富)。

# Ubuntu/Debian 装 chrony
sudo apt install -y chrony
sudo systemctl enable --now chronyd
chronyc sources -v
chronyc tracking
# /etc/chrony/chrony.conf
pool time.google.com iburst
pool time.cloudflare.com iburst
makestep 1.0 3
rtcsync

Kubernetes 节点装 chrony 或 systemd-timesyncd,pod 自动继承。快照恢复后的 VM 手动 step 一次。

sudo chronyc makestep

Step 3: JWT 校验加 leeway

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',
});

Python(python-jose):

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)

5 秒是个安全默认。超过 30 秒就削弱时间约束太多了。

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 秒,就该告警。

预防

  • 每台机器跑 NTP(chrony),至少列两个 pool。
  • 每个 JWT 校验器都显式设 clockTolerance 为 5 秒。
  • VM 恢复钩子里跑 chronyc makestep,大维护窗也要。
  • 把时钟偏移当 SLO——节点偏移超过 100 ms 就告警。
  • 多 region 部署里同一 region 用同一 NTP 源,避免来回抖。

标签: #后端 #排查 #jwt