← 返回工具首页 JWT Token 过期了?我翻车后总结的解决方案

JWT Token 过期了?我翻车后总结的解决方案

凌晨两点,一个线上告警把我从床上拽起来。

「用户下单接口 401,满屏都是。」

我揉了揉眼睛,打开日志一看:token 过期。用户明明在正常操作,页面没崩,但接口就是 401。一问产品,得知昨晚有个 Token 过期时间从 2 小时悄悄改成了 30 分钟——没通知前端。

就这样,我翻车了。

这篇文章,就把我踩过的坑、试过的方案、验证过的代码,一次性全部分享出来。希望你不用像我一样凌晨两点爬起来修 bug。


一、先说清楚:JWT 什么时候会「过期」?

很多人以为 Token 过期是「被踢出去」,其实不是。JWT 的过期是一种时间戳校验,发生在解码阶段。

一个 JWT 长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

解码后 Payload 部分长这样:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,      // 签发时间
  "exp": 1516242622       // 过期时间,这才是关键
}

什么时候会过期?

所以「过期」跟你有没有在操作没关系,纯粹是时间到了。服务器不会主动通知你,都是等你撞门才告诉你「此路不通」。


二、翻车场景还原:我遇到的四类典型问题

翻车 1:用户还在操作,Token 突然失效

最常见的情况:用户在写长表单,刚写完点提交,401。

根本原因:Token 设置了固定过期时间(如2小时),用户中途离开了一会儿,回来继续操作,Token 已经凉了。

翻车 2:Token 续期时间改了,前端不知道

就是开头我遇到的那个。后端把过期时间从 2 小时改成 30 分钟,前端还在用旧的逻辑,等到旧的 Token 自然失效才发现不对劲。

翻车 3:多端登录,Token 被顶掉

用户在手机和电脑同时登录,电脑改了密码,手机的 Token 没有主动失效,继续带着旧 Token 请求,被 401。

翻车 4:微服务架构下 Token 验证失败

网关校验 Token 通过,但下游服务自己维护一套密钥,或者不同服务用的算法不一致(HS256 vs RS256),导致 401。


三、四种实战解决方案

方案一:前端主动刷新 Token(最常用)

思路很简单:客户端知道 Token 快过期了,主动在后台换一个新的,用户完全无感。

什么时候刷新?

核心代码:

// 封装一个自动刷新 Token 的请求方法
class AuthService {
  constructor() {
    this.accessToken = localStorage.getItem('access_token');
    this.refreshToken = localStorage.getItem('refresh_token');
    this.isRefreshing = false;
  }

  // 检查 Token 是否快过期(比如少于 5 分钟)
  isTokenExpiringSoon() {
    if (!this.accessToken) return true;
    const payload = JSON.parse(atob(this.accessToken.split('.')[1]));
    const now = Date.now() / 1000;
    return payload.exp - now < 300; // 300秒 = 5分钟
  }

  // 刷新 Token
  async refreshAccessToken() {
    if (this.isRefreshing) return;
    this.isRefreshing = true;

    try {
      const res = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: this.refreshToken })
      });

      if (res.ok) {
        const data = await res.json();
        this.accessToken = data.access_token;
        localStorage.setItem('access_token', data.access_token);
        if (data.refresh_token) {
          this.refreshToken = data.refresh_token;
          localStorage.setItem('refresh_token', data.refresh_token);
        }
      } else {
        // 刷新失败,跳转登录
        window.location.href = '/login';
      }
    } finally {
      this.isRefreshing = false;
    }
  }

  // 封装请求,自动处理 Token 刷新
  async fetchWithAuth(url, options = {}) {
    if (this.isTokenExpiringSoon()) {
      await this.refreshAccessToken();
    }

    const headers = {
      ...options.headers,
      'Authorization': `Bearer ${this.accessToken}`
    };

    const res = await fetch(url, { ...options, headers });

    // 收到 401 说明 Token 已经失效(可能并发刷新没赶上),再刷新一次
    if (res.status === 401) {
      await this.refreshAccessToken();
      headers['Authorization'] = `Bearer ${this.accessToken}`;
      return fetch(url, { ...options, headers });
    }

    return res;
  }
}

const auth = new AuthService();

// 使用示例
async function getUserInfo() {
  const res = await auth.fetchWithAuth('/api/user/info');
  const data = await res.json();
  console.log(data);
}

这个方案适合绝大多数场景。关键是:不要等到 401 才刷新,要提前主动刷新。等 401 发生再处理,用户已经看到错误了,体验不好。


方案二:后端 Token 续期(滑动窗口)

前端主动刷新虽然好,但有一种情况无法解决:用户很久没操作,回来时 Token 已经凉透了。

滑动窗口续期的思路:只要用户在过期时间内有操作,就给他续期。比如每调用一次有效接口,就把过期时间往后推。

// Node.js / Express 中间件
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;

function slidingRefreshMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) return next();

  const token = authHeader.substring(7);

  try {
    // 先解码,不验证(取 exp)
    const payload = jwt.decode(token);
    const now = Math.floor(Date.now() / 1000);
    const TTL = 7200; // 2小时

    // 如果 Token 剩余有效期不足 30 分钟,续期
    if (payload.exp - now < 1800) {
      const newPayload = {
        ...payload,
        iat: now,
        exp: now + TTL
      };
      const newToken = jwt.sign(newPayload, SECRET, { algorithm: 'HS256' });

      // 把新 Token 放到响应头里,前端自己更新
      res.setHeader('X-Access-Token', newToken);

      // 更新请求对象里的 token,给后续 handlers 用
      req.token = newToken;
      req.user = payload;
      return next();
    }

    req.token = token;
    req.user = payload;
    next();
  } catch (err) {
    next();
  }
}

// 使用
app.use(slidingRefreshMiddleware);
app.get('/api/user', (req, res) => {
  res.json({ user: req.user.sub });
});

前端收到 X-Access-Token 响应头后,更新本地存储:

// 拦截器里处理续期响应头
fetch(url, options).then(res => {
  const newToken = res.headers.get('X-Access-Token');
  if (newToken) {
    localStorage.setItem('access_token', newToken);
  }
  return res;
});

这个方案的好处是用户无感知,但要注意:续期操作要有上限,防止 Token 无限续期导致安全问题(比如设置 maxAge,超过则强制重新登录)。


方案三:双 Token 无感刷新(最完善)

Access Token 和 Refresh Token 分开,是目前最主流、最安全的方案。

后端签发:

const jwt = require('jsonwebtoken');
const REFRESH_SECRET = process.env.REFRESH_SECRET;

function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, type: 'access' },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh', jti: crypto.randomUUID() },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  // Refresh Token 的 jti(JWT ID)要存进数据库,用于吊销
  return { accessToken, refreshToken };
}

后端刷新接口:

app.post('/api/auth/refresh', async (req, res) => {
  const { refresh_token } = req.body;
  if (!refresh_token) {
    return res.status(400).json({ error: 'missing refresh_token' });
  }

  try {
    // 验证 Refresh Token
    const payload = jwt.verify(refresh_token, REFRESH_SECRET);

    // 检查 Token 是否已被吊销(查数据库 blacklist)
    const isRevoked = await redis.get(`revoked:${payload.jti}`);
    if (isRevoked) {
      return res.status(401).json({ error: 'token revoked' });
    }

    // 生成新的 Access Token
    const newAccessToken = jwt.sign(
      { sub: payload.sub, type: 'access' },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );

    // 可选:Rotate Refresh Token(换一个新的旧的作废)
    // 这样旧 Token 被盗也无法反复使用
    const newRefreshToken = jwt.sign(
      { sub: payload.sub, type: 'refresh', jti: crypto.randomUUID() },
      REFRESH_SECRET,
      { expiresIn: '7d' }
    );

    // 把旧 Refresh Token 加入黑名单
    await redis.setex(`revoked:${payload.jti}`, 7 * 86400, '1');

    res.json({
      access_token: newAccessToken,
      refresh_token: newRefreshToken
    });
  } catch (err) {
    res.status(401).json({ error: 'invalid refresh_token' });
  }
});

前端 Axios 拦截器:

import axios from 'axios';

const api = axios.create({ baseURL: '/api' });

let isRefreshing = false;
let refreshSubscribers = [];

// 把所有等待的请求都存入队列
function subscribeTokenRefresh(callback) {
  refreshSubscribers.push(callback);
}

// Token 刷新后,逐个重试队列里的请求
function onTokenRefreshed(newToken) {
  refreshSubscribers.forEach(callback => callback(newToken));
  refreshSubscribers = [];
}

api.interceptors.request.use(async config => {
  const token = getAccessToken();
  if (token && isTokenExpiringSoon(token)) {
    if (!isRefreshing) {
      isRefreshing = true;
      try {
        const res = await axios.post('/api/auth/refresh', {
          refresh_token: getRefreshToken()
        });
        const newToken = res.data.access_token;
        setAccessToken(newToken);
        if (res.data.refresh_token) {
          setRefreshToken(res.data.refresh_token);
        }
        onTokenRefreshed(newToken);
      } catch (err) {
        window.location.href = '/login';
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
    }

    // 当前请求加入等待队列,等新 Token 拿到后再重试
    return new Promise(resolve => {
      subscribeTokenRefresh(newToken => {
        config.headers['Authorization'] = `Bearer ${newToken}`;
        resolve(config);
      });
    });
  }

  config.headers['Authorization'] = `Bearer ${token}`;
  return config;
});

api.interceptors.response.use(
  res => res,
  async err => {
    if (err.response?.status === 401 && !err.config._retry) {
      err.config._retry = true;
      // 触发刷新,等待完成后重试
      // ... 同上逻辑
    }
    return Promise.reject(err);
  }
);

这种方案最健壮。即使 Access Token 过期,只要 Refresh Token 有效,用户完全无感知。而且 Refresh Token 泄漏的风险也更低,因为它只用于换 Token,不参与实际请求。


方案四:微服务架构下的 Token 处理

微服务场景有个独特问题:多个服务都要验证 Token,但各服务不能各自独立验证(否则密钥管理混乱),也不能所有请求都打到认证中心(延迟太高)。

常见解法是「网关统一验证 + 下游服务信任网关」。

网关层(Node.js / Express):

const jwt = require('jsonwebtoken');
const axios = require('axios');

// 网关:验证 Token,通过后把用户信息写入请求头,下发给下游服务
async function gatewayAuthMiddleware(req, res, next) {
  const token = req.headers.authorization?.substring(7);
  if (!token) return next();

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    // 把用户信息通过内部 Header 下发,不暴露原始 Token
    req.headers['x-user-id'] = payload.sub;
    req.headers['x-user-roles'] = payload.roles?.join(',');
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      // Token 过期,尝试用 Refresh Token 续期
      const refreshToken = req.headers['x-refresh-token'];
      if (refreshToken) {
        try {
          const newTokens = await refreshAccessToken(refreshToken);
          const newPayload = jwt.decode(newTokens.access_token);
          req.headers['x-user-id'] = newPayload.sub;
          res.setHeader('x-access-token', newTokens.access_token);
          next();
        } catch {
          res.status(401).json({ error: 'token expired and cannot refresh' });
        }
      } else {
        res.status(401).json({ error: 'token expired' });
      }
    } else {
      res.status(401).json({ error: 'invalid token' });
    }
  }
}

// 下游服务:信任网关,直接读 x-user-id
function serviceAuthMiddleware(req, res, next) {
  const userId = req.headers['x-user-id'];
  if (!userId) return res.status(401).json({ error: 'unauthorized' });
  req.userId = userId;
  next();
}

app.use('/api', gatewayAuthMiddleware);           // 网关层
app.use('/internal', serviceAuthMiddleware);      // 下游服务层

下游服务之间的调用(服务网格思路):

// 下游服务 A 调用服务 B 时,携带网关下发的身份Header
async function callServiceB(serviceName, endpoint, data) {
  const token = getInternalToken(); // 服务间专用的短效Token

  return axios.post(`http://${serviceName}${endpoint}`, data, {
    headers: {
      'x-internal-token': token,
      'x-user-id': req.userId, // 透传原始用户身份
      'x-request-id': generateUUID() // 追踪用
    }
  });
}

微服务场景的关键原则:不要让下游服务直接解析用户 JWT,统一走网关或 service mesh 的 mTLS 通道,否则密钥管理会成为噩梦。


四、401 处理:统一错误码设计

不管哪种方案,前端都要统一处理 401。建议封一个全局拦截器:

// 统一处理 401
window.addEventListener('unauthorized', () => {
  // 清除本地 Token
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');

  // 跳转到登录页,带上 return URL
  const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
  window.location.href = `/login?return=${returnUrl}`;
});

后端也要区分 401 的具体原因,不要所有错误都返回同一个 401:

{
  "error": "token_expired",
  "message": "Access token has expired",
  "code": 401,
  "retryable": true   // 前端看到这个字段,可以自动刷新
}

{
  "error": "token_revoked",
  "message": "Token has been revoked",
  "code": 401,
  "retryable": false  // Refresh Token 也失效,必须重新登录
}

retryable 字段非常重要,前端据此决定是刷新还是跳转登录页。


五、踩坑总结


六、在线工具推荐

写代码调试 JWT 的时候,最烦的就是手动解码看 Payload。CloverTools 的 JWT 解析工具可以一键解码任意 JWT,查看 header、payload、签名,还支持在线验证。不用再在代码里 console.log 了。

👉 点击使用 CloverTools JWT 解析工具

另外,如果你需要生成测试 Token、验证签名是否正确,这个工具也很好用。


Token 过期这事儿,说大不大,说小不小。做好了,用户完全感知不到;做砸了,就是凌晨两点的告警和用户投诉。

希望这篇文章能帮你把「Token 过期」这件事从紧急 bug 变成一个可以优雅处理的正常流程。

有问题?评论区见。

💡 遇到同类问题?用工具快速解决

试试这些配套工具,无需注册,打开即用

JWT解密

常见问题

Q: 如何使用 jwttoken过期翻车实录 相关工具?
A: 这类工具一般有明确的输入框和输出框,按提示输入内容,点击对应按钮即可得到结果。建议先用简单示例测试功能是否正常,再处理实际数据。
Q: jwttoken过期翻车实录 适合在什么场景使用?
A: 根据具体工具类型决定。格式转换工具适合处理第三方数据,编码工具适合加密传输,压缩工具适合文件上传前处理。多积累工具使用经验,遇到问题时能快速判断用哪个工具解决。
Q: 有没有更好的替代工具?
A: 不同工具有不同侧重,重点是理解原理。可以同时安装多个类似工具,实际使用中对比效果,选择最顺手的一个。随着使用经验增加,你也能判断工具的好坏。