JWT 双 Token 机制:Access Token + Refresh Token 实战

2026 年 1 月 · 阅读约 12 分钟 · JWT 系列第 2 篇

上一篇 《JWT 原理与结构》 我们讲了 JWT 是什么、怎么签名验证。但在生产环境,单 Token 几乎注定出问题。本篇讲生产级方案——Access Token + Refresh Token 双 Token 机制,重点解决"怎么安全撤销"和"怎么不打扰用户"这对矛盾。

单 Token 困境:为什么需要双 Token?

JWT 一旦签发就无法主动失效(服务端默认不存任何状态),所以你必须靠 exp 过期。问题是:

  • 短 exp(15 分钟):用户每 15 分钟被迫重新登录,体验极差,尤其在填长表单时突然掉线
  • 长 exp(30 天):token 泄露后 30 天内都有效,无法撤销,被盗风险巨大

解法就是双 Token 分工:一个短命负责日常请求(被盗也很快失效),一个长命负责换短命的(暴露面最小)。

双 Token 机制详解

特性Access TokenRefresh Token
有效期5–30 分钟7–30 天
用途调用 API用于换新 access
传输Authorization: Bearer headerhttpOnly Cookie
存储内存(最佳) / sessionStoragehttpOnly + Secure + SameSite cookie

⚠️ 严禁把任何 token 存 localStorage——XSS 攻击可读。

完整登录流程

1. POST /api/login {email, password}
   → Server 验证凭据
   → 返回 access (15min) + Set-Cookie refresh (7天)

2. GET /api/profile  Header: Authorization: Bearer <access>
   → Server 验签 → 返回用户数据

3. (15min 后) access 过期
   → GET /api/refresh  浏览器自动带 refresh cookie
   → Server 验证 → 返回新 access + 新 refresh (rotation)
   → 旧 refresh 立即失效(防重放)

4. (7天后) refresh 也过期 → 跳登录页

Node.js 完整实现

登录端点

// login.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const ACCESS_SECRET  = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user || !await bcrypt.compare(req.body.password, user.hash))
    return res.status(401).json({ error: 'Invalid credentials' });

  const access = jwt.sign(
    { sub: user._id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  const refresh = jwt.sign(
    { sub: user._id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  res.cookie('refresh', refresh, {
    httpOnly: true,   // JS 读不到,防 XSS
    secure: true,     // 仅 HTTPS
    sameSite: 'strict', // 防 CSRF
    maxAge: 7 * 24 * 60 * 60 * 1000
  });
  res.json({ access, user: { id: user._id, name: user.name } });
});

Refresh Token Rotation(防重放核心)

定义:每次用 refresh 换新 access 时,同时换新 refresh,旧 refresh 立即失效

为什么能防重放:攻击者截获旧 refresh 后想重放,服务端发现这个 token 已在使用过集合里 → 立即撤销整条 token 链,强制全员重新登录。

const usedRefreshTokens = new Set(); // 生产用 Redis

app.post('/api/refresh', async (req, res) => {
  const oldRefresh = req.cookies.refresh;
  if (!oldRefresh) return res.status(401).end();

  // 检测重放:旧 token 被用过 → 整链撤销
  if (usedRefreshTokens.has(oldRefresh)) {
    usedRefreshTokens.clear(); // 撤销所有
    return res.status(401).json({ error: 'Token reuse detected' });
  }

  try {
    const payload = jwt.verify(oldRefresh, REFRESH_SECRET);
    usedRefreshTokens.add(oldRefresh);

    const newAccess = jwt.sign(
      { sub: payload.sub },
      ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    const newRefresh = jwt.sign(
      { sub: payload.sub },
      REFRESH_SECRET,
      { expiresIn: '7d' }
    );

    res.cookie('refresh', newRefresh, {
      httpOnly: true, secure: true, sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });
    res.json({ access: newAccess });
  } catch (err) {
    res.status(401).end();
  }
});

4 种 JWT 撤销机制对比

方案原理评价
黑名单Redis 存已撤销 jti,请求时检查简单,需共享存储
白名单默认拒绝,只放白名单内 token极少用
短 exp + refreshaccess 自动过期,refresh 可撤销最推荐
tokenVersion用户表加版本字段,+1 使旧 token 全失效粒度粗,适合"踢出全部设备"

jti + 黑名单实战

const crypto = require('crypto');
const Redis  = require('ioredis');
const redis  = new Redis();

// 签发时加 jti
const access = jwt.sign(
  { sub: user._id },
  ACCESS_SECRET,
  { expiresIn: '15m', jwtid: crypto.randomUUID() }
);

// 主动撤销(登出/改密)
app.post('/api/logout', async (req, res) => {
  const jti = req.user.jti;
  await redis.set(`revoked:${jti}`, '1', 'EX', 900); // 15min 后自动清理
  res.clearCookie('refresh');
  res.status(204).end();
});

// 鉴权中间件
async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.slice(7);
  if (!token) return res.status(401).end();
  const payload = jwt.verify(token, ACCESS_SECRET);
  if (await redis.get(`revoked:${payload.jti}`))
    return res.status(401).json({ error: 'Token revoked' });
  req.user = payload;
  next();
}

5 个生产陷阱(真实踩坑)

  1. Access 存 localStorage → XSS 一锅端。改用内存 + refresh cookie
  2. Access 放 Cookie → CSRF 风险。用 Authorization header
  3. Refresh 存 localStorage → 同 1
  4. 不验 alg 字段alg=none 绕过 / RS256 公钥被当 HS256 secret
  5. 同一 secret 签 access 和 refresh → 一个泄露 = 全完。必须用两个不同 secret
  6. 不做 rotation → refresh 永久有效,被盗不可控

🛠️ 动手试一下

DevToolbox JWT 解码工具 解码一个现有的 JWT,看看 payload 里 subexpjti 这些字段。

常见问题

Q1:为什么 access 短、refresh 长?

A:安全 vs 体验的权衡。Access 暴露面最大(每个 API 请求都带),所以必须短命;Refresh 只在换 token 时用,暴露面小,可以长命。

Q2:Refresh 也存 Redis 不行吗?

A:可以(白名单模式),但需要持久化和一致性保障。配合 rotation + 短 refresh(7天)已足够,大多数场景不需要。

Q3:前端如何自动刷新?

A:axios 拦截器:收到 401 → 调 /api/refresh → 拿到新 access → 重发原请求。多个并发 401 用队列避免重复刷新。

Q4:Access 还在用时被撤销怎么办?

A:后端 jti 黑名单 + 前端拦截器收到 401 → 自动跳登录页。最长延迟 = access 剩余有效期(15 分钟)。

Q5:如何防止 refresh 端点被滥用?

A:三层防护:① 限流(每 IP 每分钟 5 次)② IP + User-Agent 绑定 ③ 设备指纹。配合 rotation,单 refresh 重放立刻触发整链撤销。

总结

JWT 双 Token 机制的核心思想是职责分离:Access 负责日常访问(短命可丢),Refresh 负责续命(长命但严管)。配合 Rotation(防重放)、httpOnly Cookie(防 XSS)、jti 黑名单(主动撤销),就构成生产级方案。

下篇我们会讲 OAuth 2.0 + JWT 集成——第三方登录场景下 JWT 怎么用。

📚 系列阅读