跳至主要內容

十万个why:一个 Token 就够了,JWT 续签为什么要搞 Access Token + Refresh Token 双 Token?

程序员小富大约 7 分钟

大家好,我是小富。

《十万个why》系列持续更新中

JWT 做登录认证,有一点很多人不理解,登录成功发一个 Token,前端每次请求带上,后端验签,过期了重新登录。感觉一个 Token 就能解决所有问题了。

那为什么 OAuth 2.0 规范里非搞了 Access TokenRefresh Token 两个?

因为单 Token 方案有一个绕不开的点,那就是 Token 的有效期。

单 Token 的问题

Token 用于身份认证,必须给它设一个过期时间。这个过期时间怎么设,就是问题所在。

设短了,用户体验不好了

设 30 分钟,用户正在填一个复杂的表单,填了 25 分钟,点提交的时候 Token 过期了,直接跳转到登录页,表单数据全没了。谁都得想骂人。

设长了,安全风险高了

设 7 天,如果这个 Token 被泄露了,抓包、XSS 攻击、用户在公共电脑上没退出登录——攻击者拿着这个 Token 可以用 7 天。在这 7 天里你没有任何办法让它失效,因为 JWT 是无状态的,服务端不存 Session,没地方去标记这个 Token 作废了。

也可以搞个黑名单,Token 被盗就加入黑名单。

这样也行,但这会使得每次请求都要查一遍黑名单,JWT 的无状态、不用查库的优势就没了,又干回到了 Session 的模式,只不过换了个存储的地方。

单 Token 方案的根本矛盾:过期时间越短越安全但用户体验越差,过期时间越长体验越好但安全风险越大。 没法同时满足两头。

双 Token 如何解决

思路很直接,既然一个 Token 兼顾不了安全和体验,那就拆成两个,一个管安全,一个管体验。

Access Token 过期时间很短,通常 15-30 分钟。前端每次请求带它去访问业务接口。因为有效期短,即使被泄露了,攻击者能利用的窗口也很小。

Refresh Token 过期时间很长,通常 7-30 天。它只有一个用途,在 Access Token 过期之后,用它去换一个新的 Access Token。它不能直接访问业务接口。

整个流程是这样的:

用户体验上,只要 Refresh Token 没过期,用户永远不会被踢出去。哪怕 Access Token 15 分钟就过期一次,前端自动刷新,用户根本感觉不到。

安全性上,真正在网络上高频传输的是 Access Token,它即使被截获也只有 15-30 分钟的有效期。Refresh Token 只在刷新的时候传输一次,暴露面小得多。

为什么 Refresh Token 更安全

Refresh Token 有效期那么长,它被偷了不是一样危险?

确实但 Refresh Token 有几个特点让它被盗的概率远低于 Access Token

传输频率低

Access Token 每次请求都要带上,一天可能传输几百上千次。Refresh Token 只在 Access Token 过期时才传输一次,一天可能就传几次。传输次数越少,被中间人截获的概率越低。

存储方式可以不同

Access Token 通常放在内存或者 localStorage 里方便前端随时取用。Refresh Token 可以存在 HttpOnly Cookie 里,JavaScript 访问不到,XSS 攻击偷不走它。

可以做更严格的校验

Refresh Token 请求刷新接口时,服务端可以额外校验设备指纹、IP 地址是否一致。如果发现 Refresh Token 在一个陌生 IP 上使用,直接拒绝并让用户重新登录。这种重校验对 Access Token 做的话成本太高,每个请求都校验设备指纹影响性能,但 Refresh Token 只在刷新时才用,一天就几次,完全扛得住。

可以做 Refresh Token 轮换

每次用 Refresh Token 换新的 Access Token 时,同时下发一个新的 Refresh Token,旧的立即作废。这样即使 Refresh Token 被偷了,攻击者用了一次之后,正常用户下一次刷新就会因为旧 Token 已经失效而触发异常,服务端可以立刻发现并强制用户重新登录。

后端实现的核心逻辑

不贴完整的代码了,说清楚关键点。

登录接口,同时生成两个 Token:

public LoginResponse login(String username, String password) {
    // 验证用户名密码...
    
    String accessToken = jwtUtil.generateToken(userId, Duration.ofMinutes(30));
    String refreshToken = UUID.randomUUID().toString();
    
    // Refresh Token 存 Redis,关联用户信息
    redis.opsForValue().set(
        "refresh:" + refreshToken, 
        userId, 
        7, TimeUnit.DAYS
    );
    
    return new LoginResponse(accessToken, refreshToken);
}

注意 Refresh Token 不一定是 JWT。

很多人以为两个都是 JWT,其实 Refresh Token 用一个随机字符串就行,真正的状态存在服务端的 Redis 里。这样你可以随时在服务端让它失效——用户点了退出登录,直接删掉 Redis 里的 Refresh Token 立刻生效。

如果 Refresh Token 也用 JWT 且不存服务端,那又回到了无法主动让它失效的老问题。

刷新接口:

public TokenResponse refresh(String refreshToken) {
    // 从 Redis 查 Refresh Token 对应的用户
    String userId = redis.opsForValue().get("refresh:" + refreshToken);
    if (userId == null) {
        throw new AuthException("Refresh Token 已过期,请重新登录");
    }
    
    // 生成新的 Access Token
    String newAccessToken = jwtUtil.generateToken(userId, Duration.ofMinutes(30));
    
    // Refresh Token 轮换:旧的删掉,发一个新的
    redis.delete("refresh:" + refreshToken);
    String newRefreshToken = UUID.randomUUID().toString();
    redis.opsForValue().set(
        "refresh:" + newRefreshToken, 
        userId, 
        7, TimeUnit.DAYS
    );
    
    return new TokenResponse(newAccessToken, newRefreshToken);
}

每次刷新都换一个新的 Refresh Token,旧的立刻删掉。这是 Refresh Token Rotation,OAuth 2.0 安全最佳实践里推荐的做法。

前端实现

前端要做的核心是拦截 401 响应,自动刷新,然后重放失败的请求。用 Axios 拦截器来实现:

let isRefreshing = false;
let pendingRequests = [];

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

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 已经在刷新了,把请求排队
        return new Promise(resolve => {
          pendingRequests.push(token => {
            originalRequest.headers.Authorization = 'Bearer ' + token;
            resolve(axios(originalRequest));
          });
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { data } = await axios.post('/auth/refresh', {
          refreshToken: getRefreshToken()
        });
        setAccessToken(data.accessToken);
        setRefreshToken(data.refreshToken);

        // 把排队的请求全部用新 Token 重发
        pendingRequests.forEach(cb => cb(data.accessToken));
        pendingRequests = [];

        originalRequest.headers.Authorization = 'Bearer ' + data.accessToken;
        return axios(originalRequest);
      } catch (e) {
        // Refresh Token 也过期了,跳登录
        clearTokens();
        window.location.href = '/login';
        return Promise.reject(e);
      } finally {
        isRefreshing = false;
      }
    }
    return Promise.reject(error);
  }
);

这段代码有个关键细节:isRefreshing 标志位和 pendingRequests 队列。

Access Token 过期的瞬间,页面上可能同时有好几个请求都返回了 401。如果每个 401 都去调一次刷新接口,就会并发刷新,前一个刷新拿到的 Refresh Token 刚换完就被后一个刷新请求用旧的 Token 去调,直接失败。

所以要保证只有第一个 401 触发刷新,其他的排队等着,等新 Token 拿到了统一重发。

什么时候不需要双 Token

双 Token 不是所有场景都值得搞。

内部管理后台,用户就那几十个运营人员,安全要求没那么极端,Token 过期了重新登录也不是什么大事。单 Token 设个 2 小时过期完全够用。

短期活动页面,H5 活动页可能就活一周,搞双 Token 的工程量大于它的收益。

已经用了 Session 的项目,如果你的系统本来就是有状态的,Session 存在 Redis 里,那 Token 只是 Session ID 的另一种表现形式。Session 机制天然支持服务端主动失效,双 Token 解决的问题对你来说不存在。

双 Token 真正有价值的场景是:无状态的 JWT 认证 + 用户量大 + 安全性要求高 + 用户体验不能打折扣。移动端 App、SaaS 产品、开放平台 API 这些是典型场景。

说在最后

Access Token 是给业务接口用的,Refresh Token 是给 Access Token 续命的。职责分开了,安全和体验就能同时兼顾了。

上次编辑于: