前一阵在内部项目里给新同事讲登录联调的流程,他对着返回的一长串token问我:“这个字符串我看着像乱码,前端要拿它做什么?”我说你先把这个串丢到jwt.io上看一眼,他看完之后恍然大悟。其实很多人每天都在用JWT做登录验证,但真被问到“这个token是怎么拼出来的、为什么这么设计、解析的时候到底做了什么”,能一次讲清楚的人并不多。这篇我用一个简单版的项目实践,把JWT的解析原理和实战编码整个过一遍,适合刚开始接触token认证、或者已经在用但没细究过原理的同学参考。
我尽量不堆理论,从“拆开看结构”开始,到“手写一个工具类”,再到项目里登录验证和续签的常见套路,最后把容易踩的坑也整理出来。你可以照着代码直接改,也能通过这篇文章把JWT的运行机制弄明白。
1. JWT到底是什么:先把结构拆明白
1.1 三段式的身份令牌
JWT的全称是JSON Web Token,从名字就能看出来,它是用JSON格式组织的、在网络环境中传递的令牌。它不是一个加密后的密文,而是一段结构清晰的字符串,按照“头部.载荷.签名”的方式分成三段,中间用英文句点隔开。
一个典型的长这样:
code复制eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.KVn3e2jA1k98KLVLqwH4dArWjI7lQqgZzY6QB0aJ8Zo
- 第一段是Header,里面记录了签名算法和token类型;
- 第二段是Payload,存放业务需要的信息,比如用户ID、用户名、过期时间等;
- 第三段是Signature,把前两段内容用一个密钥(或私钥)做签名后得到的值。
我经常用快递单来类比这段结构:Header相当于快递单上的“运输方式说明”,Payload是包裹里的商品信息,Signature是快递盒上的防拆封条。盒子是透明的,谁都能看到商品是什么,但一旦有人动过商品,防拆封条就会对不上,收件方就能判断出这个包裹被改过。这个理解方式,在讲JWT的时候特别好用,新同事一下就明白了。
1.2 为什么服务端不存SESSION也认识你
传统Web应用里,登录状态通常保存在服务端Session里,用户拿着一个sessionId,服务端每次请求都去内存里查一下这个sessionId有没有、过期没有。这在单机时代很顺手,但到了微服务架构、前后端分离、多端登录的场景,问题就来了:
- 服务端要维护大量session,重启之后就丢了;
- 多个服务实例之间要同步session,不然用户在A机器登录,请求却发到B机器就识别不了;
- 移动端和浏览器端的会话管理方式还不一样,处理起来很繁琐。
JWT的思路完全不同。它把“用户是谁、什么时候过期”这些信息直接编码进token本身,并且用签名保证内容没被篡改。服务端拿到token后只需要做三件事:
- 解析出Header和Payload;
- 用自己持有的密钥去验证签名;
- 校验过期时间是否还在有效期内。
整个过程不依赖任何存储。服务端是无状态的,哪个节点收到请求都能独立完成验证,这正是分布式架构下大家倾向于用JWT做登录认证的核心原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解析JWT:在线工具和手动解析的原理
2.1 jwt.io这类在线工具到底做了什么
很多人第一次认识JWT就是通过jwt.io这个在线解析网站,把token粘进去,右侧立刻显示JSON格式的Header和Payload。看起来高大上,其实它做的事情极其简单:把token按句点切分成三段,然后把第一段和第二段做Base64Url解码。
这里有个关键点容易忽略,JWT用的是Base64Url编码,而不是我们平时做图片传输时常用的Base64标准编码。两者差别在于URL安全处理:标准Base64里的+、/、=三个字符放到URL里会造成歧义,所以JWT规定把+换成-,把/换成_,并且去掉末尾的=号。
我在没注意这个细节之前,拿着解析出来的内容总感觉对不上,检查好久才发现是自己手写的Base64解码工具没切换成URL安全模式。所以如果你打算自己动手写一个JWT解析函数,第一个要注意的点就是:先处理字符映射关系,再去做解码。
在线工具只帮你解到这一步。Payload和Header本身就是明文编码的,不是加密内容,任何能解码Base64Url的人都能看到其中的业务信息。签名段它不会帮你“验签”,因为要做到验签必须知道密钥,在线工具没有你的密钥,自然只能做无密钥的解码。你要判断token是真的还是伪造的,必须在自己的服务端用密钥验签,这个后面会专门写。
2.2 手动解析一遍你就理解得更深
我建议每个做后端开发的同学都自己实现一次解析,哪怕只是玩具级代码。用Java举个例子,手动拆解JWT大概长这样:
java复制public class SimpleJwtParser {
public static void main(String[] args) {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
+ "eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWRtaW4ifQ."
+ "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
// 1. 按句点切分
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("token格式不正确");
}
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]));
String payloadJson = new String(Base64.getUrlDecoder().decode(parts[1]));
System.out.println("Header: " + headerJson);
System.out.println("Payload: " + payloadJson);
}
}
运行这段代码,你会看到Header输出类似这样的内容:
json复制{"alg":"HS256","typ":"JWT"}
Payload输出:
json复制{"userId":1001,"username":"admin"}
看到这里你就会明白,所谓“在线解析”,本质就是手动切字符串加Base64Url解码。解析本身不需要密钥,因为内容本来就没加密。这也是为什么JWT规范一直强调:不要再Payload里放密码、手机号、身份证号这类敏感信息。谁拿到token都能看到这些字段,你就算把token缩短到三分钟有效期,泄露风险也一样存在。
3. 签名和校验:为什么token不能被随便改
3.1 签名是怎么算出来的
用户登录成功后,服务端把用户信息放进Payload,然后拼接Header和Payload,用约定好的算法对这段内容做签名计算,签名的输入大体上是这样的公式:
code复制signature = HMACSHA256(
base64urlEncode(header) + "." + base64urlEncode(payload),
secret
)
不同的算法对应不同的计算方式。HS256是HMAC-SHA256,使用同一个密钥做签名和验证,这种叫对称算法;RS256是RSA-SHA256,用私钥签名、公钥验证,非对称算法。
HMAC算法算是“带钥匙的哈希”。它会把密钥混进内容里,重复计算多次,最终输出一串固定长度的值。只要内容或者密钥任何一个发生变化,最终算出来的签名都会对不上。RSA则是公钥密码体系,私钥只有服务端持有,所以理论上无法被伪造签名。
3.2 服务端校验时做了什么
服务端收到一个token后,会取出来请求头里的Authorization字段,常见的格式是:
text复制Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....
服务端拿到token后,先按句点切分,取第一段和第二段,用自己本地的密钥重新计算签名,然后跟token携带的第三段做比对。如果一致,说明这个token的内容确实是由持有密钥的一方签发的,中途没人篡改过。如果不一致,直接判定为非法token,返回401。
校验过期时间也很重要。Payload里常见的两个时间字段是iat和exp:
iat:issued at,签发时间exp:expiration time,过期时间
服务端校验时要把当前时间和exp做比对,如果当前时间已经超过exp,这个token就失效了。这个校验逻辑是应用自己处理的,不是JWT规范里自动执行的,所以如果你调用了某个解析库却不启用过期时间校验,过期的token也会被当作有效token,这是一个安全隐患。
3.3 关于“破解”和“伪造”的真相
网上经常能看到“JWT破解”的讨论,很多人把这个问题想得很神秘。真实情况其实分几种:
第一种是算法降级攻击。某些JWT库在解析时直接信任token里的alg字段,没有跟服务端配置的算法做强制比对。攻击者把一个原本用RS256签名的token,把Header里的alg改成HS256,然后尝试用目标服务端的公钥当作HS256的密钥来签一个新的token。如果服务端在验签时按照Header里的算法走了HS256分支,并且用的密钥是公钥内容,攻击者的伪造就成功了。这种问题跟具体实现有关,所以我在项目里一直强调:验签时固定算法,不要直接信任Header里的alg。
第二种是弱密钥爆破。HS256这种对称算法,密钥强度直接决定安全性。如果你用了一个类似secret、123456的弱密钥,攻击者拿到一个合法token后,完全可以离线跑字典,把密钥试出来,然后想签什么就签什么。我处理过一些历史项目,发现有人把JWT密钥硬编码在代码里,甚至提交到仓库,这种问题比算法本身更致命。
第三种是密钥泄露。RS256的私钥一旦泄露,攻击者就可以自己签发任意身份的token,和弱密钥爆破的后果一样。
所以真正要防的不是token被“解密”,因为解密根本不存在;要防的是签名被绕过、算法被降级、密钥太弱被人试出来。这部分我在后面常见问题里还会细化,现在先记住结论:JWT的安全性不依赖不可见,而是依赖签名的不可伪造性。
4. 实战编码:手写一个JWT工具类
4.1 引入依赖和准备工作
实际项目里没必要自己写JWT编码的底层算法,主流语言都有成熟的JWT库。Java生态里我用得比较多的是jjwt,因为它的API设计比较直观,出错信息也友好。这里用Spring Boot + jjwt 0.11.5版本演示,先加依赖:
xml复制<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
jjwt这个库分工明确,api模块是给你写代码时用的接口,impl在运行期提供实现,jackson模块负责JSON序列化。scope设成runtime是因为你不需要直接引用实现类,你在代码里只用api包下的接口。
4.2 完整的工具类代码
工具类的核心功能包括三块:生成token、解析token、校验token是否有效。我写一个不用任何框架辅助的版本,方便你直接搬进项目里改:
java复制import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtUtils {
// 实际项目中请放到配置中心或环境变量,不要写死在代码里
private static final String SECRET = "your-256-bit-secret-your-256-bit-secret!";
private static final long EXPIRE_MILLIS = 60 * 60 * 1000; // 默认1小时
private static SecretKey getKey() {
return Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成token
*/
public static String generateToken(Long userId, String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
Date now = new Date();
Date expireDate = new Date(now.getTime() + EXPIRE_MILLIS);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getKey(), SignatureAlgorithm.HS256)
.compact();
}
/**
* 解析token,得到Claims
*/
public static Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 校验token是否有效
*/
public static boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false;
}
}
}
注意生成密钥的地方,Keys.hmacShaKeyFor方法对密钥长度有要求,HS256算法要求密钥至少256比特(32字节),我的示例字符串长度够用。如果你随便写一个短字符串,jjwt启动时会直接抛异常,提示Key length不够,这是很多新手第一次跑这个工具类会遇到的问题。
4.3 生成和解析的关键说明
生成token的builder链路上,有一个容易忽略的细节:setClaims会替换默认的claims集合,如果先调了setClaims再调setSubject或者直接传入其他参数,会得到不是预期结构的结果。我习惯的做法是先把所有自定义的业务字段放进一个Map,统一调用setClaims,然后再设置issuedAt和expiration。顺序问题不复杂,但混乱时很容易产生调试半天也找不到原因的bug。
解析端要理解这个方法链:
java复制Jwts.parserBuilder()
.setSigningKey(getKey())
.build()
.parseClaimsJws(token)
.getBody();
parseClaimsJws方法名里的字母Jws表示它期望的是一个带签名的JWT,也就是我们说的三段式完整token。它内部做的事情就是我在前面章节讲的那一套:解码Header和Payload、重新计算签名、比对结果、检查过期时间。只要签名不对,或者token已经过期,方法会直接抛出异常。
所以validateToken里catch所有异常返回false是合理的做法。从调用方的角度看,只关心token到底能不能通过校验,至于是过期了还是被篡改了,可以放到日志里细化,不用让业务层感知每种异常。
5. 项目接入:登录签发与拦截器校验
5.1 写一个登录接口示范签发
工具类写好后,下一步就是接到真实的登录流程里。假设你的项目用的是Spring Boot,Controller层大概这样写:
java复制@RestController
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/login")
public Result<?> login(@RequestBody LoginRequest req) {
// 1. 校验用户名密码,这里省略查库和密码比对逻辑
// userService.checkPassword(req.getUsername(), req.getPassword());
Long userId = 1001L;
String username = req.getUsername();
// 2. 登录成功后签发token
String token = JwtUtils.generateToken(userId, username);
// 3. 返回给前端
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("tokenType", "Bearer");
data.put("expiresIn", 3600);
return Result.success(data);
}
}
登录接口本身的工作很简单:身份验证通过后,把当前用户的标识信息放进token,返回给调用方。前端收到这个token后,需要在后续每一个需要登录状态的请求Header里带上它。
关于返回“Bearer”前缀,我多说两句。HTTP规范里的Authorization字段有很多认证方案,Bearer是专门为OAuth2定义的一种,表示“持有此令牌即可访问”。前后端对接时,我建议后端返回裸token,前端在存储和发送的时候自己拼上“Bearer ”前缀,或者后端直接返回完整的前缀形式。两种方式都可以,关键是团队内保持一致约定。如果不带前缀,后端解析时要注意去掉多余空格,我用Spring拦截器处理时一般是先判断是否以Bearer开头。
5.2 拦截器里做统一校验
每个需要鉴权的接口都手动解析token就太累了,正规做法是定义一个拦截器,统一处理所有请求。HandlerInterceptor配合WebMvcConfigurer在Spring Boot里是最轻量的一套组合,不需要引入Spring Security,就能满足绝大多数内部项目的鉴权需求。
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 放行预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(401);
response.getWriter().write("missing token");
return false;
}
String token = authHeader.substring(7);
try {
Claims claims = JwtUtils.parseToken(token);
// 把用户信息放入request,方便后续接口使用
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("username", claims.get("username"));
return true;
} catch (Exception e) {
response.setStatus(401);
response.getWriter().write("invalid token");
return false;
}
}
}
注册拦截器的时候需要声明哪些路径需要拦截,哪些路径放行。登录接口本身必须放行,否则用户还没登录你就不让他访问登录页面了,这就是典型的先有鸡还是先有蛋。Swagger文档等调试资源根据情况也通常放行,否则开发环境连接口文档都打不开。
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/auth/login")
.excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**");
}
}
大家在网上搜“springboot jwt 放开swagger”这类热词,其实就是在搜这段配置。Swagger相关路径如果没放行,会出现一个经典现象:页面能打开但加载不出接口列表,因为后端接口文档的静态资源和JSON数据接口都被拦截器挡掉了。放行的时候注意路径匹配规则,Spring Boot 3 + SpringDoc OpenAPI 3的静态路径一般是/swagger-ui/**,接口文档路径是/v3/api-docs/**,不同版本和交换机配置会有差异,具体以你项目实际访问地址为准。
5.3 加密方式怎么选
我在示例里用的是HS256,也就是对称密钥。这种方式适合多数内部项目,因为实现简单,服务端只需要配置一个密钥。但缺点也很明显:签发和验证用的是同一个密钥,这个密钥一旦泄露,攻击者可以给自己签发任意身份的token。
如果项目是微服务架构,尤其是多个服务互相调用的场景,我更推荐用RS256。签发token在认证服务,持有私钥;其他业务服务只做校验,持有公钥。业务服务不需要接触到私钥,也就降低了密钥扩散的风险。就算某个业务服务的公钥被拿到,攻击者也无法用它来伪造签名,因为公钥只能验签不能签名。
RSA算法的密钥对生成,使用Java自带工具就行:
bash复制keytool -genkeypair -alias myjwt -keyalg RSA -keystore myjwt.jks -storepass changeit
也可以直接用openssl生成:
bash复制openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
生成后把私钥配置在认证服务里,公钥分发到各个校验服务。这个方案在“简单版”范围里我不展开代码了,但是你需要知道有这么个升级路线,尤其当团队明确表示后面服务会拆分、鉴权要集中处理时,一开始就按RS256设计能省去之后换算法的重构成本。
6. Token过期和续签的常见套路
6.1 过期时间设多长比较合适
token过期时间是一个典型的权衡问题。设短了,用户没过多久就要重新登录,体验很差;设长了,token泄露后相当于把用户访问权限暴露了很长时间给攻击者。
不同的业务场景适合不同的过期策略:
- 内部管理系统:用户信任度较高,会话时长通常设8到12小时,用户一天内不需要反复登录;
- 面向C端的App:一般1到2小时,配合续签机制保证用户无感知;
- 涉及支付、修改密码等高危操作:要么把有效期压到15到30分钟,要么再做一次独立校验。
生产环境里我倾向于给登录token设1小时的有效期,再通过续签机制把“保持登录”这件事延续下去。如果某个场景要求绝对安全又不希望用户体验受损,再引入短期token和刷新token的双层设计。
6.2 三种有效的续签方案
第一种是固定过期时间加重新登录。这是最简单的方案,token过期后前端跳转登录页,用户重新输入账号密码。适合后台管理系统,安全要求高,用户也能接受。
第二种是滑动续期。每次请求时不仅校验token有效性,还判断剩余有效期。如果剩余时间低于某个阈值,比如总有效期的四分之一,就重新签发一个有效期更长的token返回给前端。前端接口层收到新token后替换本地存储的旧值。这个方案实现起来也不难,但要注意并发请求可能同时触发多个续签,最后前端本地存的token可能不是最新签发的那个,需要在前端做一个简单的串行更新或者以最后一次写入为准。
第三种是双token机制。用户在登录时同时拿到accessToken和refreshToken。accessToken有效期短,比如30分钟,用来访问业务接口;refreshToken有效期长,比如7天,只用来调用刷新接口换取新的accessToken。一旦accessToken过期,前端调用刷新接口:
http复制POST /auth/refresh
Authorization: Bearer <refreshToken>
服务端校验refreshToken有效后返回新的accessToken(以及可选的新的refreshToken)。这样做的好处是业务接口暴露的token有效期很短,即使泄露,攻击者拿到的是一个很快失效的token。refreshToken虽然有效期长,但它只在一个接口上使用,链路可控。
从jwt本身的设计哲学来看,它是无状态的,服务端不保存会话信息,所以“强制下线”和“彻底续签”这类操作在纯JWT下很难做到公平。真正要精确控制用户会话时,多数项目会选择引入Redis辅助管理,在Redis里记录token版本号或者用户当前的有效会话标识。这样每次请求除了验签,还去查一下Redis,虽然失去了JWT纯无状态的优势,但换来了可控性。我的建议是不要盲目追求某一种理念,按业务需要来。
6.3 退出登录时为什么token还“活着”
几乎所有负责登录模块的同学都会问一个问题:用户点退出登录,前端把token删了,是不是就完事了?
从前端的角度看,确实完事了,因为后续请求不再携带这个token,服务端也就不会识别用户身份。但从安全角度看,这个已经签发的token并没有失效,如果它之前被某个恶意脚本窃取过,攻击者仍然可以在token有效期内拿着它冒充用户访问接口。
纯JWT方案很难处理这种“主动失效”需求,因为服务端不保存token状态。想要做到真正的服务端失效,还是要引入黑名单机制。最简单的方式是维护一个Redis黑名单,退出登录时把该token的jti(token唯一标识)或原始token串存入Redis,设置过期时间和token剩余有效期一致。拦截器在每次校验时先查一下这个token是否在黑名单里,如果在就直接拒绝。
我参与的项目里,凡是涉及真实的用户敏感数据的,几乎都会做这层黑名单。虽然这让JWT不再那么“无状态”,但系统安全往往就是这么权衡出来的。如果你们项目还在早期,先不做黑名单是合理的,等收到安全审计报告再补也不迟。
7. 常见问题与排查技巧实录
7.1 签名校验失败的几类原因
签名校验失败是JWT接入时最高频的问题,开发环境里十次报错有八次是密钥对不上或密钥配置不一致。我梳理一张排查表,照着顺序查基本能定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| SignatureException | 生成和解析用的密钥不一致 | 对比签发方和验证方的secret配置 |
| ExpiredJwtException | token过期 | 检查系统时间和exp字段 |
| MalformedJwtException | token格式不完整 | 确认请求头没有被截断或空格处理错误 |
| UnsupportedJwtException | 算法或token类型不匹配 | 确认Header里的alg和库要求是否一致 |
| 在微服务之间校验失败 | A服务签发时用的是自己的secret,B服务用另一个secret验签 | 统一密钥管理,改用RS256公钥验签方案 |
遇到SignatureException时,我建议先别急着看代码逻辑,先把自己手里生成token和解析token的密钥原样打印出来看一眼。很多坑都是因为配置文件里的secret里有隐藏换行符,或者不同环境读取配置时编码不一致导致的。特别是从环境变量注入密钥的场景,shell里末尾换行符被带进环境变量,排查起来很隐蔽。
7.2 时间不一致造成的过期问题
JWT的iat和exp都依赖系统时间。如果签发token的服务器和验证token的服务器时间不同步,就会出现一个诡异的现象:A服务生成的token有效期明明是1小时,B服务却提示已经过期。
最常见的原因是部署在不同主机上的服务存在时钟漂移。比如云服务器启动时没有配置时间同步,运行一段时间后系统时间慢慢偏离了标准时间,幅度可能达到几十秒甚至几分钟。
解决方案有两层。第一层是运维层面,所有服务节点配置NTP时间同步服务。第二层是应用层面,在JWT解析链路上允许一定的时钟偏移量。jjwt提供了setClockSkewSeconds方法:
java复制Claims claims = Jwts.parserBuilder()
.setSigningKey(getKey())
.setClockSkewSeconds(60) // 允许60秒时间差
.build()
.parseClaimsJws(token)
.getBody();
我一般会设置60秒,既能容忍轻微的时钟漂移,又不至于让过期的token大量存活。
7.3 JWT字符串太长怎么处理
JWT一个常见的槽点是“太长”。一个完整的token动辄两三百字符,如果Payload里塞了很多用户信息,四五百字符也正常。而Cookie单条存储上限通常是4KB左右,部分浏览器实现还有更严格的限制,如果把token存进Cookie,一不小心就会超限。
实际项目里更推荐把token存在内存或localStorage中,通过Authorization头发送。这样做的好处是既避免了Cookie大小限制,也在一定程度上降低了CSRF攻击的风险,因为跨站请求默认不会带上localStorage里的内容。缺点是localStorage被XSS脚本读取后会有泄露风险,所以关键项目还要配合内容安全策略(CSP)来降低XSS面。
如果发现token实在太长,可以检查Payload里是不是塞了过多的业务字段。有些同学习惯把用户昵称、头像、部门、角色列表全塞进去,一个token膨胀到上千字符。JWT只适合放身份识别信息,用户昵称头像这种频繁变动的内容,接口需要时再去查一次缓存就好,不要塞token。
7.4 Swagger调试时401问题
引入JWT拦截器后,在Swagger界面调试接口经常会遇到401。除了前面说的放行Swagger静态资源路径外,还有一个细节是Swagger UI界面本身需要配置全局Authorization头才能带上token。
SpringDoc的配置里可以通过SecurityScheme声明Bearer认证方式:
java复制@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("bearer-key",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList("bearer-key"));
}
这样Swagger UI页面会多出一个Authorize按钮,点击后在弹窗里粘贴token,后续请求就会统一加上Authorization头。如果你在Swagger里调试单个接口时手动拼Header,容易因为缺少Bearer前缀或者复制了带引号的token而一直报401,半天找不到原因。
7.5 跨语言环境下 token 不通用怎么排查
团队技术栈变复杂之后,经常出现Java服务签发的token,Node.js或Go写的网关校验不通过的情况。多数时候是语言库对JWT规范的实现细节存在差异。
我遇到的比较多的是Claims类型问题。java的jjwt对数字类型的处理会原样保留为Integer或Long,但某些语言的JSON反序列化会把数字统一转成Double,然后你在代码里调用claims.get("userId")时拿到的是一个Double类型的对象,直接转Long或执行比较操作就出问题。
解决方法是约定业务字段的取值类型尽量统一用字符串,比如userId存成"1001"而不是数字1001。字符串没有类型歧义,跨语言解析最省心。这个约定最好在项目初期定好,不然等各个服务都写完了再改就麻烦了。
8. 开发调试中的几个实用技巧
8.1 打印和分析token的工具推荐
平常开发时可以自己写一个本地的命令行小工具类,专门用来解析token,方便在联调环节快速判断问题是出在签发端还是校验端。我在很多项目里看到过同事用System.out.println手动打印token内容,这本身可以,但更好用的是把解析逻辑做成一个简单的main方法类,随时丢一个token进去就能看到完整内容:
java复制public class JwtDebugTool {
public static void main(String[] args) {
String token = "粘贴你的token";
try {
// 1. 不验签只解内容,看Header和Payload
String[] parts = token.split("\\.");
String header = new String(Base64.getUrlDecoder().decode(parts[0]));
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
System.out.println("Header: " + header);
System.out.println("Payload: " + payload);
// 2. 验签解析,确认是不是自己密钥签发的
Claims claims = JwtUtils.parseToken(token);
System.out.println("验签通过,Claims: " + claims);
} catch (Exception e) {
System.out.println("验签失败: " + e.getMessage());
}
}
}
第一段输出能看到这个token在不知道密钥的情况下透露了哪些信息,帮你检查是否把敏感字段放进去了;第二段输出确认这个token是不是你期望的签发方签发的。联调时前后端互相对照,能减少很多“我觉得token没问题但你那边就是401”的拉扯。
8.2 单元测试要覆盖哪些场景
JWT工具类写完之后,我建议补上单元测试,覆盖几个典型的成功和失败场景。至少要有:
- 生成token后,用同一个工具类解析,能正确拿到userId和username;
- 将Payload里的字段篡改后,解析应该抛异常;
- 用一个错误的密钥进行解析,应该抛异常;
- token过期场景,通过构造过去的时间生成一个已经过期的token,解析应该抛异常。
第一个场景很多人会写,但后面几个很容易漏。漏掉的核心原因是大家对“签名保护”的理解只停留在概念层面,没有在代码里验证过篡改会被抓住、错误密钥会被拒绝。这些用例写起来不复杂,但对后续修改密钥算法、重构工具类起到了很好的守护作用。
最后说几点个人体会
JWT这个东西,网上资料多得很,但很多资料一上来就讲算法参数、讲微服务统一认证,反倒让刚开始接触的人觉得很难。其实从我的经验看,只要把“分段拆解、明文载荷、签名校验”这个核心链路跑通,剩下的都是围绕业务安全做权衡。
我自己在实际项目中踩过不少坑,比如密钥太短没注意导致运行期抛异常、Payload里放了手机号被安全团队打回、还有一次因为服务端密钥配置文件带了一个不可见换行符,导致同一个token在不同环境下验证结果不一致,排查了很久才意识到是配置的字节级差异。如果你也是刚在项目里接触JWT,我建议先按这篇文章的流程搭一个最小可运行的版本,token少放业务字段,密钥放配置中心,过期时间别拍脑袋,再根据真实的用户体验慢慢调整。
另外一个小建议:把解析和验签的日志做得清晰一点,至少失败时要能区分“签名对不上”“token已过期”“格式不对”这几种情况。这样一来,前端联调时只要把响应报文发过来,你扫一眼日志就能定位是哪一段出了问题,能省下大量来回沟通的时间。
