深入理解JWT

深入理解 JWT 双 Token 认证机制:Access Token、Refresh Token、Rotation 与会话生命周期

——基于 NestJS + Drizzle ORM + PostgreSQL 的设计与实践

前言

在开发 Web 后端系统时,身份认证是几乎无法回避的基础能力。

以 NestJS 为例,我们可以使用 JWT(JSON Web Token)实现无状态身份认证。客户端登录成功后,服务端签发 JWT;客户端在后续请求中携带令牌,服务端通过验证签名、有效期等信息识别用户身份。

但是,随着系统逐渐完善,我们会遇到几个问题:

  1. JWT 的有效期应该设置多长?15 分钟还是 7 天?
  2. 如果 JWT 只有 15 分钟有效期,用户是否需要每 15 分钟重新登录?
  3. 为什么许多身份认证系统同时使用 Access Token 和 Refresh Token?
  4. Refresh Token Rotation(刷新令牌轮换)解决了什么问题?
  5. 如果 Refresh Token 有效期为 7 天,持续轮换后,用户是否可以一直保持登录?
  6. 用户主动登出后,已经签发的 JWT 为什么可能仍然有效?

这些问题实际上都围绕一个核心主题:如何在安全性、用户体验和服务端状态管理之间取得平衡。

本文将以 NestJS + Drizzle ORM + PostgreSQL 为技术背景,循序渐进地分析 JWT 双 Token 认证机制的工作原理,并给出适用于实际工程的设计建议。


一、理解 JWT:它解决了什么问题?

1.1 传统 Session 身份认证

首先考虑传统的 Session 登录方式。

用户向服务端提交账号密码:

POST /auth/login
Content-Type: application/json

{
  "email": "alice@example.com",
  "password": "SecurePass123!"
}

服务端验证通过后,创建一条 Session:

Session ID: abc123
User ID: 10001
Expires At: ...

随后,服务端通过 Cookie 将 Session ID 返回给客户端:

Set-Cookie: sessionId=abc123; HttpOnly; Secure

客户端再次请求时,浏览器自动携带 Cookie。服务端根据 Session ID 查找对应的登录状态。

整个认证过程可表示为:

客户端
   |
   | Cookie: sessionId=abc123
   v
NestJS
   |
   | 查询 Session
   v
Redis / PostgreSQL
   |
   | 获取 User ID
   v
执行业务逻辑

这种方式的特点是:服务端保有会话状态,因此可以通过删除或撤销 Session,集中控制用户是否仍然登录。

但这通常意味着需要管理会话存储、过期时间以及分布式环境中的会话共享。

1.2 JWT 的基本思想

JWT 提供了另一种传递身份信息的方式。

一个 JWT 通常由三个部分组成:

Header.Payload.Signature

例如 Header:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload:

{
  "sub": "10001",
  "iss": "https://api.example.com",
  "aud": "my-api",
  "iat": 1791626400,
  "exp": 1791627300
}

常见字段如下:

字段 英文 含义
sub Subject 令牌对应的主体,通常是用户 ID
iss Issuer 令牌签发方
aud Audience 令牌的目标接收方
iat Issued At 签发时间
exp Expiration Time 过期时间
jti JWT ID 令牌唯一标识
sid Session ID 应用自定义的会话标识

签名使服务端能够验证 JWT 是否被篡改。

对于使用 HMAC 的 JWT,签名可以概念性地表示为:

\[Signature = HMACSHA256(secret,\ encodedHeader + "." + encodedPayload) \]

客户端访问受保护的 API 时,在请求头中携带 JWT:

GET /auth/me
Authorization: Bearer <access_token>

服务端验证令牌的签名、签名算法、过期时间、签发方、受众等信息。验证成功后,再结合业务授权规则决定是否允许访问。

这种机制的一个重要优点是:如果使用自包含 JWT,服务端可以仅凭令牌及签名验证所需的信息完成基础认证,而不必每次查询 Session 存储。

不过,JWT 有三个必须理解的特点:

第一,签名不等于加密。 常见的签名 JWT(JWS)可以被解码读取 Payload,因此不应该将密码、密钥等秘密直接放入其中。

第二,JWT 不保证当前使用者就是最初登录的人。 Bearer Token 本质上是持有者凭证。只要攻击者拿到有效令牌,就可能冒用对应的身份。

第三,JWT 不自带集中撤销机制。 如果服务端只验证签名和有效期,就无法仅通过删除一条数据库记录使某个 JWT 立刻失效。

第三点,正是理解双 Token 机制的重要起点。


二、只有 Access Token 为什么不够?

假设系统只使用一个 JWT 作为访问令牌。

用户登录成功后,NestJS 签发一个有效期为 7 天的 JWT:

const accessToken = await jwtService.signAsync(
  { sub: user.id },
  { expiresIn: '7d' },
);

客户端保存这个 Token,并在后续请求中使用。

对于正常用户而言,这种机制非常方便:7 天内通常都不需要重新登录。

但是,如果这个 Access Token 被盗,攻击者也可能在其剩余有效期内调用业务接口。

假设 JWT 签发后只过了 1 小时就发生泄露,那么攻击者可能拥有接近 7 天的利用窗口。

如果没有额外的黑名单、会话状态校验或密钥撤销机制,单纯的 JWT 签名验证无法识别这次泄露。

2.1 缩短 Access Token 有效期

一种直观的办法是缩短有效期:

const accessToken = await jwtService.signAsync(
  { sub: user.id },
  { expiresIn: '15m' },
);

这样一来,即使 Access Token 被盗,也只能在它剩余的有效期内使用。

但是,新的问题随之出现。

15 分钟之后,JWT 过期。用户再次访问 API:

GET /auth/me
Authorization: Bearer <expired_token>

服务端返回:

401 Unauthorized

如果客户端没有其他可用的认证凭证,就必须要求用户再次输入账号密码。

对于需要持续交互的后台管理系统,这显然不够理想。

因此我们面临一个矛盾:

  • Access Token 有效期越长,用户体验越好,但令牌泄露后的风险窗口也可能越长。
  • Access Token 有效期越短,风险窗口越小,但频繁重新登录会影响用户体验。

Refresh Token 就是用于缓解这一矛盾的。


三、双 Token 机制的核心原理

双 Token 机制将身份认证拆分为两个不同的职责。

3.1 Access Token:负责访问资源

Access Token(访问令牌)用于访问受保护的业务 API。

例如:

GET /users/me
Authorization: Bearer <access_token>

在本文的设计中,Access Token 采用 JWT 格式,有效期为 15 分钟。

服务端通过 JWT Guard 验证令牌,并获取用户身份。

Access Token 的核心特点:

  • 有效期较短;
  • 频繁用于业务 API;
  • 可以采用自包含 JWT,减少会话查询;
  • 令牌失效后,不能再直接访问受保护资源。

3.2 Refresh Token:负责延续登录状态

Refresh Token(刷新令牌)用于获取新的 Access Token。

例如:

POST /auth/refresh
Content-Type: application/json

{
  "refreshToken": "<refresh_token>"
}

如果 Refresh Token 有效,服务端就可以在不重新验证用户密码的情况下,签发新的 Access Token。

Refresh Token 的核心特点:

  • 通常比 Access Token 长效;
  • 不直接用于普通业务 API;
  • 需要更严格的存储与泄露防护;
  • 可以设计为可撤销、可轮换;
  • 可以关联服务端会话。

需要注意:Refresh Token 不一定是 JWT。

在自行构建的身份认证系统中,完全可以使用密码学安全随机数生成的不可预测字符串。

例如:

import { randomBytes } from 'node:crypto';

const refreshToken = randomBytes(32).toString('base64url');

这里生成了 256 位的随机令牌材料,适合作为高熵 Bearer 凭证。

本文后续采用:

  • Access Token:JWT;
  • Refresh Token:随机字符串;
  • PostgreSQL:保存 Refresh Token 的哈希及其会话状态。

3.3 两种 Token 的区别

对比项 Access Token Refresh Token
用途 访问业务资源 续期访问凭证
格式 JWT 随机字符串
示例有效期 15 分钟 7 天空闲期限
使用频率 每次受保护的 API 请求 即将过期或认证失效时
是否直接访问业务 API 是 否
是否需要服务端可撤销状态 取决于需求 本文设计需要
是否轮换 每次刷新生成新令牌 启用 Rotation 后每次刷新更换

可以用一个形象的比喻理解:

Access Token 是有效期较短的门禁卡,Refresh Token 是保存得更严格的换卡凭证。门禁卡可以开门,换卡凭证不能直接开门,但可以换取新的门禁卡。


四、双 Token 的完整生命周期

假设用户在上午 10:00 登录。

服务端签发:

Access Token  A1:10:00 签发,10:15 过期
Refresh Token R1:有效,满足续期条件

4.1 登录阶段

用户
  |
  | POST /auth/login
  | email + password
  v
NestJS AuthService
  |
  | 从 PostgreSQL 查询用户
  | 使用 Argon2id 验证密码
  v
验证成功
  |
  +----> 签发 Access Token A1
  |
  +----> 生成 Refresh Token R1
  |
  +----> 保存 R1 哈希和会话记录
  |
  v
返回客户端

响应示例:

{
  "tokenType": "Bearer",
  "accessToken": "<A1>",
  "expiresIn": 900,
  "refreshToken": "<R1>"
}

其中 expiresIn: 900 表示 Access Token 有效期为 900 秒,即 15 分钟。

4.2 正常访问阶段

客户端调用:

GET /posts
Authorization: Bearer <A1>

NestJS 的 JWT Guard 验证令牌。只要 A1 仍然有效,就可以继续执行业务权限检查。

此阶段不需要使用 Refresh Token。

4.3 Access Token 过期

到了 10:16,客户端使用 A1 访问业务接口。

由于 A1 已经过期,服务端返回:

401 Unauthorized

注意,Access Token 过期不代表整个登录会话已经失效。

客户端仍然可以尝试使用有效的 R1 续期。

4.4 刷新令牌阶段

客户端向 /auth/refresh 发送 R1。

服务端检查:

  1. R1 是否存在于合法的刷新会话中?
  2. R1 是否已过期?
  3. R1 是否被使用过或撤销?
  4. 当前会话是否超过绝对有效期?
  5. 对应用户是否仍然有效?

全部通过后,服务端签发新的令牌:

Access Token  A2
Refresh Token R2

如果采用 Refresh Token Rotation,R1 同时被标记为已使用。

之后客户端使用 A2 访问业务 API,使用 R2 进行下一次刷新。

只要刷新会话仍然有效,用户就不需要每 15 分钟重新输入密码。


五、为什么 Access Token 和 Refresh Token 要一起更换?

这是一个很关键的设计选择。

实际上,有两种不同的方案。

5.1 方案一:只更换 Access Token

假设首次登录得到:

A1 + R1

第一次刷新:

A1 + R1 → A2 + R1

第二次刷新:

A2 + R1 → A3 + R1

也就是说,R1 在有效期内可以重复使用。

优点是实现简单,不需要每次更新 Refresh Token。

但缺点也明显:如果 R1 被盗,攻击者可能反复使用 R1 换取新的 Access Token,直到 R1 失效或被服务端撤销。

如果没有其他发送方约束机制,服务端也很难通过令牌重复使用本身检测泄露。

5.2 方案二:Refresh Token Rotation

Rotation 的意思是:每次成功刷新 Access Token 时,同时生成新的 Refresh Token,并使旧 Refresh Token 失效。

例如:

登录:
A1 + R1

第一次刷新:
R1 → A2 + R2
R1 失效

第二次刷新:
R2 → A3 + R3
R2 失效

第三次刷新:
R3 → A4 + R4
R3 失效

同一条会话链中,正常情况下只有最新的 Refresh Token 可用于下一次刷新。

这样设计最大的价值,是提高对 Refresh Token 重放攻击的检测能力。

5.3 Rotation 如何检测攻击?

假设用户和攻击者都获取了 R1。

用户先使用 R1 刷新:

用户使用 R1
     ↓
获得 A2 + R2
     ↓
R1 被标记为已使用

此时攻击者再次提交 R1:

攻击者使用 R1
     ↓
服务端发现 R1 已被使用
     ↓
判定发生可疑重放
     ↓
撤销该 Refresh Token 会话家族

在这种情况下,服务端无法仅凭 R1 判断究竟谁是合法用户、谁是攻击者。

因此,一个常见的安全策略是撤销该会话家族中仍然有效的刷新令牌,并要求重新认证。

但是需要注意:

Rotation 并不能保证阻止 Refresh Token 的首次盗用。

如果攻击者抢先使用 R1,攻击者也可能成功拿到 R2。只有当其他参与者再次使用已经失效的旧令牌时,服务端才有机会发现重放。

此外,如果攻击者能够持续控制客户端执行环境,就可能持续获得最新令牌。Rotation 对这种持续性入侵的保护能力有限。

5.4 官方规范如何规定?

RFC 9700《Best Current Practice for OAuth 2.0 Security》要求,对于获得 Refresh Token 的 OAuth 2.0 公共客户端,授权服务器应采用以下两类保护机制之一:

  • Refresh Token Rotation;
  • Sender-Constrained Refresh Token,即将令牌通过密码学手段绑定到特定客户端实例。

因此,Rotation 是现代 OAuth 2.0 安全实践中的重要机制,但并不是所有 JWT 登录系统在协议层面都必须使用双 Token 或 Rotation。

自行构建的一方登录系统与标准 OAuth 2.0 授权体系,也应在讨论规范约束时加以区分。


六、Rotation 之后,旧 Access Token 会立即失效吗?

不会自动失效。

假设:

10:00  签发 A1,有效期至 10:15
10:10  使用 R1 提前刷新
10:10  得到 A2 + R2
10:10  R1 被撤销

那么在 10:10 至 10:15 期间:

令牌 状态
A1 可能仍然有效
A2 有效
R1 已失效
R2 有效

这是因为 Access Token 和 Refresh Token 具有不同的生命周期。

如果服务端仅对 A1 进行 JWT 签名与过期时间检查,那么 R1 是否被撤销,并不会影响 A1 的验证结果。

换句话说:

Refresh Token 的撤销不等于 Access Token 的撤销。

如果业务要求用户登出后立即使所有 Access Token 失效,可以引入额外机制:

  • JWT 黑名单;
  • Session ID 与服务端会话状态校验;
  • 用户 Token Version;
  • 更短的 Access Token 有效期;
  • 特定高风险操作的重新认证。

这些机制会引入额外状态或数据库、Redis 查询,也意味着需要在性能、复杂度与即时撤销能力之间做取舍。


七、Refresh Token 有效期 7 天,用户最多只能登录 7 天吗?

这是整个双 Token 设计中最容易混淆的问题之一。

答案取决于系统如何定义 7 天有效期,以及是否设置了整个会话的绝对截止时间。

我们需要区分三个概念。

7.1 固定过期:Fixed Expiration

假设用户在 10 月 1 日登录,系统给这次刷新授权设置了固定的 7 天截止期限。

那么:

首次登录:10 月 1 日
最终截止:10 月 8 日

即使在这期间发生多次 Rotation:

R1 → R2 → R3 → R4

后续令牌也不能突破原先固定的截止时间。

一旦达到 10 月 8 日,用户就无法继续通过该 Refresh Token 家族获得新的 Access Token。

系统可能要求重新输入凭据,也可能借助其他仍然有效的身份认证会话完成重新认证,具体取决于系统设计。

7.2 滑动过期:Sliding Expiration

另一种模型是以最近一次成功刷新时间计算空闲期限。

例如,把 7 天定义为:

连续 7 天没有成功刷新,则当前登录会话失效。

时间线如下:

时间 操作 新的空闲截止时间
10 月 1 日 登录 10 月 8 日
10 月 2 日 刷新 10 月 9 日
10 月 5 日 刷新 10 月 12 日
10 月 10 日 刷新 10 月 17 日
10 月 15 日 刷新 10 月 22 日

在纯滑动过期机制下,只要客户端一直在有效时间窗口内刷新,会话理论上可以持续延长。

此时,7 天表示的是最大连续空闲期限,而不是首次登录后的最长生命周期。

这种设计可以改善长期使用体验,但如果没有额外限制,也可能让被盗的刷新凭证持续获得续期机会。

7.3 混合策略:空闲过期 + 绝对过期

对于自行设计的业务登录会话,可以同时维护两个截止时间:

  1. Idle Expiration: 连续 7 天未成功刷新时失效。
  2. Absolute Expiration: 自首次登录起最多维持 30 天。

例如:

10 月 1 日:首次登录
            空闲截止:10 月 8 日
            绝对截止:10 月 31 日

10 月 2 日:成功刷新
            空闲截止:10 月 9 日
            绝对截止:10 月 31 日

10 月 8 日:成功刷新
            空闲截止:10 月 15 日
            绝对截止:10 月 31 日

10 月 14 日:成功刷新
             空闲截止:10 月 21 日
             绝对截止:10 月 31 日

10 月 20 日:成功刷新
             空闲截止:10 月 27 日
             绝对截止:10 月 31 日

10 月 26 日:成功刷新
             空闲截止:10 月 31 日
             绝对截止:10 月 31 日

这里的空闲截止时间永远不能超过绝对截止时间。

数学上可以写成:

\[T_{\text{idle-expire}} = \min(T_{\text{now}}+7d,\ T_{\text{absolute}}) \]

其中:

\[T_{\text{absolute}}=T_{\text{login}}+30d \]

TypeScript 实现示意:

const IDLE_TTL = 7 * 24 * 60 * 60 * 1000;

function calculateIdleExpiration(
  now: Date,
  absoluteExpiresAt: Date,
): Date {
  return new Date(
    Math.min(
      now.getTime() + IDLE_TTL,
      absoluteExpiresAt.getTime(),
    ),
  );
}

数据库可以分别存储 idleExpiresAt 和 absoluteExpiresAt,并在每次刷新时检查两个期限。

但这里有一条重要的规范边界:

RFC 10017《OAuth 2.0 for Browser-Based Applications》规定,对于 OAuth 浏览器客户端,如果初始 Refresh Token 已经具有预先确定的最终过期时间,那么轮换不能将新令牌的有效期延长至初始令牌的截止时间之后。

因此,不能把“初始 Refresh Token 固定 7 天到期”与“每次 Rotation 将其再延长 7 天”不加区分地视为符合该规范。

如果采用本文的混合会话策略,应当明确区分:

  • 7 天是会话的空闲超时策略;
  • 30 天是预先确定的绝对期限;
  • 令牌轮换必须受适用的会话期限与协议规范约束。

具体是否符合 OAuth 2.0 的要求,还需结合授权服务器如何定义、签发和约束初始 Refresh Token。

所以,7 天、15 分钟和 30 天都不是 JWT 或 OAuth 强制规定的固定数值,而是需要根据应用风险、用户体验与适用协议确定的安全策略。


八、基于 NestJS + Drizzle + PostgreSQL 的工程设计

接下来把上述机制映射到实际代码架构。

本文采用:

NestJS
├── AuthController
│   ├── POST /auth/register
│   ├── POST /auth/login
│   ├── POST /auth/refresh
│   └── POST /auth/logout
│
├── AuthService
│   ├── verifyPassword()
│   ├── issueAccessToken()
│   ├── issueRefreshToken()
│   ├── rotateRefreshToken()
│   └── revokeSession()
│
├── JwtAuthGuard
│   ├── extractBearerToken()
│   ├── verifyJwt()
│   └── attachCurrentUser()
│
├── Drizzle ORM
│   ├── users
│   └── refresh_tokens
│
└── PostgreSQL

其中 NestJS 负责组织认证逻辑与 HTTP 接口,Drizzle 负责数据持久化,PostgreSQL 负责保存用户及刷新会话状态。

8.1 用户密码不能明文存储

用户表应保存密码哈希,而不是密码明文。

例如:

import * as argon2 from 'argon2';

const passwordHash = await argon2.hash(
  password,
  { type: argon2.argon2id },
);

登录时验证:

const valid = await argon2.verify(
  user.passwordHash,
  loginDto.password,
);

密码应该使用 Argon2id、scrypt 等适合密码存储的算法。

不要直接使用 SHA-256 对用户密码进行哈希,因为普通 SHA-256 的计算成本较低,不适合抵抗离线密码猜测。

8.2 Access Token 如何签发?

NestJS 可以通过 @nestjs/jwt 提供的 JwtService 签发 JWT。

示例:

async issueAccessToken(userId: string) {
  return this.jwtService.signAsync(
    { sub: userId },
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      issuer: 'https://api.example.com',
      audience: 'my-api',
    },
  );
}

这里的 JWT 签名密钥应通过环境变量、密钥管理服务或其他安全机制配置,不应硬编码在源码中。

在 JWT Guard 中,除了验证签名和 exp,还应验证预期的签名算法、issuer、audience 等信息,避免错误地接受不属于当前 API 的令牌。

如果系统有多个微服务需要独立验证 JWT,也可以考虑 RS256、EdDSA 等非对称签名方案,通过签发私钥与验证公钥分离降低密钥分发风险。

8.3 Drizzle 如何存储 Refresh Token?

对于随机生成的 Refresh Token,建议数据库只存储其哈希。

例如:

import { createHash, randomBytes } from 'node:crypto';

function issueRefreshToken(): string {
  return randomBytes(32).toString('base64url');
}

function hashRefreshToken(token: string): string {
  return createHash('sha256')
    .update(token)
    .digest('hex');
}

生成 R1 后:

const r1 = issueRefreshToken();
const tokenHash = hashRefreshToken(r1);

将 tokenHash 保存到 PostgreSQL,而不是保存 R1 的明文。

对于由安全随机数生成器产生的高熵 Refresh Token,SHA-256 哈希是合理的存储方式。这与用户密码的存储场景不同。

8.4 数据库表如何设计?

Drizzle Schema 可以包含:

import {
  pgTable,
  uuid,
  varchar,
  timestamp,
} from 'drizzle-orm/pg-core';

export const refreshTokens = pgTable(
  'refresh_tokens',
  {
    id: uuid('id')
      .defaultRandom()
      .primaryKey(),

    userId: uuid('user_id').notNull(),

    familyId: uuid('family_id').notNull(),

    tokenHash: varchar('token_hash', {
      length: 64,
    }).notNull().unique(),

    expiresAt: timestamp('expires_at', {
      withTimezone: true,
    }).notNull(),

    absoluteExpiresAt: timestamp(
      'absolute_expires_at',
      { withTimezone: true },
    ).notNull(),

    usedAt: timestamp('used_at', {
      withTimezone: true,
    }),

    revokedAt: timestamp('revoked_at', {
      withTimezone: true,
    }),
  },
);

这里省略了用户表关联和部分索引配置,重点展示会话字段。

字段 作用
userId 关联用户
familyId 标识同一次登录产生的令牌家族
tokenHash Refresh Token 的 SHA-256 摘要
expiresAt 当前令牌或会话的空闲截止时间
absoluteExpiresAt 会话的最终截止时间
usedAt 判断令牌是否已经参与过轮换
revokedAt 判断令牌是否已经被撤销

为什么需要 familyId?

因为一次登录可能经历多次刷新:

Session S1
   |
   ├── R1(已使用)
   ├── R2(已使用)
   ├── R3(已使用)
   └── R4(有效)

它们都属于同一个刷新令牌家族。

一旦检测到 R1 或 R2 被重放,系统可以撤销家族中仍然有效的 R4,而不是仅仅拒绝当前请求。

8.5 Rotation 必须保证原子性

假设客户端几乎同时发出两个刷新请求:

Request A:使用 R1
Request B:使用 R1

如果服务端先查询 R1 是否有效,再分别更新,那么两个请求可能同时认为 R1 有效,并各自签发新的 Refresh Token。

这就是典型的并发竞争问题。

因此,Rotation 的关键数据库操作必须具有原子性。

在 PostgreSQL 中,可以使用带条件的 UPDATE:

UPDATE refresh_tokens
SET used_at = NOW()
WHERE token_hash = $1
  AND used_at IS NULL
  AND revoked_at IS NULL
  AND expires_at > NOW()
  AND absolute_expires_at > NOW()
RETURNING user_id, family_id, absolute_expires_at;

只有成功更新原记录的请求才允许继续签发新的刷新令牌。

在同一个数据库事务中,服务端需要完成:

  1. 原子性地消费旧 Refresh Token。
  2. 验证成功更新的记录及会话状态。
  3. 生成并插入新 Refresh Token 的哈希。
  4. 提交事务后返回新的令牌。

如果任何一步失败,事务应该回滚。

Drizzle 可以通过以下接口执行事务:

await db.transaction(async (tx) => {
  // 1. 原子更新旧令牌
  // 2. 检查更新结果
  // 3. 插入新令牌
  // 4. 事务提交后返回结果
});

这里展示的是事务边界,而非完整的 Rotation 实现。

如果旧令牌已经被使用,还需要进一步识别其令牌家族,并按策略撤销活动会话。

服务端同时需要考虑旧令牌记录的保留期限。如果轮换后立即物理删除旧记录,就可能失去检测旧令牌重放的能力。


九、前端如何自动刷新 Token?

双 Token 不仅涉及 NestJS 后端,也涉及客户端请求管理。

例如 Vue 或 React 前端使用 Axios 调用 API。

典型处理流程如下:

客户端请求业务 API
         |
         v
携带 Access Token
         |
         v
NestJS JWT Guard
         |
    +----+----+
    |         |
  有效      401
    |         |
返回数据      v
         检查是否可以刷新
                 |
                 v
          POST /auth/refresh
                 |
            +----+----+
            |         |
           成功      失败
            |         |
       更新令牌     清理登录状态
            |         |
       重试原请求   引导重新登录

一个常见的工程问题是:多个 API 同时遇到 Access Token 过期。

例如页面并行调用:

GET /users/me
GET /posts
GET /notifications

如果三个请求都触发独立刷新,它们可能同时提交相同的 R1。

由于 R1 是一次性令牌,这可能导致合法请求被判断为重放攻击。

因此,前端通常需要使用 Single-flight 模式:

  • 第一个请求发起刷新;
  • 其他请求等待同一个刷新 Promise;
  • 刷新成功后,所有等待的请求使用新的 Access Token;
  • 刷新失败时,统一清理登录状态。

如果用户同时打开多个浏览器标签页,还需要进一步考虑跨标签页协调,例如通过 BroadcastChannel 或其他可靠的客户端协调机制避免并发轮换。

对于网络超时、响应丢失等情况,也要设计明确的重试策略。

因为可能发生这样的问题:

客户端发送 R1
         ↓
服务端成功轮换为 R2
         ↓
服务端提交事务
         ↓
响应在网络中丢失
         ↓
客户端不知道 R2
         ↓
客户端重试 R1
         ↓
服务端检测到旧令牌

这并不一定代表存在真实攻击。

因此,生产环境需要平衡严格的重放检测和合法网络重试。不能简单地在所有场景中忽略旧令牌重复使用,也不能假设每一次重放都一定是攻击者造成的。


十、浏览器应该把 Token 存在哪里?

这是安全性影响非常大、但经常被忽略的问题。

10.1 不建议长期保存在 localStorage

例如:

localStorage.setItem(
  'refreshToken',
  refreshToken,
);

这种方式虽然方便,但同源 JavaScript 可以读取 localStorage。

如果应用存在 XSS 漏洞,攻击者注入的恶意脚本也可能读取其中的令牌。

因此,不建议在浏览器的 localStorage 或 sessionStorage 中存储长期身份认证凭证。

10.2 更合适的浏览器方案

对于需要前端直接使用 Access Token 的 SPA,可以考虑:

  • Access Token 保存在应用运行时内存中;
  • Refresh Token 保存在 HttpOnly + Secure + SameSite Cookie 中;
  • Refresh Token 只用于身份认证服务;
  • 通过 HTTPS 传输全部认证请求。

例如:

Set-Cookie: refreshToken=<token>;
            HttpOnly;
            Secure;
            SameSite=Strict;
            Path=/auth

该配置仅用于说明 Cookie 属性,实际 Path、Domain 和 SameSite 需要根据前后端部署方式确定。

HttpOnly 可以阻止普通 JavaScript 直接读取 Cookie,但它不能阻止 XSS 在用户浏览器中发起已认证的请求。

同时,Cookie 会自动随符合条件的请求发送,因此需要考虑 CSRF 防护。

对于完全由同一组织控制的浏览器 Web 应用,还可以考虑 Backend-for-Frontend(BFF)架构,让浏览器只持有服务端 Session Cookie,而不直接接触 Access Token 和 Refresh Token。

没有一种存储方式可以单独解决所有攻击问题。


十一、Access Token、Refresh Token 与 Session 的关系

理解这三个概念之后,身份认证体系会更加清晰。

Access Token 是业务访问凭证。

它解决:

当前请求是否携带有效的访问凭证?

Refresh Token 是续期凭证。

它解决:

Access Token 失效之后,是否允许在不重新输入密码的情况下继续获取访问凭证?

Session 是更高层的登录会话概念。

它解决:

这次登录是什么时候建立的?属于哪个用户或设备?是否已过期、被撤销或超过安全策略规定的期限?

三者可以关联,但并不必须同时存在。

例如,传统 Session Cookie 系统完全可以不使用 JWT。

另一方面,服务间调用也可能只使用短期 Access Token,不提供 Refresh Token。

对于普通浏览器后台,集中管理的 Session Cookie 可能比自建双 Token 简单。

对于移动应用、多客户端 API 或需要独立资源服务验证凭证的系统,双 Token 方案可能更适合。

选择认证架构时,应该从客户端类型、部署模式、安全边界和撤销需求出发,而不是先假设 JWT 一定优于 Session。


十二、生产环境中的安全最佳实践

在 NestJS + Drizzle 项目中,建议将以下要求纳入认证模块的设计和测试。

12.1 默认保护业务接口

可以通过 NestJS 全局 Guard 实现默认认证:

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
];

然后为注册、登录等匿名接口显式标记 @Public()。

这样可以降低开发人员遗漏 @UseGuards() 而意外暴露业务接口的风险。

NestJS 新版官方认证模块也提供了全局认证和相关能力;本文采用的 JwtModule + Guard 属于便于理解和定制的显式实现方式。

12.2 区分认证和授权

认证回答:

用户是谁?

授权回答:

用户是否有权操作这条资源?

例如,创建文章时不应该直接相信客户端提交的 authorId。

应该从已经验证的用户身份中获取作者 ID。

修改文章时,还需要在数据库查询条件中限制作者:

await db
  .update(posts)
  .set(updateDto)
  .where(
    and(
      eq(posts.id, postId),
      eq(posts.authorId, currentUser.id),
    ),
  );

这样可以在数据库更新时直接执行所有权限制。

12.3 区分 401 和 403

  • 401 Unauthorized:尚未提供有效认证凭证,或者凭证无效、过期。
  • 403 Forbidden:身份已得到认证,但不具备执行该操作所需的权限。

对于需要避免泄露资源是否存在的场景,也可以有意识地对无权限资源返回 404。

12.4 保护登录接口

登录接口需要考虑:

  • 限流和暴力破解防护;
  • 密码 Argon2id 哈希存储;
  • 不泄露邮箱是否存在的错误响应;
  • 安全审计与异常登录监测;
  • 根据风险增加 MFA;
  • 密码重置后的会话撤销策略。

12.5 管理密钥及其轮换

JWT 密钥应当:

  • 从安全环境配置或密钥管理系统加载;
  • 具备足够的随机性与强度;
  • 不提交到 Git 仓库;
  • 支持轮换;
  • 必要时通过 kid 区分不同密钥版本。

同时,服务端必须限定允许的 JWT 签名算法,不能不加约束地信任令牌 Header 中声明的算法。

12.6 测试令牌生命周期

建议至少覆盖以下情况:

测试场景 预期结果
Access Token 有效 可以访问授权的业务 API
Access Token 过期 返回 401
Access Token 签名错误 返回 401
Access Token 的 audience 错误 返回 401
Refresh Token 有效 可以成功刷新
Refresh Token 已过期 拒绝刷新
Refresh Token 已被使用 拒绝并执行重放处理策略
两个请求同时使用相同 Refresh Token 最多一个成功轮换
用户主动登出 不允许继续刷新被撤销的会话
用户删除账号 不能继续通过该账号续期
Session 超过绝对期限 拒绝继续刷新
用户修改他人文章 返回 403 或按策略返回 404

对于安全敏感的身份认证系统,这些测试与业务 CRUD 的正确性测试同样重要。


十三、一个值得注意的问题:双 Token 是否一定更安全?

不能简单地说双 Token 一定比单 Token 更安全。

双 Token 的安全收益,来自其合理的生命周期设计:

  • 日常访问使用短时效 Access Token;
  • 长期续期凭证减少暴露;
  • Refresh Token 可以撤销和轮换;
  • 通过会话管理限制长期凭证滥用;
  • 通过重放检测发现部分令牌泄露场景。

如果把 Access Token 和 Refresh Token 都明文保存在 localStorage 中,或者使用永不过期、不可撤销的 Refresh Token,那么双 Token 并没有真正实现预期的安全目标。

相反,它还增加了系统复杂度与新的攻击面。

因此,真正重要的不是“有两个 Token”,而是:

两个 Token 是否拥有不同的权限、生命周期、存储方式与撤销策略。


十四、推荐的 NestJS + Drizzle 认证方案

对于一个具有 Web 前端、独立 API 和 PostgreSQL 数据库的业务系统,可以采用以下设计作为评估起点:

配置项 建议策略
Access Token JWT
Access Token 有效期 15 分钟
Refresh Token 256 位安全随机令牌
Refresh Token 数据库存储 SHA-256 哈希
Rotation 每次成功刷新后轮换
空闲会话期限 示例为 7 天
会话绝对期限 示例为 30 天
用户密码 Argon2id
认证方式 NestJS 全局 Guard
数据持久化 Drizzle + PostgreSQL
刷新并发控制 数据库事务与原子条件更新
浏览器存储 安全 Cookie 或 BFF 方案
HTTPS 生产环境必须启用

这些数值是工程设计示例,不是通用的安全标准。

如果选择严格的 30 天会话截止时间,还需要注意:在绝对期限临近时新签发的 Access JWT,不应在实际授权语义上突破这个截止时间。可以将 JWT 的 exp 限制在会话截止时间以内,或者在受保护 API 中额外校验相关会话状态。

最后,若系统实际使用 OAuth 2.0,尤其是浏览器公共客户端,应进一步遵循 RFC 9700 和 RFC 10017,而不应把自行设计的一方登录方案直接等同于标准 OAuth 授权服务器的全部要求。


十五、总结

经过上述分析,可以把 JWT 双 Token 身份认证机制归纳为六个核心结论。

第一,Access Token 负责访问,Refresh Token 负责续期。

两者职责不同,因此不应该把 Refresh Token 当作普通业务 API 的访问凭证。

第二,短时效 Access Token 可以降低单次访问令牌泄露后的风险窗口。

但如果 Refresh Token 或客户端执行环境同样被攻击者控制,短时效 Access Token 并不能单独阻止持续攻击。

第三,Refresh Token Rotation 的关键不是简单地“生成新字符串”,而是使旧令牌失效、保留令牌家族关系,并具备重放检测和撤销能力。

第四,刷新令牌轮换不会自动使旧 Access JWT 失效。

如果需要即时撤销访问权限,必须设计额外的验证或撤销机制。

第五,7 天有效期不必然等于 7 天最长登录期限。

必须区分空闲超时、令牌本身的过期时间和整个会话的绝对截止时间。同时,采用 OAuth 2.0 的系统需要遵循适用规范对令牌轮换与有效期的约束。

第六,双 Token 不是唯一正确的身份认证方案。

对于传统 Web 应用,服务端 Session + 安全 Cookie 同样可能是更简单、更合适的选择。

归根结底,身份认证系统真正需要解决的,不只是“如何证明用户已经登录”,而是:

如何安全地建立会话、持续验证身份、控制访问权限,并在凭证过期、泄露、撤销或用户登出时,以可预测、可验证的方式终止访问。

理解了这个目标,也就真正理解了 Access Token、Refresh Token 与 Rotation 的设计意义。


参考资料

  1. RFC 7519 — JSON Web Token (JWT)
  2. RFC 9700 — Best Current Practice for OAuth 2.0 Security
  3. RFC 10017 — OAuth 2.0 for Browser-Based Applications
  4. NestJS 官方文档:Authentication
  5. NestJS 官方文档:Drizzle ORM
  6. Drizzle ORM:Transactions
  7. OWASP:Session Management Cheat Sheet
  8. OWASP:Password Storage Cheat Sheet
posted @ 2026-10-10 13:49  ZhengQC  阅读(20)  评论(0)    收藏  举报