惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cyberwarzone
Cyberwarzone
Help Net Security
Help Net Security
L
LINUX DO - 最新话题
Security Archives - TechRepublic
Security Archives - TechRepublic
A
About on SuperTechFans
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Attack and Defense Labs
Attack and Defense Labs
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
The GitHub Blog
The GitHub Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Webroot Blog
Webroot Blog
T
Tenable Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
Microsoft Security Blog
Microsoft Security Blog
人人都是产品经理
人人都是产品经理
Simon Willison's Weblog
Simon Willison's Weblog
D
Docker
爱范儿
爱范儿
AI
AI
宝玉的分享
宝玉的分享
PCI Perspectives
PCI Perspectives
The Register - Security
The Register - Security
Project Zero
Project Zero
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
S
Securelist
Scott Helme
Scott Helme
B
Blog
Forbes - Security
Forbes - Security
Google DeepMind News
Google DeepMind News
T
The Blog of Author Tim Ferriss
月光博客
月光博客
P
Proofpoint News Feed
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
F
Fortinet All Blogs
H
Help Net Security
Last Week in AI
Last Week in AI
N
News and Events Feed by Topic
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
MyScale Blog
MyScale Blog
I
InfoQ
P
Privacy International News Feed
V
V2EX
有赞技术团队
有赞技术团队
G
Google Developers Blog
阮一峰的网络日志
阮一峰的网络日志
腾讯CDC
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
S
Schneier on Security
T
Tailwind CSS Blog

博客园 - KLAPT

Gateway 网关 CodeX =>Skills Redis 内存满了怎么处理 SpringBoot 默认配置修改 claudeCode 命令 MyBatis 的 Mapper 接口 AI | CC GUI 集成 IDEA 完整教程 在IDEA中使用Claude Code IDEA中使用CodeX MyBatisPlus解决大数据量查询慢问题 idea 中的 claude code Token Dubbo 和 Spring Cloud Gateway的区别 Transactional 注解中propagation 掌握 Spring 框架这 10 个扩展点 SpringBoot 快速实现 api 加密 Spring Boot/Cloud 中 bootstrap.yml 与 application.yml SpringBoot 实现 DOCX 转 PDF 微服务Token鉴权设计的几种方案 进程、线程、协程 RSA 加密 Java二维码 ntp服务端和客户端 Chronyd与NTP chronyd 作为服务器时钟 chrony sudo命令和su 的区别 java -cp 和 java -jar Maven 项目打包:实现业务代码与第三方依赖分离 达梦数据库创建用户 梦数据库新增大字段报错问题 达梦数据库操作 MySQL UPDATE多表关联更新 达梦数据库 为HTTP POST请求设置请求体 在Java中调用第三方接口并返回第三方页面 Java调用第三方接口的方法 Nginx 之Rewrite 使用详解 linux 命令 Spring Boot项目中集成Spring Security OAuth2和Apache Shiro
JWT 续签 Access Token + Refresh Token 双 Token
KLAPT · 2026-06-29 · via 博客园 - KLAPT

两个 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