JWT 入门:结构是什么、jjwt 怎么用、有哪些坑要注意
📌 先说结论
JWT(JSON Web Token)就是一段经过签名的字符串,用来在客户端和服务端之间传递身份信息。
它解决什么问题?传统 Session 需要服务端存一份登录状态,多台服务器还要共享。JWT 把信息直接编码在 token 里,服务端不用存,只要验签就知道你是谁。
三句话记住它的特性:
- 不是加密的—— 里面的内容谁都能解码看到,只是防篡改(改了签名就对不上)
- 无状态—— 服务端不存,所以没法主动让某个 token 立即失效
- 靠签名验证—— 服务端用密钥重新算一遍签名,对得上才认
一、JWT 长什么样
1.1 三段结构
一个 JWT 就是三个用.连接的字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6IuW8oOS4iSJ9.xxx └──────── header ────────┘ └──────── payload ────────┘ └─ signature ─┘| 段 | 内容 | 举例 |
|---|---|---|
| Header | 用什么算法、什么类型 | {"alg":"HS256","typ":"JWT"} |
| Payload | 存放的数据 | {"sub":"1001","name":"张三"} |
| Signature | 签名,防止篡改 | 对前两段用密钥算的 HMAC |
1.2 前两段可以直接解码看
这是最重要的一点:header和payload都只是 Base64 编码,不是加密。
用 jjwt 生成一个带手机号的 token,然后直接解码 payload:
Stringtoken=Jwts.builder().subject("1001").claim("phone","13800138000")// ← 塞了手机号.signWith(KEY).compact();// 不验签,直接解码第二段Stringpayload=newString(Base64.getUrlDecoder().decode(token.split("\\.")[1]));System.out.println(payload);实测输出:
直接解码: {"sub":"1001","phone":"13800138000"} → 手机号明文可见!签名只防篡改,不防窥探⚠️绝对不要在 payload 里放密码、手机号、身份证、银行卡号。任何拿到 token 的人都能看到。
1.3 签名在干什么
signature = HMAC-SHA256(header + "." + payload, 密钥)服务端验证的流程:
拿到 token → 拆三段 → 用密钥重新算一遍签名 → 和自己带的签名对比 ├─ 一致 → 内容没被改过,可信 └─ 不一致 → 有人动过手脚,拒绝关键:服务端不需要存任何东西,只要密钥在,就能独立验证。这就是"无状态"。
二、jjwt 怎么用(最常用的库)
Java 里最流行的是jjwt。下面全是实测通过的代码。
2.1 加依赖(Maven)
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.12.6</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.12.6</version><scope>runtime</scope></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.6</version><scope>runtime</scope></dependency>💡三个包都要:
jjwt-api是接口,jjwt-impl是实现,jjwt-jackson负责 JSON 序列化。只加 api 会运行报错(我实测过,会抛ServiceConfigurationError)。
2.2 生成密钥
importio.jsonwebtoken.security.Keys;importjavax.crypto.SecretKey;// 生成一个符合 HS256 要求的密钥(256 位)SecretKeyKEY=Keys.secretKeyFor(SignatureAlgorithm.HS256);⚠️密钥必须够长。HS256 要求至少 256 位(32 字节),用短密钥会直接抛
WeakKeyException。生产环境不要把密钥硬编码在代码里,应该放配置中心或环境变量。
2.3 生成 token
importio.jsonwebtoken.Jwts;importjava.util.Date;Stringtoken=Jwts.builder().subject("1001")// sub:用户ID.claim("name","张三")// 自定义字段.claim("role","admin").issuedAt(newDate())// iat:签发时间.expiration(newDate(System.currentTimeMillis()+30*60*1000))// exp:30分钟后过期.signWith(KEY)// 用密钥签名.compact();// 生成 token 字符串System.out.println(token);2.4 验证 token
importio.jsonwebtoken.*;importio.jsonwebtoken.security.Keys;JwtParserparser=Jwts.parser().verifyWith(KEY)// 用同一个密钥验签.build();Claimsclaims=parser.parseSignedClaims(token).getPayload();System.out.println(claims.getSubject());// 1001System.out.println(claims.get("name"));// 张三System.out.println(claims.get("role"));// admin实测输出:
1) 生成和校验 token: token 长度: 148 字符 subject=1001 name=张三 role=admin💡注意 API 版本:
jjwt 0.12.x用了新 API(verifyWith/parseSignedClaims)。老教程里的setSigningKey/parseClaimsJws是 0.11 及以前的写法,在 0.12 里会编译报错。
2.5 处理验证失败
验签可能因为各种原因失败,必须区分处理:
try{Claimsclaims=parser.parseSignedClaims(token).getPayload();// 验证通过,正常业务}catch(ExpiredJwtExceptione){// token 过期了 → 让用户重新登录thrownewAuthException("登录已过期,请重新登录");}catch(SignatureExceptione){// 签名不对 → 有人篡改,或密钥不对thrownewAuthException("token 无效");}catch(JwtExceptione){// 其他 JWT 格式问题thrownewAuthException("token 格式错误");}实测:过期和篡改分别抛什么异常
2) 过期时间: 30秒后过期: 通过 ✓ 已过期: 失败 → 已过期 3) 篡改检测: 正确密钥: 通过 ✓ 错误密钥: 失败 → SignatureException 篡改 payload: 失败 → SignatureException⚠️千万别只写
catch (Exception e)然后放行——验签失败必须拒绝请求,这是安全底线。
三、JWT 的标准字段
除了自定义字段,JWT 有一组约定的标准字段:
| 字段 | 全称 | 含义 | 示例 |
|---|---|---|---|
sub | subject | 主体,通常是用户 ID | "1001" |
exp | expiration time | 过期时间(秒级时间戳) | 1790000000 |
iat | issued at | 签发时间 | 同上 |
iss | issuer | 签发者(哪个服务发的) | "my-app" |
aud | audience | 接收方(给谁用) | "web-client" |
jti | JWT ID | token 唯一标识(可用于黑名单) | "uuid-1234" |
代码示例:
Datenow=newDate();Stringtoken=Jwts.builder().subject("1001").issuer("my-app")// iss.audience().add("web-client").and()// aud.issuedAt(now)// iat.expiration(newDate(now.getTime()+30*60*1000))// exp.id("uuid-1234")// jti.signWith(KEY).compact();Claimsc=parser.parseSignedClaims(token).getPayload();System.out.println(c.getSubject());// 1001System.out.println(c.getIssuer());// my-appSystem.out.println(c.getAudience());// [web-client]System.out.println(c.getId());// uuid-1234💡
sub和exp是必用的:sub标识用户身份,exp控制有效期。jti在需要"主动踢人"时有用(见下一章)。
四、五个必须知道的坑
4.1 坑一:payload 放敏感信息
已在 1.2 说明。payload 是明文可读的,只放 userId、角色编码这类不敏感标识。
需要展示的用户信息,用 userId 去查缓存/数据库,不要塞 token 里。
4.2 坑二:token 没法主动失效(最头疼的坑)
这是 JWT 最大的架构代价。
用户点"退出登录",token 从客户端删了,但服务端并不知道——旧 token 在过期前仍然有效。
同样的问题:
- 管理员封禁某用户,他的 token 还是有效的;
- 用户改了密码,旧 token 依然能用;
- token 泄露了,没法立刻作废。
三种主流解法:
| 方案 | 做法 | 代价 |
|---|---|---|
| 短有效期 + Refresh Token | access token 5~15 分钟,refresh token 换新的 | 需要维护 refresh 的存储 |
| Redis 黑名单 | 把要作废的jti存 Redis,验签后再查一次 | 退化成"有状态" |
| token 版本号 | payload 放ver,改密码/封禁时数据库ver + 1 | 每次请求多查一次版本号 |
💡实践最常见组合:短有效期(15 分钟)+ Refresh Token + 黑名单兜底。
不要指望 JWT 既能无状态又能即时吊销——这是协议本身的取舍。
4.3 坑三:验签失败却放行了
// ❌ 灾难写法:异常被吞掉,未登录也能访问try{Claimsclaims=parser.parseSignedClaims(token).getPayload();}catch(Exceptione){// 什么都不做,继续往下走 → 越权!}// ✅ 正确:验签失败必须中断try{Claimsclaims=parser.parseSignedClaims(token).getPayload();// 正常业务}catch(JwtExceptione){thrownewAuthException("token 无效");// 拒绝请求}4.4 坑四:算法不固定,被alg=none绕过
攻击原理:JWT 的 header 是攻击者可写的。如果服务端"按 header 里声明的算法来验签",攻击者把alg改成none,某些库会认为"不需要验签"直接放行。
正确做法:算法由服务端固定,不看 header 的alg。
// ✅ jjwt 的 verifyWith(KEY) 已经强制用你指定的密钥,不受 header 影响JwtParserparser=Jwts.parser().verifyWith(KEY)// 固定密钥 = 固定算法.build();💡用 jjwt 的正确 API 就天然安全。危险的是自己解析 header 再动态选算法的写法。
4.5 坑五:token 越用越长
payload 里塞的字段越多,token 越长。每次请求都要放在 HTTP Header 里,太长有风险:
- 超过服务器 Header 大小限制(常见 8KB)→ 请求被拒;
- 带宽浪费。
建议:payload 只放标识类信息(userId、角色、过期时间),不放完整用户对象。
五、JWT vs Session 怎么选
| 维度 | Session(服务端存) | JWT(客户端存) |
|---|---|---|
| 服务端存储 | 有(内存/Redis) | 无 |
| 横向扩展 | 需要共享存储 | ✅ 天然支持 |
| 主动失效 | ✅ 删 session 就行 | ❌ 困难(要黑名单) |
| 信息更新 | ✅ 立即生效 | ❌ 要等 token 过期 |
| 体积 | 小(只传 sessionId) | 大(每次传整个 payload) |
| 适合场景 | 后台系统、要即时登出 | 分布式、移动端、跨域 |
一句话选型:
需要"随时踢人下线" → Session;需要"多服务无状态验证" → JWT。
别被"无状态"忽悠:JWT 换掉的正是"即时可控性",换来的是"扩展性"。看你的业务更在乎哪个。
六、面试常问的 6 个问题
1. JWT 的三段结构是什么?哪一段加密了?
答:header.payload.signature。三段都没有加密——header 和 payload 只是 Base64URL编码(可直接解码看明文),signature 是对前两段的 HMAC/签名。JWT 提供的是完整性(防篡改),不是保密性。要加密得用 JWE。
2. JWT 怎么防止篡改?
答:靠签名。服务端用密钥对header.payload重新计算签名,和 token 里的签名比对。改了任何内容,签名就对不上。实测:篡改 payload 后验签抛SignatureException。
3. JWT 能主动失效吗?为什么?
答:原生不能。JWT 是无状态的,服务端不存任何东西,所以客户端删了 token 后服务端仍认旧 token 直到过期。解法:① 短有效期 + Refresh Token;② Redis 黑名单存被吊销的jti;③ payload 带版本号,吊销时数据库版本 + 1。
4.sub、exp、iat分别是什么?
答:sub(subject)主体标识,通常是用户 ID;exp(expiration time)过期时间戳;iat(issued at)签发时间。exp必须校验,注意客户端和服务端时钟偏差(一般容忍 30~60 秒)。
5. JWT 和 Session 怎么选?
答:JWT 无状态、易横向扩展,但不支持即时失效、每次请求携带体积大;Session 有状态、需要共享存储,但能即时踢人、信息更新立即生效。看重扩展性和跨域用 JWT;看重即时控制用 Session。
6.alg=none攻击是什么?怎么防?
答:攻击者在 header 里把alg改成none,诱导服务端跳过验签。防御原则是"算法由服务端固定,不信任 token 里的alg"。用 jjwt 的verifyWith(KEY)就是安全的(密钥固定了算法)。
七、总结
五句话:
- 结构——
header.payload.signature,前两段明文可解码,签名只防篡改不防窥探。 - 用法—— jjwt 三件套依赖;
Jwts.builder()生成,Jwts.parser().verifyWith(KEY).build()验证。 - 必用字段——
sub(用户 ID)、exp(过期时间)。 - 最大的坑——无法主动失效,要配短有效期 + Refresh Token + 黑名单。
- 安全底线——验签失败必须拒绝,payload 不放敏感信息。
一句口诀:
JWT 是"自包含的通行证"——它证明你是谁,但不保证内容保密,也没法随时作废。
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~
关注我,后续分享更多 Java 核心知识点和实战技巧!