JWT刷新时旧token未及时回收导致重复登录怎么解决?

上官沁仪 阅读 45

我在用JWT做登录鉴权时遇到个问题,用户在A设备刷新token后,旧token居然还能在B设备正常登录,这样用户明明退出了怎么还会被绕过?

我的实现逻辑是这样的:用户登录成功后存储token到localStorage,刷新接口会返回新token覆盖旧值。但测试时发现:


// 刷新token的axios拦截器
axios.interceptors.response.use(
  response => response,
  error => {
    if (error.response.status === 401) {
      return refreshAccessToken().then(newToken => {
        localStorage.setItem('token', newToken); // 直接覆盖旧token
        error.config.headers['Authorization'] = <code>Bearer ${newToken}</code>;
        return axios(error.config);
      });
    }
    return Promise.reject(error);
  }
);

尝试过在服务端设置shortToken+refreshToken机制,但好像没生效?用户退出登录时清除localStorage,但别人用抓包工具复制之前的旧token,居然还能访问接口。是不是应该让服务端主动作废旧token?或者我的刷新逻辑哪里漏了?

我来解答 赞 5 收藏
二维码
手机扫码查看
2 条解答
博主悦洋
第一步,你遇到的问题很典型,JWT本身是无状态的,服务端不存储token信息,所以当你在A设备刷新token后,旧token在过期前依然有效,别人拿到这个token确实可以继续访问接口。这就是你说的“旧token还能用”的根本原因。

JWT的设计初衷就是无状态,但这也带来了无法主动作废旧token的问题。要解决这个问题,不能只靠客户端清除localStorage,因为那只是前端的操作,攻击者完全可以绕过前端直接调用接口。

解决方案的核心思路是:引入服务端的token状态管理,哪怕只管一小部分关键状态。

我推荐你采用“黑名单机制”配合合理的过期时间控制。具体分下面几步来做:

第一步,在用户刷新token时,把旧token加到服务端的黑名单里,并设置一个过期时间等于原token剩余有效期。比如你的access token有效期是15分钟,那么当用户刷新时,就把旧token加入黑名单并缓存15分钟。

你可以用Redis来实现这个黑名单,key可以用token的jti(JWT ID)或者token本身的哈希值,value可以存个标记或时间戳,过期自动删除。

第二步,修改你的认证中间件,在验证JWT签名和过期时间之后,再查一下这个token是否在黑名单里。如果在,就拒绝请求。

下面是Node.js + Express的一个简单示例:

const jwt = require('jsonwebtoken');
const redisClient = require('./redis'); // 连接到Redis

// 认证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];

if (!token) return res.sendStatus(401);

// 先检查黑名单
const tokenHash = generateHash(token); // 可以用crypto.createHash('sha256').update(token).digest('hex')

redisClient.get(tokenHash, (err, reply) => {
if (reply) {
// 在黑名单中,拒绝访问
return res.status(401).json({ message: 'Token已失效,请重新登录' });
}

// 验证JWT
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.sendStatus(403);
}
req.user = user;
next();
});
});
}

function generateHash(token) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(token).digest('hex');
}


第三步,刷新token的逻辑要做调整。不要只在前端覆盖,而是在服务端明确处理新老token的关系。

// 刷新token接口
app.post('/refresh-token', (req, res) => {
const { refreshToken } = req.body;

if (!refreshToken) return res.sendStatus(401);

// 验证refreshToken有效性(一般用另一个密钥)
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
if (err) return res.sendStatus(403);

// 解析出旧的accessToken(从前端传过来)
const oldAccessToken = req.headers.authorization?.split(' ')[1];

if (oldAccessToken) {
// 将旧token加入黑名单
const tokenHash = generateHash(oldAccessToken);
const decoded = jwt.decode(oldAccessToken);
const remainingTime = decoded.exp - Math.floor(Date.now() / 1000); // 剩余秒数

redisClient.setex(tokenHash, remainingTime, 'invalid'); // 自动过期
}

// 生成新的access token
const newAccessToken = jwt.sign(
{ userId: user.userId },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken: newAccessToken });
});
});


第四步,前端的拦截器也要改,不能只等401才刷新。因为你现在有黑名单机制了,即使旧token没过期也会被拒绝,所以应该更积极地使用refresh token流程。

let isRefreshing = false;
let refreshSubscribers = [];

function subscribeTokenRefresh(cb) {
refreshSubscribers.push(cb);
}

function onRefreshed(newToken) {
refreshSubscribers.forEach((cb) => cb(newToken));
refreshSubscribers = [];
}

axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

if (error.response.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 如果已经在刷新,等待新token
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
originalRequest.headers.Authorization = Bearer ${token};
resolve(axios(originalRequest));
});
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
// 调用刷新接口获取新token
const response = await axios.post('/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
}, {
headers: {
Authorization: Bearer ${localStorage.getItem('token')}
}
});

const newToken = response.data.accessToken;
localStorage.setItem('token', newToken);
onRefreshed(newToken);

// 重试原请求
originalRequest.headers.Authorization = Bearer ${newToken};
return axios(originalRequest);

} catch (refreshError) {
// 刷新失败,跳转登录
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}

return Promise.reject(error);
}
);


最后说下原理:JWT之所以难回收,是因为它不依赖服务端状态。但我们可以通过增加一个轻量级的状态层(黑名单)来打破完全无状态的限制,只对最近一次被淘汰的token做短期追踪。这样既保留了JWT大部分性能优势,又解决了安全漏洞。

补充建议:
- access token有效期别设太长,15分钟比较合理
- refresh token可以长一点,比如7天,但也要支持服务端主动废除(比如退出登录时删掉refresh token记录)
- 所有敏感操作最好再加上二次验证,比如修改密码时要输入当前密码

你现在的问题本质是“只做了客户端清理,没做服务端状态控制”,补上黑名单这一步就完整了。这套方案我们线上用了很久,效果不错。
点赞 3
2026-02-10 00:03
Prog.思涵
JWT 是无状态的,这是它设计的核心特点,但这也意味着服务端无法像传统 Session 那样主动让 Token 失效,所以你发现的这个问题是 JWT 的“通病”。

你说得对,**服务端需要主动作废旧 Token**,否则抓包拿到的旧 Token 还能继续用,直到它自然过期。这不是前端覆盖 localStorage 就能解决的事。

### 解决方案:Token 黑名单(黑名单机制)

1. **服务端维护一个 Token 黑名单**,比如用 Redis 存,Key 是 Token 的 JTI(或 Hash),Value 是 Token 剩余有效期。
2. 每次用户刷新 Token,**旧 Token 放入黑名单**。
3. 用户退出登录时,当前 Token 也加入黑名单。
4. 每次请求进入接口前,**先校验 Token 是否在黑名单中**,命中则拒绝访问。

### 刷新逻辑补充建议

你现在的拦截器逻辑只是前端替换 localStorage,但没有通知服务端回收旧 Token。建议在刷新 Token 成功后,**调用一个接口通知服务端作废旧 Token**,或者干脆让刷新接口内部自动把旧 Token 加入黑名单。

### 示例代码(刷新 Token 时通知服务端):

function refreshAccessToken() {
return axios.post('/auth/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
}).then(res => {
const { accessToken, oldToken } = res.data;
// 通知服务端作废旧 Token
axios.post('/auth/invalidate-token', { token: oldToken }, {
headers: {
'Authorization': Bearer ${accessToken}
}
}).catch(() => {
// 失败不影响主流程,但可以记录日志
});
localStorage.setItem('token', accessToken);
return accessToken;
});
}


### 总结一下

- 前端 localStorage 替换 Token,只是更新了本地状态;
- 真正要让旧 Token 无效,必须服务端加黑名单机制;
- 这样哪怕抓包拿到了旧 Token,也会被服务端拦截;
- Token 黑名单建议用 Redis 实现,效率高,过期自动清理。

你要是用 JWT 又想强控制登录状态,这一步是绕不过去的。别想着“无状态”能解决所有问题,这玩意儿就得加点“状态”。
点赞 5
2026-02-04 21:01