做后端接口开发,权限认证是绕不开的一关。无论你是写微服务、给App提供API,还是最近在搞SPA单页应用,只要涉及用户登录态,就一定听过JWT这三个字母。JWT全称是JSON Web Token,简单说就是一种“带着签名的JSON通行证”,服务端不用存Session,客户端每次请求把Token带上,服务端验一下签名就能确认身份。我这几年用JWT替多个项目做过统一的权限认证方案,从最初的踩坑到后面整理出一套可复用的模板,中间积累了不少实操经验。这篇内容不算教科书,更像是我把整个入门过程、代码细节和常见坑位一次性给你梳理清楚,适合刚接触权限认证的初中级开发者,也适合那些已经在用JWT但想弄明白“为什么这么写”的同行。
1. 先把JWT的设计逻辑讲透
很多教程上来就贴代码,结果读者连Token长什么样、为什么分三段都没搞明白。我觉得要上手JWT,第一步不该是写代码,而是理解它为什么会被设计成现在这个样子。
1.1 从Session认证说起,JWT解决了什么
在JWT流行之前,最常见的是Session认证。用户登录成功后,服务端生成一个Session ID,存到内存或Redis里,同时把Session ID通过Cookie返回给浏览器。下次请求时浏览器自动带上Cookie,服务端拿着Session ID去查对应的用户信息,查到了就放行。
这套机制在传统的“单服务器+模板渲染”时代非常好用,但一旦进入分布式部署、前后端分离、移动端接入这些场景,问题就来了。Session存在哪台服务器上,请求就必须打到哪台服务器上,不然查不到状态;要是做跨端,App里根本没有Cookie这个概念,你得手动维护Session ID。更麻烦的是每次请求服务端都要查一次存储,高并发下Redis的压力也不小。
JWT的思路完全不同:用户登录成功后,服务端把用户ID、过期时间、权限标识等信息打包,用密钥签名,生成一串字符串交给客户端。之后客户端每次请求把头一装、Token一放就行,服务端只需要验签,不需要查任何会话存储。用一句话总结:Session是“状态存在服务端”,JWT是“状态存在客户端”。 这种设计天然适合无状态API,也方便横向扩容。
1.2 JWT三段式的组成原理
JWT字符串是两段点号连接的Base64Url编码内容,实际长这样:
code复制eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
中间用点号拆成三段:
| 段位 | 名称 | 保存内容 | 说明 |
|---|---|---|---|
| 第一段 | Header | 签名算法(alg)、Token类型(typ) | 一般固定为 |
| 第二段 | Payload | 用户ID、过期时间、角色等自定义声明 | 明文Base64Url编码,千万别放密码 |
| 第三段 | Signature | 前两段内容+密钥的签名结果 | 用于防篡改,也是JWT安全的核心所在 |
第三段签名是怎么算的?拿HS256举例,就是把Base64Url(Header) + "." + Base64Url(Payload)拼接起来,用HMAC-SHA256算法和密钥一起做哈希运算。所以哪怕有人把Payload改成自己的用户ID,因为不知道密钥,重新算出来的签名对不上,服务端验签就会失败。
这里有个特别重要的认知:JWT的第二段是明文,任何拿到Token的人用Base64一解码,就能看到你存进去的所有字段。 所以敏感信息绝对不能放进Payload,我见过有人把用户手机号、身份证直接放进去,这等于把隐私装进透明信封里发给所有人。
2. 权限认证的完整链路设计
理解了JWT的结构,接下来要回答一个核心问题:一套完整的权限认证体系,JWT到底在哪些环节起作用?我习惯把它拆成三个环节:登录发令牌、请求验身份、按权限做授权。很多新手把这三个环节混在一起,代码写得绕来绕去,就是没把职责拆分清楚。
2.1 登录签发Token的流程细节
登录接口是JWT体系的入口。用户提交用户名密码后,流程是这样的:
- 校验用户名密码是否合法;
- 从数据库查出用户ID、角色、权限标识;
- 把这些信息组织成JWT的Payload;
- 设置过期时间(比如2小时);
- 用密钥签名生成Token;
- 把Token返回给前端。
我在这一步会额外做两件事。第一,把用户的角色和权限码写进Token的自定义声明里,这样后续做接口级权限控制时不用再查一次库。第二,生成Token的同时预留一个“Token渠道标识”(比如用户登录的设备类型),一旦检测到用户在其他端重复登录,可以有依据地做踢人下线。
还有一点容易被忽略:登录接口本身要加防暴力破解措施。JWT只是认证机制,不负责帮你挡暴力攻击,频繁的登录尝试还得靠验证码、IP限流、账号锁定这些手段兜底。
2.2 请求携带与校验机制
客户端拿到Token之后,后续每个需要认证的接口都要带上它。最常见的做法是放在HTTP头里:
http复制Authorization: Bearer <token>
服务端需要写一个拦截器或者过滤器,统一拦截所有需要认证的请求。它的核心逻辑是:
- 从请求头里取出Authorization,去掉Bearer前缀;
- 判断Token是否存在,不存在直接返回401;
- 解析并验签Token,签名不对或已过期就返回401;
- 从Token中取出用户ID,放入请求上下文;
- 放行到具体的Controller。
我习惯把第4步的用户信息塞进ThreadLocal或者继承HandlerInterceptor自定义一个UserContext,这样Controller里直接UserContext.getUserId()就能拿到当前登录用户,不用每个接口都重复解析一遍Token。如果项目用的是Spring Security,通常是把它整合成一个OncePerRequestFilter,原理一样。
2.3 SPA项目中的无状态认证注意点
搜索引擎里有个词是“spa项目开发之jwt验证码实现”,说明不少人在做前后端分离项目时踩了认证的坑。SPA(单页应用)跟传统多页应用最大的区别是:页面路由在前端由JavaScript控制,页面本身不会刷新,所以不能用服务端跳转的方式控制登录态。
在SPA项目里,JWT通常存哪是个经典问题。存localStorage简单方便,但容易被XSS脚本偷走;存Cookie并且设置HttpOnly属性,能防XSS偷取,但要额外处理CSRF攻击。我的建议是:普通内部系统用localStorage足够,涉及资金交易的系统一定要上Cookie+HttpOnly+CSRF Token的组合。 前端拿到Token后,在Axios拦截器里统一塞进请求头,遇到401响应就跳回登录页。
验证码的实现在SPA架构下跟JWT是两条逻辑:验证码是登录前的身份验证,属于“你是不是人”的问题;JWT是登录后的身份认证,属于“你是谁”的问题。我一般让后端生成验证码图片,同时把验证码答案存Redis,提交登录时校验通过后再走用户名密码和JWT签发流程。
3. Spring Boot集成实操:写一个最小可运行的JWT认证
理论讲了这么多,最终要落到代码。我下面用一个Spring Boot项目演示最精简的JWT认证实现,不引入Spring Security,只用HandlerInterceptor加一个工具类,这样能让你看清JWT本身的运作逻辑,不至于被Security的过滤器链绕晕。
3.1 依赖与工具类
第一步引入JJWT依赖。JJWT是Java社区用得比较多的JWT库,API设计直观,文档也全。在Maven的pom.xml里加:
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是编译期接口,jjwt-impl和jjwt-jackson是运行期实现。这样做是为了让你在不改代码的情况下换实现库,属于库作者的模块化设计考量。
然后写JWT工具类:
java复制@Component
public class JwtUtil {
// 实际项目中从配置读取,这里简化
private String secret = "your-256-bit-secret-key-please-change-me";
private long expire = 2 * 60 * 60 * 1000; // 默认2小时
public String createToken(Long userId, String username, List<String> roles) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("username", username)
.claim("roles", roles)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expire))
.signWith(getKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private SecretKey getKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
}
这里要强调一下Keys.hmacShaKeyFor的坑:这个方法要求传入的密钥字节数组长度至少32字节,也就是密钥字符串至少要有32个字符。我之前用过一段16个字符的简写密钥,启动没问题,一调用就抛WeakKeyException。所以要么用足够长的密钥,要么用官方推荐的方式生成随机密钥。
3.2 登录接口与认证拦截器
登录接口就三件事:查用户、对密码、发Token。密码校验我强烈建议用BCrypt,不是简单拼接加盐做个MD5就完事。BCrypt每次生成的哈希都不同,自带盐值,能有效抵御彩虹表攻击。
java复制@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public Result login(@RequestBody LoginRequest req) {
// 1. 校验用户名和密码(这里查数据库、验证码逻辑略过)
Long userId = 1001L;
String username = "admin";
List<String> roles = List.of("admin", "user");
// 2. 签发Token
String token = jwtUtil.createToken(userId, username, roles);
return Result.ok().put("token", token);
}
}
认证拦截器是整个体系的关卡:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@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 ")) {
throw new AuthException("未登录");
}
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseToken(token);
request.setAttribute("userId", claims.getSubject());
request.setAttribute("username", claims.get("username"));
request.setAttribute("roles", claims.get("roles"));
return true;
} catch (ExpiredJwtException e) {
throw new AuthException("登录已过期");
} catch (JwtException e) {
throw new AuthException("Token无效");
}
}
}
注册拦截器时要注意路径放行规则,登录接口、验证码接口、Swagger文档等不需要认证的路径必须排除在外。
3.3 Swagger接口文档放行配置
搜索引擎里有“springboot jwt 放开swagger”这个词,说明这是很多人会卡住的点。原因很简单:加上JWT拦截器之后,所有请求默认都要过认证,可Swagger的UI页面和接口文档资源本身没有Token,一旦被拦截,接口文档页面就白屏了。
Swagger相关的路径通常是这些:
properties复制/swagger-ui.html
/swagger-ui/**
/v3/api-docs/**
/swagger-resources/**
/webjars/**
把这些路径加到拦截器的排除清单里就行。我的办法是在注册拦截器时用excludePathPatterns配置,比在拦截器代码里写死一堆路径要清晰:
java复制@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/captcha",
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**"
);
}
}
如果你用的是Spring Security,逻辑类似,用SecurityFilterChain配置permitAll与authenticated来区分。另外我个人的习惯是:生产环境直接关掉Swagger,只在内网测试环境打开,从源头减少一些无谓的漏洞扫描面。
3.4 Token续签的几种常见方案
JWT的过期时间一旦设置,就是个“死时间”,到期之后直接失效。但用户不可能每两个小时登录一次,所以续签是实际项目里必须处理的。常见的做法有三种。
第一种是前端定时刷新。前端用setInterval定时(比如在过期前10分钟)调用一个刷新接口,用旧的Token换新的。实现简单,但它要求前端必须“活着”,如果用户一直开着页面但系统休眠了,定时器可能不准。
第二种是Redis延长过期时间,业界也叫“滑动过期”。每次请求时,除了校验JWT,还去Redis里判断这个Token是否在有效期内,如果在就重新设置过期时间。这种方式等于把JWT的过期控制权转移到了Redis,跟Session方案有点殊途同归,但好处是用户可以无感续签。
第三种是双Token机制:返回一个短期AccessToken(比如30分钟)和一个长期RefreshToken(比如7天)。AccessToken过期后,前端拿RefreshToken去刷新接口换新AccessToken。这是目前业界用的最多的方案,适合对安全性要求高的App和Web应用。
我实际项目中普遍用的是第三种,代码不算复杂,核心就是再加一个RefreshToken的生成和校验接口。唯一要提醒的是:RefreshToken必须存到服务端或至少做好撤销机制,否则一旦RefreshToken泄露,等于给了攻击者一个“长期免密登录”的钥匙。
4. 安全避坑:在线解析、防伪造与常见威胁
JWT虽然好用,但网上关于“jwt伪造”“jwt破解”的话题从来没断过。这些词听起来吓人,但本质上都是因为开发者没有正确使用JWT。这一节我把几个真实的安全问题和一个典型误用案例串起来讲。
4.1 签名算法与密钥管理的几个关键点
首先要明白,JWT的签名算法分两大类。一类是对称签名(HS256),加密和解密用的是同一个密钥,适合单个服务或者能安全共享密钥的场景。另一类是非对称签名(RS256/ES256),私钥签名、公钥验签,适合多个服务共享验签、但不想暴露签名密钥的场景。
很多安全事件都出在算法配置上。比如攻击者把Token的Header改成{"alg":"none"},同时把Payload改成自己的身份,然后把签名段去掉,某些校验不严的库就会直接放行。这就是著名的“alg=none攻击”。所以解析Token时一定要严格校验算法,不能用库的默认宽松模式。
密钥管理是三句话总结:开发环境用配置文件,测试环境用配置中心,生产环境一定要用KMS或专用密钥管理系统。 密钥不要写死在代码里,也不要提交到Git仓库。我见过一个项目把私钥直接写在application.yml里还传到了公开仓库,那基本等于把系统登录权限公开了。
用RS256的时候,官方推荐的密钥长度是2048位,别为了省性能用1024位。用HS256的时候,密钥至少32字节,且应该是高熵随机串,别用“123456”这种能猜到的值。
4.2 jwt在线解析工具与调试技巧
jwt在线解析是很多开发者第一次接触JWT时用到的工具,它有一个核心作用:把Token的Header和Payload直接Base64解码成JSON,让你一眼看到Token里存了哪些信息。用在线解析调试JWT很方便,但有个安全红线我必须强调:
绝不要用在线工具解析生产环境的真实Token。因为你的Payload里可能包含用户ID、角色等信息,这些数据经过第三方服务器,等于你把用户身份信息泄露给了第三方。调试时请用自己本地的脚本或离线工具。
我在本地习惯用Node.js写个几行的脚本解析JWT,或者用Java单元测试直接调parseToken方法。在线工具只用来学习原理,或者是解析测试环境的Token,生产数据一律不进第三方工具。
4.3 常见攻击威胁及防护清单
把几个真实存在的攻击手段列一下,同时给出对应的防护方案:
| 攻击方式 | 原理 | 防护方案 |
|---|---|---|
| alg=none攻击 | 把头部算法改成none,去掉签名 | 严格限制算法白名单,拒绝alg=none |
| 密钥爆破 | 用弱密钥字典离线爆破签名 | 使用高熵长密钥;用RS256替代HS256 |
| Token泄露 | 前端localStorage被XSS偷走 | 敏感系统用HttpOnly Cookie存储;对所有输入做XSS过滤 |
| 重放攻击 | 截获合法Token重复使用 | 设置合理的短过期时间;关键操作加一次性Nonce |
| 篡改Payload | 改用户ID、角色等字段 | 服务端必须验签;角色权限从服务端拿,不全信Token |
有一招我强烈推荐:服务端保存每个Token的“当前版本号”或“签发时间戳”,当用户修改密码、被踢下线、封号时,把该用户的token_version加一。每次校验Token时比对版本号,版本不一致直接拒绝。这样弥补了JWT“一旦签发,失效前都有效”的短板,实现主动吊销。
4.4 我踩过一次的“jwt破解”式事故
搜索引擎热词里有“jwt破解”,这让我想起一次印象深刻的排障经历。有一年我接手一个老项目,用户反馈说“别人能登录我的账号”。排查下来发现问题是这样的:原来的开发者用了HS256签名,但密钥就六个字母,而且接口里没有对Token做任何过期校验,连Token版本号都没有。
攻击者拿到一个Token后,用工具把密钥爆破出来,然后篡改Payload里的用户ID,重新生成个新Token,直接“变身”成别的用户。这跟“破解JWT”在原理上完全吻合,但根子不在JWT算法,而是密钥太弱、设计太松。
那次之后我定了一套JWT使用的铁律:
- 密钥长度不达标,不让上线;
- 生产环境一律不打印Token相关日志;
- 用户信息变更后必须能主动踢掉旧Token;
- Token过期时间按业务风险等级来定,普通后台2小时,支付操作15分钟;
- 解析失败要区分“过期”“签名不对”“格式不对”,分别返回不同提示,方便排查。
如果你正在做一个新项目,直接把这几条作为检查清单对照,能少踩很多坑。
5. 常见问题与排查技巧实录
最后这部分,我把平时被问得最多、以及自己在项目里真实遇到过的坑整理成速查表,方便你对照排查。
5.1 典型问题汇总
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录成功但调用接口401 | Token没放请求头、Key和生成时不一致 | 检查前端拦截器是否加Authorization头 |
| Token刚签发就过期 | 服务器时间不同步、时钟偏移 | 统一用NTP时间同步 |
| 中文用户名乱码 | Header的Base64Url编码问题 | 确保使用UTF-8编码 |
| Swagger页面打不开 | 被拦截器拦了 | 在excludePathPatterns里放开静态资源 |
| 其他服务验签失败 | 密钥配置不一致或算法不匹配 | 检查所有服务的secret是否一致,算法是否为同一套 |
| 换用RS256后验签失败 | 公钥私钥不匹配或格式问题 | 用openssl重新生成密钥对,确保公钥加载方式正确 |
5.2 排查思路建议
遇到认证问题,我一般按这个顺序排查:
先看Token能不能解析。拿Token到本地解析脚本里跑一下,看Payload里面的exp和iat字段对不对。再看异常类型,是ExpiredJwtException还是SignatureException,前者是过期,后者是签名不匹配,原因截然不同。最后看服务端日志,确认使用的密钥跟签发时是否一致。
如果是跨系统联调问题,两个服务使用同一套密钥,最容易出错的是密钥字符串前后多了一个空格或换行。我遇到过两次这种情况,用户全在问为什么生产环境的签名在测试环境验不过,结果就是配置文件复制时末尾多了个回车符。
要养成一个习惯:认证逻辑单独打成公共模块,不要让每个服务的开发自己写一套JwtUtil。 一旦密钥策略、算法选型、异常处理有变动,公共模块改一处,所有服务跟着生效,比每个项目各自维护一套要省心得多。
我在实际项目里还习惯做一件事:在统一的异常处理器里,把JWT相关的异常单独映射成更明确的状态码。401表示未登录或Token失效,403表示无权限,400表示Token格式不对。这样前端拿到不同的状态码能做出不同的反应,而不是所有失败都弹一个“登录过期”。
JWT入门其实没那么复杂,核心就是三段结构、一个签名、一套流转流程。工具选型上用JJWT完全够用,有条件的话深入读一下Spring Security对JWT的原生整合,会有更深的理解。总归一句话:把密钥管好、把过期时间设置合理、把签名校验写严,JWT这套方案稳定得让人非常放心。
