用户反馈刚登录就 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-jose、golang-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 源,避免来回抖。