JWT 深度解析:从认证原理到双 Token 实战

JWT 不是认证方案,是一个 Token 格式。把这两件事分开,很多困惑就消失了。


目录


JWT 到底是什么

JWT(JSON Web Token)的本质是一段经过签名的 JSON 数据。用户登录成功后,服务端把用户信息打包成 JSON,加上签名,发给客户端。客户端之后每次请求都带上这个 Token,服务端验签后直接读取用户信息,不需要查数据库。

三段式结构

一个 JWT 长这样:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTcwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

. 分成三段,每段都是 Base64URL 编码:

Header(算法和类型):

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

alg 指定签名算法,typ 固定为 JWT。这段 JSON Base64URL 编码后就是 Token 的第一段。

Payload(业务数据):

{
  "userId": 42,
  "username": "zhangsan",
  "role": "admin",
  "exp": 1717000000,
  "iat": 1716996400
}

Payload 是你自定义的数据。exp 是过期时间(Unix 时间戳),iat 是签发时间。JWT 规范定义了一批标准字段(Registered Claims),常用的有:

字段 含义 必填
iss 签发者
sub 主题(通常是用户标识)
exp 过期时间 建议必填
iat 签发时间
jti Token 唯一标识(用于黑名单) 建议必填

Signature(签名):

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)

签名的计算方式是:把 Header 和 Payload 的 Base64URL 编码用 . 拼接,然后用指定算法和密钥做哈希。

签名的本质不是加密,是防篡改。Payload 是明文的——Base64URL 可以逆向解码,任何人拿到 Token 都能看到里面的内容。但如果没有 secret_key,就无法生成合法的签名。服务端验签失败就知道 Token 被改过了。

一句话:JWT 让你能在不查数据库的情况下验证"这段数据是我说的,而且没被改过"。

对称签名 vs 非对称签名

签名方式 算法 特点 适用场景
对称签名 HS256/HS384/HS512 签发和验证用同一个密钥,速度快 单体应用、服务端自用
非对称签名 RS256/ES256 私钥签发、公钥验证,密钥管理更安全 微服务(多个服务只需公钥)

选型建议:单服务用 HS256 足够——一个密钥,签发验证都用它,简单直接。多服务验证同一个 Token 用 RS256——把公钥分发给各下游服务,私钥只留在签发服务。这样即使某个下游服务被攻破,攻击者也拿不到私钥,无法伪造 Token。


技术选型:Session vs JWT vs OAuth2

这三个东西不是同一层级的,拿来比较需要先厘清关系。

本质区别

维度 Session JWT OAuth2
是什么 服务端会话管理机制 Token 格式 授权框架
状态 有状态(服务端存 Session) 无状态(客户端带 Token) 取决于 Token 类型
存储 服务端内存/Redis 客户端(localStorage/Cookie) 授权服务器管理
扩展性 需要 Session 共享(Redis/粘性会话) 天然跨服务 天然跨系统
主动注销 ✅ 删 Session 即可 ❌ 签发后无法单方面废止 ✅ 通过 Token 撤销端点
传输开销 一个 JSESSIONID Cookie 每次带完整 Token(数百字节到几KB) 取决于 Token 格式

Session 的根本问题

Session 本身没什么毛病,单体应用用 Session 完全没问题。但在分布式环境下,Session 共享是个绕不开的坑。

粘性会话(Sticky Session):同一个用户的请求总是打到同一台机器。问题是负载不均——如果某个用户特别活跃,他的那台机器压力就大。而且机器宕机会丢 Session。

Session 复制:每台机器都存所有 Session 的副本。10 台机器,每台 10 万 Session,同步开销是 O(n²)。机器越多越痛苦。

集中式 Session 存储(Redis):最主流的方案。所有服务都去 Redis 读写 Session。解决了共享问题,但引入了额外的基础设施依赖——Redis 挂了,所有人都登不了。

JWT 的无状态特性天然避开了这些问题。Token 自带用户信息,任何服务都能独立验证(只需要验签),不需要查共享存储。

OAuth2 和 JWT 的关系

OAuth2 不是 JWT 的替代品,是更高层的授权框架。简单说:OAuth2 解决的是"用户授权第三方应用访问自己的资源"这个问题(比如用微信登录某个网站),JWT 是 OAuth2 中 Access Token 的一种常用格式。

Google、GitHub 的 OAuth2 授权返回的 Access Token 就是 JWT。但 OAuth2 也可以用不透明 Token(opaque token)——一串随机字符串,需要回授权服务器查。JWT 的优势是不需要回查。

一句话选型:单体应用用 Session;微服务或跨系统认证用 JWT;需要第三方授权用 OAuth2 + JWT。


JWT + Spring Security 集成流程

整体认证流程

数据库 UserDetailsService SecurityContext JwtAuthenticationFilter 客户端 数据库 UserDetailsService SecurityContext JwtAuthenticationFilter 客户端 登录流程 请求认证流程 POST /api/auth/login (username + password) loadUserByUsername(username) 查询用户 User 对象 UserDetails 校验密码(BCrypt) 生成 Access Token + Refresh Token 返回 {accessToken, refreshToken} GET /api/users/me (Header: Bearer accessToken) 从 Authorization Header 提取 Token 验签 + 解析 Payload 从 Payload 构建 Authentication 对象 SecurityContextHolder.setAuthentication(auth) 认证完成 200 OK + 业务数据

核心组件职责

JwtAuthenticationFilter:继承 OncePerRequestFilter,在每次请求时执行:从 Header 提取 Token → 验签 → 解析用户信息 → 注入 SecurityContext。这个 Filter 在 Spring Security Filter Chain 中的位置很关键——必须在 UsernamePasswordAuthenticationFilter 之前,否则表单登录的 Filter 会先拦截请求。

SecurityContext:存储当前请求的认证信息。默认用 ThreadLocal 存储,每个线程独立。Session 模式下,SecurityContext 存在 Session 里,跨请求持久化;JWT 模式下,SecurityContext 只在当前请求内有效,每个请求都从 Token 重新构建,请求结束后清除。

UserDetailsService:登录时用来查询用户和校验密码。在 JWT 模式下,只有登录这一步需要它——登录成功后签发 Token,后续请求通过 Token 认证,不再查库。这也是 JWT 性能优势的来源:省掉了每次请求查 Session/查数据库的开销。

核心伪代码:

// JwtAuthenticationFilter 核心逻辑
doFilter(request, response, chain):
    header = request.getHeader("Authorization")
    if header == null or !header.startsWith("Bearer "):
        chain.doFilter(request, response)  // 没 Token,继续走后续 Filter
        return

    token = header.substring(7)  // 去掉 "Bearer " 前缀
    try:
        claims = jwtUtil.validateAndParse(token)  // 验签 + 解析
        auth = new UsernamePasswordAuthenticationToken(
            claims.userId, null, AuthorityUtils.createAuthorityList(claims.role)
        )
        SecurityContextHolder.getContext().setAuthentication(auth)
    catch (JwtException e):
        // Token 无效,不设置认证,后续 Filter 会拒绝访问
        log.warn("Invalid JWT: {}", e.getMessage())

    chain.doFilter(request, response)

SecurityContext 的存储策略切换

Spring Security 的 SecurityContextHolder 默认用 ThreadLocal 存储认证信息。在 Web 应用中,一个请求一个线程,所以请求结束后 ThreadLocal 会被清除。

如果你用了 @Async 异步方法或者响应式编程,异步线程拿不到主线程的 ThreadLocal。这时候需要切换策略:MODE_INHERITABLETHREADLOCAL(子线程继承)或显式传递 SecurityContext

对 JWT 来说这不是问题——每个请求都从 Token 重新解析,不依赖跨请求的状态。


JWT 为什么"重"?那些被忽略的缺点

JWT 被吹得很多,但真实缺点很少有人讲清楚。用了之后才发现,无状态的代价比想象中大。

体积问题

Session 模式下,每个请求只带一个 JSESSIONID Cookie,通常 30-40 字节。JWT 的 Payload 里塞了用户信息和权限声明,加上 Header 和 Signature,轻松到 300-800 字节。如果在 Payload 里放了角色列表、权限集合,甚至超过 1KB。

每个请求都带。100 QPS 的系统,一天下来多传几十 MB 的 Token 数据。在内网这不是问题,在移动端弱网环境下,这个开销不可忽略。

无法主动失效

这是 JWT 最大的痛点,也是面试最高频的问题。

Token 签发后,在过期之前,服务端无法单方面让它失效。场景:用户改了密码,想让旧 Token 全部失效。Session 模式下删掉 Session 就行;JWT 模式下,旧 Token 在过期前依然合法。

唯一的解法是引入黑名单(Redis),验签时先查黑名单。但这就引入了有状态存储,JWT 的无状态优势被部分抵消。讽刺的是,很多用了 JWT 的项目最终都加了 Redis 黑名单——那你当初用 JWT 图什么呢?

续签复杂

Session 有容器自动续期——用户活跃时自动延长过期时间,用户无活动时超时清除。开发者不需要操心。

JWT 的过期时间写死在 Payload 的 exp 字段里,签发后不能改。Token 过期了就过期了,你没法给一个已签发的 Token 延期。续签需要额外机制:双 Token 刷新、滑动过期、或者签发短命 Token 配合频繁刷新。每种方案都有复杂度。

Payload 不安全

Base64URL 是编码,不是加密。任何人都能解码 Payload 看到内容:

echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==" | base64 -d
# {"userId":42,"role":"admin"}

Payload 里绝对不能放密码、手机号、身份证号、银行卡号等敏感信息。如果确实需要加密 Payload,用 JWE(JSON Web Encryption),但复杂度会上升一个台阶——密钥管理、加解密性能开销、库的支持度都要考虑。

算法混淆攻击

JWT 最经典的安全漏洞。某些 JWT 库在验签时会读取 Header 里的 alg 字段来决定用什么算法。攻击者可以把 alg 改成 none(无签名),服务端如果没做白名单校验,就会跳过验签直接信任 Payload。

更隐蔽的攻击:对称算法和非对称算法混淆。如果服务端用 RS256(非对称)签发 Token,但验签时信任 Token 自带的 alg 字段,攻击者可以把 alg 改成 HS256(对称),然后用公钥作为 HMAC 的密钥来签名——因为公钥是公开的,攻击者能伪造合法 Token。

防范:永远在服务端硬编码允许的算法白名单,不要信任 Token 自带的 alg

// 错误:信任 Token 自带的算法
Algorithm algorithm = Algorithm.none();  // alg: "none"

// 正确:硬编码算法
JJWT 要求你显式指定:
Jwts.parser()
    .setSigningKey(secretKey)           // 密钥本身就决定了算法
    .requireIssuer("my-app")
    .parseClaimsJws(token);

一句话总结:JWT 用无状态换来了分布式便利,代价是把状态管理的复杂度推给了客户端和业务层。


Token 被盗了怎么办

先说结论:没有任何单一方案能完美解决 Token 被盗问题。你需要的是纵深防御——多层防御叠在一起,每一层挡住不同场景的攻击。

第一层:缩短过期时间

Access Token 设 15-30 分钟过期。Token 被盗后,攻击者能用的窗口很短。过期后 Token 自动失效,不需要任何额外操作。

这是成本最低的防御,但不能阻止实时截获——攻击者在 Token 有效期内拿到就能用。

第二层:Redis 黑名单

用户登出或改密码时,把当前 Token 的唯一标识(jti 字段)加入 Redis 黑名单,过期时间设为 Token 剩余有效期。验签时先查黑名单。

// 登出接口
@PostMapping("/logout")
public void logout(HttpServletRequest request) {
    String token = extractToken(request);
    String jti = jwtUtil.getJti(token);
    long remainingTTL = jwtUtil.getRemainingTTL(token);
    redisTemplate.opsForValue().set("jwt:blacklist:" + jti, "1", remainingTTL, TimeUnit.SECONDS);
}

// Filter 中校验
if (redisTemplate.hasKey("jwt:blacklist:" + claims.getJti())) {
    throw new JwtException("Token has been revoked");
}

局限性:只能废止已知泄露的 Token。如果用户不知道 Token 被盗了,黑名单就没用。而且引入了 Redis 依赖,JWT 的无状态优势被部分抵消。

第三层:Token 绑定

把 Token 和客户端特征绑定。验签时比对特征,不匹配则拒绝。

常用的绑定维度:

维度 实现方式 优点 缺点
IP 地址 Payload 加 ip 字段 简单直接 用户换网络就失效
设备指纹 浏览器指纹 / 设备 ID 指纹稳定性好 生成逻辑复杂
User-Agent 哈希 取 UA 的 hash 轻量 UA 可被伪造

实际项目中,IP 绑定最常用但体验最差——用户从 WiFi 切到 4G,IP 就变了,Token 直接失效。一个折中方案是只绑定到 /24 网段(前三段 IP 相同就算匹配),而不是精确到单个 IP。

第四层:HTTPS 传输加密

Token 在网络传输中被截获(中间人攻击)是最基本的攻击方式。HTTPS 是最基础的防线——加密传输内容,防止中间人窃听。

在开发环境用 HTTP 没问题,但生产环境必须强制 HTTPS。配置层面:Nginx 层做 HTTP → HTTPS 重定向,Spring Security 层配置 requiresSecure()

HTTPS 不能防止 Token 在客户端被窃取(比如 XSS 攻击从 localStorage 读取),所以它只是防御链中的一环,不是全部。

防御层级对比

防御层 保护范围 实现成本 局限性
短过期时间 缩小攻击窗口 低(改配置) 不能阻止实时截获
Redis 黑名单 主动废止已知泄露 中(引入 Redis) 无法防未知泄露
Token 绑定 防止异地/异设备使用 中(改 Filter) IP 变动影响体验
HTTPS 防传输层截获 低(运维层面) 不能防客户端窃取

四层都做,才构成纵深防御。没有银弹。


双 Token 刷新机制

为什么需要两个 Token

Access Token 短命(15-30 分钟),用来干活——每次请求带上它认证身份。Refresh Token 长命(7-30 天),用来续命——Access Token 过期后,用它换一个新的。

分开的核心好处是攻击面隔离:Access Token 随每个请求传输,暴露面大,所以要短命;Refresh Token 只在刷新时使用,传输频率低,暴露面小得多。即使 Access Token 被截获,攻击窗口只有 15 分钟;Refresh Token 不会随普通请求传输,安全性高得多。

完整流程

Redis/DB 服务端 客户端 Redis/DB 服务端 客户端 登录:签发双 Token 正常请求:只用 Access Token Access Token 过期:用 Refresh Token 换新 Refresh Token 也过期:重新登录 POST /login (username + password) 校验密码(BCrypt) 生成 Access Token (15min) + Refresh Token (7d) 存储 Refresh Token 关联关系 {accessToken, refreshToken} GET /api/data (Bearer accessToken) 验签 Access Token(本地校验,不查库) 200 OK + 业务数据 POST /auth/refresh (refreshToken) 查找 Refresh Token 是否存在且未过期 有效 删除旧 Refresh Token,存入新 Refresh Token 签发新 Access Token + 新 Refresh Token {newAccessToken, newRefreshToken} POST /auth/refresh (refreshToken) 查找 Refresh Token 不存在 / 已过期 401 Unauthorized → 跳转登录页

Refresh Token 的存储策略

Access Token 放客户端内存或 localStorage,不需要服务端存储——服务端只需要验签就能确认合法性。

Refresh Token 必须存数据库或 Redis。原因:需要支持主动废止。用户改密码时,你希望把该用户所有设备的 Refresh Token 都删掉,强制所有设备重新登录。如果 Refresh Token 不存服务端,你就没法废止它。

存储方案:

// 登录时存储
redisTemplate.opsForValue().set(
    "refresh_token:" + userId + ":" + deviceId,
    refreshToken,
    7, TimeUnit.DAYS
);

// 刷新时校验 + 轮转
String stored = redisTemplate.opsForValue().get("refresh_token:" + userId + ":" + deviceId);
if (!stored.equals(incomingRefreshToken)) {
    // 可能是旧 Token 被重放,或者泄露后被攻击者使用
    // 安全策略:删除该用户所有 Refresh Token,强制全设备重新登录
    deleteAllRefreshTokens(userId);
    throw new SecurityException("Refresh token reuse detected");
}

Refresh Token 轮转(Rotation)

每次使用 Refresh Token 换新 Token 时,旧的 Refresh Token 立即失效,签发一个新的返回给客户端。这样即使 Refresh Token 被截获,攻击者用了一次后就废了。

如果攻击者先于用户使用了被盗的 Refresh Token,用户下次刷新会发现 Token 已失效——此时系统应该删除该用户的所有 Refresh Token,强制重新登录。这是一个安全信号:说明可能有泄露。

并发刷新问题

一个边界场景:多个请求同时发现 Access Token 过期,同时用 Refresh Token 去刷新。如果 Refresh Token 已经轮转了,第二个请求会失败。

解决思路:客户端加一个刷新锁——多个请求同时发现过期时,只发一个刷新请求,其他请求等刷新完成后用新 Token 重试。或者服务端给旧 Refresh Token 设一个短暂的宽限期(比如 30 秒),在宽限期内旧 Token 仍然有效。


什么时候不该用 JWT

JWT 不是万能钥匙。以下场景,Session 反而更好。

短生命周期的单体应用。内部管理系统、管理后台,用户量小、单机部署。Session 加一个 Redis 就够了,不需要 JWT 的无状态特性。引入 JWT 反而增加了 Token 管理的复杂度——刷新逻辑、黑名单、Token 存储,这些都是 Session 不需要操心的事。

需要实时废止的场景。管理员踢人、封禁账号、强制下线。JWT 签发后到过期前无法单方面废止,除非引入黑名单。但引入黑名单后,JWT 的无状态优势就被抵消了大半——你又回到了"每次请求都要查一下"的模式。

纯 Web 应用 + 单域名。Cookie + Session 天然适配:浏览器自动携带 Cookie,HttpOnly 防 XSS 读取,SameSite 防 CSRF,Secure 强制 HTTPS。这些安全属性 Cookie 原生支持,而 JWT 的 Token 存在 localStorage 里反而更容易被 XSS 攻击窃取。

什么时候该用 JWT

  • 微服务间认证:A 服务调 B 服务,Token 自带用户信息,B 不需要回查 A 的认证中心。验签就够了。
  • 移动端 API:移动端不适合用 Cookie(原生 APP 没有浏览器 Cookie 管理),Token 放 Authorization Header 更通用。
  • 跨系统 SSO:一个 Token,多个系统共享认证。签发一次,到处验证——只需要分发公钥。
  • 无状态网关:API Gateway 只验签不查库,性能好。不需要维护 Session 存储。

总结

场景 推荐方案
单体应用、内部系统 Session + Redis
微服务、跨系统认证 JWT(RS256)
移动端 API JWT + 双 Token
第三方授权 OAuth2 + JWT
需要实时废止 Session 或 JWT + Redis 黑名单
跨系统 SSO JWT + 公钥分发

JWT 的正确定位:一个好用的 Token 格式,不是完整的认证方案。 它解决了"怎么在无状态条件下安全传递用户信息"这个问题,但注销、续签、Token 被盗这些事,需要你自己设计。用对了很香,用错了比 Session 还麻烦。

选型时别被"JWT 更现代"这种话带着走。技术选型看的是场景,不是新旧。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐