JWT 续签 Access Token + Refresh Token 双 Token

两个 Token,干两件完全不同的事

双 Token 里最重要的一点,不是「有两个字符串」。

而是职责分开。

Access Token 负责访问业务接口。

Refresh Token 负责换新的 Access Token。

Access Token 的生命周期应该很短,常见是 10 到 30 分钟。前端请求业务接口时带它,后端验签后放行。

Refresh Token 的生命周期更长,常见是 7 天、14 天、30 天。它不应该拿去访问任何业务接口,只能调用刷新接口。

高频传输、暴露面大的 Access Token,有效期做短。

低频使用、只出现在刷新接口里的 Refresh Token,有效期最长。

用户感知上,只要 Refresh Token 还没过期,前端就能在 Access Token 过期后自动换一个新的。用户不需要频繁登录。

安全上,业务接口里到处跑的是短命 Token。就算被截获,窗口也比较小。

8d24cdc0052d414f008cb89708fa4956

 

Refresh Token 不一定要是 JWT。

甚至在大多数业务系统里,我更倾向于把 Refresh Token 做成随机字符串,然后把它存在服务端。

比如用一段安全随机数生成 refresh_token,服务端把它的 hash 存进 Redis,关联 userId、设备信息、过期时间。

用户退出登录,就删掉这条记录。

发现风险设备,就删掉对应设备的 Refresh Token。

用户改密码,直接让这个用户所有 Refresh Token 失效。

这才是 Refresh Token 真正值钱的地方

 

=========================一次正常登录和续签,大概是这样走的+====================

先看登录。

用户输入账号密码,后端校验通过后,下发两样东西。

一个短期 Access Token。

一个长期 Refresh Token。

如果是 Web 项目,常见做法是 Access Token 放在内存里,Refresh Token 放在 HttpOnly、Secure、SameSite 合理配置的 Cookie 里。这样 JavaScript 读不到 Refresh Token,XSS 直接偷走它的概率会低很多。

但这里也别只看 XSS。

Refresh Token 如果走 Cookie,刷新接口也要按 Cookie 认证的方式防 CSRF。SameSite 是一层保护,不是免死金牌。严谨一点的系统,还会校验 Origin / Referer,或者配合 CSRF Token。

如果是移动端 App,就要放到系统提供的安全存储里,比如 iOS Keychain、Android Keystore 这类地方。不要随手塞普通本地文件。

再看业务请求。

前端访问接口时带 Access Token。后端验签,没过期就继续处理。

Access Token 过期后,业务接口返回 401。前端拦截到 401,拿 Refresh Token 去调用刷新接口。

刷新接口做几件事。

校验 Refresh Token 是否存在。

校验是否过期。

校验设备、客户端、IP 风险,至少做一些基础判断。

通过后,发一个新的 Access Token。

更稳一点的做法是,同时发一个新的 Refresh Token,并把旧的作废。

这就是 Refresh Token Rotation。

205f3f1c58124a47f33b497f496273cf

 

Java 后端里,逻辑可以很简单。

登录时不要把重点放在「生成两个 JWT」上,重点应该放在「Access Token 短命,Refresh Token 可撤销」上。

伪代码大概是这样。

LoginResult login(String username, String password) {
    User user = checkPassword(username, password);

    String accessToken = jwtService.createAccessToken(
            user.id(),
            Duration.ofMinutes(20)
    );

    String refreshToken = tokenGenerator.secureRandom();
    String refreshHash = hash(refreshToken);

    refreshTokenStore.save(
            refreshHash,
            user.id(),
            currentDeviceId(),
            Duration.ofDays(14)
    );

    return new LoginResult(accessToken, refreshToken);
}

 

刷新时也一样。

TokenResult refresh(String refreshToken) {
    String oldHash = hash(refreshToken);
    RefreshSession session = refreshTokenStore.find(oldHash);

    if (session == null || session.expired()) {
        throw new UnauthorizedException("请重新登录");
    }

    riskChecker.check(session);

    refreshTokenStore.delete(oldHash);

    String newRefreshToken = tokenGenerator.secureRandom();
    refreshTokenStore.save(
            hash(newRefreshToken),
            session.userId(),
            session.deviceId(),
            Duration.ofDays(14)
    );

    String newAccessToken = jwtService.createAccessToken(
            session.userId(),
            Duration.ofMinutes(20)
    );

    return new TokenResult(newAccessToken, newRefreshToken);
}
=================================================Refresh Token 为什么相对更安全================================================

第一,Refresh Token 传输频率低。

Access Token 每个接口都带。首页接口、列表接口、详情接口、埋点接口,只要请求需要登录态,它就会出现。

Refresh Token 只在 Access Token 过期后刷新时使用。一天可能就几次。

暴露次数少,风险面就小。

第二,Refresh Token 可以放在更难被脚本读取的位置。

Web 里常见做法是 HttpOnly Cookie。它不是万能药,但至少能挡住一类直接通过 JavaScript 读取 token 的 XSS。

第三,刷新接口可以做重校验。

业务接口每次都校验设备指纹、IP 异常、客户端版本,成本不低,也容易误伤。

刷新接口调用频率低,就适合加更重的检查。

比如同一个 Refresh Token 突然从陌生地区、陌生设备、异常客户端过来,就可以拒绝刷新,或者要求重新登录。

第四,Refresh Token 可以轮换。

每次刷新都换一个新的 Refresh Token,旧的立刻失效。

这样做有一个好处,如果旧 Token 被重复使用,服务端就能发现异常。

正常情况下,旧 Token 用过一次就不该再出现。

如果它又出现了,说明要么客户端并发处理有问题,要么 Token 泄露了。

这时更谨慎的策略是,直接吊销这一整组 Refresh Token,也就是把这个设备会话或者 token family 都废掉,让用户重新登录。

OAuth 2.0 Security Best Current Practice 里也提到,对公开客户端来说,Refresh Token 要么做发送方约束,要么做轮换。原因就是公开客户端很难安全保存长期凭据,只能通过轮换和重放检测把风险压低。

等第一个刷新拿到新 Access Token,再统一重放

51d75b42dd9026a21ab7b2daa69c301f

 

伪代码不展开太长,大概就是这个结构。

let refreshing = false;
let queue = [];

async function handle401(originalRequest) {
  if (refreshing) {
    return new Promise(resolve => {
      queue.push(token => {
        originalRequest.headers.Authorization = `Bearer ${token}`;
        resolve(api(originalRequest));
      });
    });
  }

  refreshing = true;

  try {
    const tokens = await refreshToken();
    saveTokens(tokens);

    queue.forEach(retry => retry(tokens.accessToken));
    queue = [];

    originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`;
    return api(originalRequest);
  } finally {
    refreshing = false;
  }
}


====================================退出登录、改密码、封禁用户,到底删什么===============================

双 Token 做完后,很多人以为退出登录就是前端把 token 清掉。

不够。

前端清掉,只能保证这个浏览器不再主动带 Token。

真正要做的是,服务端删除对应的 Refresh Token。

因为 Access Token 本来就短命,通常可以等它自然过期。Refresh Token 才是长期登录态,它必须能被服务端撤销。

几个常见场景可以这么处理。

用户点击退出登录,删除当前设备的 Refresh Token。

用户修改密码,删除这个用户所有设备的 Refresh Token。

管理员封禁用户,删除所有 Refresh Token,并且业务接口校验用户状态。

发现 Refresh Token 重放,删除这一组 token family,让该设备重新登录。

高风险系统里,如果你不能接受 Access Token 还残留十几分钟,就再额外加 Access Token 黑名单或者版本号校验。

但普通系统里,通常没必要把所有请求都拖进黑名单查询。Access Token TTL 设短一点,Refresh Token 做可撤销,已经能覆盖绝大多数场景。

b936397710adf64df0d11ca26541ceca

 

posted @ 2026-06-29 22:00  KLAPT  阅读(10)  评论(0)    收藏  举报