# JWT 过期怎么排查：exp、iat、nbf 时间戳完全指南

> 理解 JWT 的 exp、iat、nbf 时间声明、秒与毫秒的区别和时钟偏移，快速定位 jwt expired、签名错误和算法不匹配。

**快速答案:** 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 被接受的条件是：

```text
当前时间 >= 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 编码，并没有加密。没有密钥也能读：

```js
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 解析工具](/tools/jwt-decoder)。把 `exp` 和当前时间对比，可以用 [Unix 时间戳转换工具](/tools/unix-timestamp)。解析工具应该明确提示“未验签”。

不要用“解码成功”作为鉴权依据。在验签之前，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` 已经早于校验方的当前时间。

排查步骤：

1. 解码 payload，读取 `exp`。
2. 把 `exp` 转成可读日期。
3. 用服务端时间比较，不要只用本机时间。
4. 检查签发方使用的是秒还是毫秒。
5. 检查 access token 的有效期是否符合客户端预期。

```js
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：

```json
{
  "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 自己声明的任意算法非常危险。校验方应该设置算法白名单：

```js
jwt.verify(token, key, { algorithms: ["RS256"] });
```

这样可以防止攻击者把 `alg` 改成 `none`，或者在对称和非对称算法之间切换。RFC 8725 也明确建议使用算法白名单并校验密钥类型。

还有一种情况：拿到的根本不是 JWT。JWE 由五段组成，第二段是加密密钥，不是 JSON payload，按 JWT 的方式解码会在 Base64URL 或 JSON 解析阶段失败。解码前先数一下段数。非对称算法还必须和密钥类型匹配：RSA 公钥验不了 ES256，EC 密钥也验不了 RS256。JWKS 里常常有多把密钥，只按 `kid` 选还不够，应该同时匹配 `alg`，否则类型不匹配会被报成签名错误，把你引向不存在的密钥问题。

## 时钟偏移

分布式系统很难做到时钟完全一致。允许一个小的容差：

```js
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。

## 实用排查清单

1. 确认 Token 由三段组成。
2. 解码 header 和 payload，但不验签。
3. 读取 `alg`、`exp`、`iat`、`nbf`。
4. 转成日期，和服务端时间比较。
5. 确认预期算法和密钥。
6. 用生产环境相同的库和配置验签。
7. 检查代理、网关或日志是否修改了 Token。
8. 用最小请求复现问题。
9. 数一下段数：三段是 JWT，五段是 JWE，处理方式完全不同。
10. 用签发方的 `Date` 响应头和 `iat` 对比校验方时钟。

调试对称签名时，可以用 [JWT 签发工具](/tools/jwt-signer) 生成已知声明的测试 Token，用 [HMAC 工具](/tools/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，而不必强制用户重新登录。

## 参考资料

- [RFC 7519 - JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519)
- [RFC 8725 - JWT 最佳实践](https://www.rfc-editor.org/rfc/rfc8725)
- [MDN - Base64](https://developer.mozilla.org/zh-CN/docs/Glossary/Base64)
- [OWASP - JSON Web Token 安全指南](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/debug-jwt-expiration
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: jwt-decoder, jwt-signer, unix-timestamp, hmac