JWT登录认证实战:从方案选型到安全加固的完整指南
最近有个项目要上线后端重构登录模块产品咬死要支持多端登录、要能随时踢人、还要兼容后续小程序端。技术评审的时候我们把Session、Token、JWT全部过了一遍最后拍板用JWT做整套APP登录认证系统。这个方案落地后效果不错但也踩了不少坑趁着记忆还热乎把整个实现思路和细节整理出来给后面要做APP登录接口的同学一个参考。这篇内容不是那种泛泛的科普而是从方案选型、JWT原理、登录接口具体实现到安全加固和线上问题排查的完整记录。不管你是刚接触后端认证的新人还是正在做APP登录改造的工程师按着这套思路走基本能把登录体系搭得比较稳。1. 登录认证方案选型为什么最终选了JWT先别急着写代码选型这步很关键。很多项目一开始用的是Session后来换Token再后来换JWT每次切换都伤筋动骨。所以在动工之前要把几种方案的适用场景理清楚。1.1 Session、Token、JWT三套方案横向对比做APP登录认证市面上主流就是这三条路线各有各的适用场景。我直接用一个表格把关键差异列出来这样选型的时候一眼就能看明白。对比维度Session-Cookie普通TokenJWT状态存储位置服务端内存或Redis服务端Redis/数据库客户端持有服务端无状态扩展性水平扩展要处理Session共享依赖集中存储扩展尚可天然无状态扩展最方便跨端支持依赖CookieAPP端支持差任意端靠请求头传递任意端靠请求头传递服务端查询开销每次请求查Session每次请求查Token验签即可无需查库主动失效能力删Session即可删Token即可本身无状态需额外黑名单机制安全风险CSRF、Session劫持Token泄露密钥泄露、算法混淆攻击选JWT的核心逻辑其实很简单APP端不像浏览器那样天然支持CookieSession方案在APP上要先做一层适配体验很别扭普通Token虽然能用但每次都得到Redis里查一下在高并发场景下存储压力不小。JWT把用户信息直接编进Token里服务端验签通过就放行省掉了查存储的步骤水平扩展也特别省事。代价就是Token一旦签发服务端不主动记状态想踢人得另想办法这也是后面要重点处理的问题。1.2 登录认证系统的整体链路设计整体链路其实不复杂但要画清楚每个环节的职责。以APP登录为例完整流程是这样用户输入账号密码APP把凭证发给后端登录接口。后端校验账号密码是否正确正确则生成JWT返回给APP。APP把JWT存到本地安全存储里。后续每次请求APP在请求头带上Authorization: Bearer token。后端拦截器统一从请求头取Token验签、查过期、查黑名单全通过才放行到业务接口。如果Token过期APP拿到401响应后用refresh token重新换取新的access token。这套设计里有个容易忽略的点不是所有接口都需要登录态。登录接口本身、注册接口、验证码接口、密码重置接口这些必须放在白名单里不经过Token校验否则就死循环了。我见过不少新手把登录接口也拦在认证过滤器后面上线后所有人都登录不了报错报得莫名其妙。另外要提前定义好前后端约定Token过期后返回什么状态码。我这里统一返回401并且响应体里带一个code1001之类的业务码APP端拿到这个码就触发展续流程而不是直接跳回登录页。这个约定如果前后端没对齐就会出现用户用着用着突然被踢回登录页的诡异问题。2. JWT核心原理与关键参数设计JWT不是一套很难理解的东西但很多开发对它的理解停留在“会用库就行”的层面出了问题完全不知道从哪排查。建议把原理吃透后面无论是调试还是做安全加固都会顺手很多。2.1 Token的三段式结构签名如何防止数据被篡改JWT长这个样子eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.8V3fZ3y5jHhQJbF8VqS2lQa6rYvKjKoX0oD0Lq0Z9Gk中间用点号分成三段分别是Header、Payload、Signature。Header是头部信息里面记录了签名算法和Token类型。常用的签名算法是HS256对称加密和RS256非对称加密。APP内部系统大部分用HS256就够因为服务端只有一个密钥自己保管如果涉及第三方系统互相认证需要把公钥暴露给对方那就用RS256。Payload是载荷部分存放用户相关的声明信息比如用户ID、用户名、过期时间。注意这里所有人用Base64URL编码就能解出来相当于明文绝对不能放密码、手机号、身份证号这类敏感信息。Signature是签名部分它的作用是防止数据被篡改。拿HS256举例签名是这样算出来的HMACSHA256( base64UrlEncode(Header) . base64UrlEncode(Payload), secret )也就是说只要Header或Payload被改动任何一个字符重算出来的签名就和原来的对不上服务端验签立刻失败。这就是JWT“防止数据被篡改”的核心机制。网上经常有人问“JWT的数据能不能被改”答案很清楚能改但改了签名就废了服务端不认。2.2 载荷设计放什么、不放什么载荷是设计Token时最灵活也最容易出错的地方。有几个标准声明是必须掌握的声明含义是否必须iss签发者用来标识Token由哪个服务签发建议sub主题一般放用户ID建议aud受众标识Token给谁用多系统时建议exp过期时间Unix时间戳必须nbf生效时间早于此时间的Token无效可选iat签发时间建议jti唯一ID用于防重放和吊销建议我个人的习惯是Payload里只放这几个字段用户ID、用户名、jti、签发时间、过期时间。自定义声明越少越好因为每多放一个字段Token体量就大一点而且一旦信息发生变化旧的Token仍然带着旧信息容易出脏数据。一个常见的坑是不要把用户角色权限直接写进JWT的Payload里。权限是会变的但Token除非过期否则一直有效用户被降权之后旧Token里的角色还是管理员就会出现越权问题。权限相关的内容要么每次请求时查数据库要么只把角色ID存进去并且配合短过期时间。2.3 密钥管理的三条实操经验密钥是整个JWT体系的命门我在这上面踩过不少坑总结三条经验。第一密钥必须用强随机数生成至少256bit。很多人图省事密钥直接写一个字符串比如abc123这种弱密钥很容易被爆破。直接用命令行生成一个openssl rand -base64 48生成出来的字符串用环境变量管理不要写进代码仓库。我在项目里用Nacos配置中心管理密钥测试环境和生产环境用不同的密钥避免测试Token在线上还能用的情况。第二定期轮换密钥但要保持旧密钥短暂有效。直接换密钥会导致所有在线用户Token瞬间失效都得重新登录。稳妥的做法是支持多密钥配置验签的时候按Token的kid声明找到对应密钥这样新旧密钥可以共存一段时间等所有Token都自然过期后再移除旧密钥。这个机制JWT标准是支持的核心就是Header里加一个密钥ID。第三密钥不要出现在日志里。我见过最离谱的事故是同事为了方便排查把密钥作为参数打印到了日志里恰好日志系统明文存储且权限管控不严最后密钥泄露所有Token都可以被伪造只能紧急换密钥并强制全员重新登录。排查问题时打日志只打用户ID和JWT的前20个字符就够了永远不要打完整Token更不要打密钥。3. 登录接口与JWT签发验证的完整实现选型做完了原理也理清了下面进入实操环节。我这里以Spring Boot jjwt库为例讲实现思路用其他语言的朋友参考逻辑就好流程都是通用的。3.1 服务端技术选型与依赖后端框架我用的Spring Boot 2.7JWT库选的是jwt0也就是io.jsonwebtoken:jjwt。选这个库的原因很简单API清晰、社区活跃、文档齐全而且支持Jwts.parserBuilder()这种链式调用写起来不容易出错。Maven依赖加这三个dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency如果用的是Spring Boot 3.x注意jjwt版本要升级到0.12.x以上因为javax到jakarta的包名迁移会影响依赖兼容性。我这边因为历史项目用的还是2.7就按这个来。3.2 登录接口实现从账号校验到Token签发登录接口的逻辑不复杂核心是三步校验参数、校验凭证、签发Token。贴一段我自己项目的精简代码PostMapping(/api/auth/login) public ResponseEntityApiResultLoginResponse login(RequestBody Valid LoginRequest request) { // 1. 校验账号密码 User user userService.authenticate(request.getUsername(), request.getPassword()); if (user null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResult.error(1002, 用户名或密码错误)); } // 2. 检查账号状态是否禁用、是否锁定 if (!user.getStatus().equals(UserStatus.ACTIVE)) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body(ApiResult.error(1003, 账号已被禁用)); } // 3. 签发JWT String accessToken jwtService.generateAccessToken(user); String refreshToken jwtService.generateRefreshToken(user); return ResponseEntity.ok(ApiResult.success( new LoginResponse(accessToken, refreshToken, user.getUsername()) )); }签发Token的核心逻辑放在JwtService里。这里我把access token和refresh token分开了access token过期时间短比如30分钟refresh token过期时间长比如7天。为什么要分开后面第4部分专门讲。public String generateAccessToken(User user) { Date now new Date(); Date expiryDate new Date(now.getTime() accessTokenExpireMs); // 30分钟 return Jwts.builder() .setSubject(user.getId().toString()) .claim(username, user.getUsername()) .claim(jti, UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }这里有几个关键细节要特别提醒第一subject字段统一放用户ID不要放用户名。因为用户名可能允许修改一旦改了旧Token的subject就对不上了后面做权限判断和用户查询会出怪问题。第二登录接口要做频率限制。我见过不少项目登录接口裸奔没有任何限流被人拿字典暴力撞库一天能撞几万次。至少要在网关或Spring AOP层面加一个IP和账号维度的限流比如同一个IP一分钟最多尝试10次同一个账号一小时最多失败5次失败达到阈值就临时锁定。第三密码校验要用BCrypt不要用MD5。这个已经是老生常谈但我查了网上资料发现还有不少新项目用MD5存密码只能说一旦数据库泄露MD5的密码基本等于裸奔。Spring Security的BCryptPasswordEncoder直接用就行。3.3 请求校验拦截器里如何解析和放行Token签发之后的另一半核心是每个请求进来时如何校验Token。Spring Boot里可以用过滤器Filter也可以用拦截器Interceptor两者的区别在于Filter在Servlet层面拦截器在Spring MVC层面。我习惯用拦截器因为能拿到HandlerMethod信息做接口级别的权限控制更方便。核心逻辑拆成四步public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行白名单登录、注册、验证码等接口 if (isWhiteListUrl(request.getRequestURI())) { return true; } // 1. 从请求头取Token String token resolveToken(request); if (token null) { return rejection(response, HttpStatus.UNAUTHORIZED, 1001, 未登录或登录已过期); } // 2. 验签 校验过期时间 Claims claims; try { claims jwtService.parseToken(token); } catch (ExpiredJwtException e) { return rejection(response, HttpStatus.UNAUTHORIZED, 1001, 登录已过期); } catch (JwtException e) { return rejection(response, HttpStatus.UNAUTHORIZED, 1001, 无效的登录凭证); } // 3. 检查Token是否在黑名单中用户主动注销、被踢下线 if (jwtService.isBlacklisted(claims.getId())) { return rejection(response, HttpStatus.UNAUTHORIZED, 1001, 登录已失效); } // 4. 把用户信息放入上下文供后续业务使用 Long userId Long.parseLong(claims.getSubject()); UserContext.set(new CurrentUser(userId, claims.get(username, String.class))); return true; }解析Token的逻辑关键点是parserBuilder().setSigningKey(secretKey)public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); }这里有一个新手最常踩的坑解析Token时如果不捕获ExpiredJwtException而是笼统地捕获JwtException那么Token过期和Token被篡改返回的异常信息是一模一样的无法区分。但前端需要区分“过期”可以自动续期和“无效”需要重新登录这两种情况所以我上面把ExpiredJwtException单独拎出来处理。还有一点解析成功后一定要把UserContext里的用户信息在请求结束后清理掉否则ThreadLocal会被线程池复用出现严重的用户串号问题。用afterCompletion方法清理Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }这个坑我印象太深刻了线上出现过用户A的请求偶发拿到用户B的数据排查了大半天最后定位到就是ThreadLocal没清干净。3.4 APP端Token存储与携带方式后端只是登录系统的一半APP端怎么处理Token同样重要。这块我整理几个容易被忽略的点。Token存储位置。客户端不能把Token存在会持久化到云端的地方比如SharedPreferences配合自动备份那样容易被云同步泄露。iOS用KeychainAndroid用EncryptedSharedPreferences把Token加密后落盘。云同步风险这点真的要想清楚——之前第三方库把本地数据自动云备份结果Token一起备上去了账号体系的安全边界直接破洞。Token携带方式。APP端每次请求在HTTP Header里加Authorization: Bearer token这个格式是行业标准。这里有个细节Header名称的大小写不敏感但Bearer和Token之间必须有一个空格而且Bearer首字母大写。我遇到过后端同学校验逻辑写的很宽松大小写都接受后来被安全测试发现可以通过改写法绕过某些网关规则最后统一收口成严格格式。Token过期后的静默续期。大多数APP的做法是请求返回401后前端拦截器先不跳登录页尝试用refresh token去换新的access token换成功了就重新发起刚才失败的请求换失败才跳登录页。这样用户基本无感知。如果APP直接把401当登录失效处理用户每30分钟就要重新输一次密码体验极差。续期的接口设计通常是POST /api/auth/refresh Authorization: Bearer refresh_token返回新的access token和refresh token。注意续期接口拿到的refresh token如果也过期了就必须强制重新登录不能让用户无限续期否则Token的安全性就失去意义了。4. 安全加固与常见问题排查实录JWT方案落地后还有一个重头戏就是安全加固和线上排障。这一节我重点讲讲实际项目中最容易出的问题和对应的处理方式。4.1 JWT常见漏洞与防御手段网上关于“JWT漏洞总结”的文章很多大多数都集中在算法混淆、弱密钥、Token过期校验缺失这几类。这里我不讲怎么攻击而是从防御者的角度说明如何在代码层面把这些洞堵上。算法混淆攻击。JWT的Header里有一个alg字段用来声明签名算法。攻击者可以把算法改成none然后把签名部分置空尝试直接绕过验签。防御手段很简单解析Token时设置算法白名单只允许HS256其他算法一律拒绝。Jwts.parserBuilder() .setSigningKey(secretKey) // 通过允许的签名算法白名单防止算法混淆攻击 .setAllowedClockSkewSeconds(60) .build();jjwt 0.11.x的parserBuilder默认对none算法是不放行的但如果你用的是其他库或者自己手写了验签逻辑一定要查一下算法白名单的配置。弱密钥爆破。JWT的HS256签名密钥如果太短很容易被字典跑出来。市面上有一些工具专门用常见密码字典暴力破解HS256的对称密钥。防御方式就是前面说过的生成至少256bit的强随机密钥定期轮换。Token过期时间校验缺失。有些项目解析Token后只校验签名不看exp字段导致已经过期的Token还能继续使用。用jjwt库时parseClaimsJws内部会校验exp但如果你用的时候手动把exp从Claims里取出来当普通字段处理就很容易漏掉过期校验。这里还是建议用库自带的能力来完成。日志泄露Token。这个问题很隐蔽但后果严重。排查线上问题时经常会顺手把请求参数、响应体打出来里面就带着完整的Token。一旦日志被第三方看到或数据泄露攻击者就能拿到真实Token做后续操作。日志里涉及用户信息时统一脱敏处理Token只打印前20个字符加星号。4.2 Token续签方案双Token还是滑动续期JWT无状态的特性带来了一个现实问题没法主动让Token失效。用户点了注销Token理论上仍然有效直到过期。所以续签和失效机制必须提前设计好。目前主流的方案就两种双Token方案和滑动续期方案。我做了个对比维度双Token方案滑动续期方案实现复杂度较高需要额外管理refresh token较低只有一套Token安全性较高access token短存活泄露窗口小一般单Token存活时间长泄露风险大用户体验好静默续期几乎无感好只要活跃就不过期服务端存储需要存储refresh token的hash或状态同样需要维护黑名单支持踢人我现在更推荐双Token方案原因很简单access token留存时间短即使泄露攻击者能利用的时间窗口也就几十分钟refresh token虽然存活时间长但它只在请求续期接口时才会发送落到业务接口的路径少了很多。这里要特别提醒一个点refresh token一旦泄露等于账号永久沦陷因为它能无限兑换新的access token。所以refresh token必须满足两个条件一是一次性的每次刷新接口都重新签发新的refresh token旧refresh token立即作废二是服务端要能主动吊销refresh token最简单的方式是Redis里存一个refresh token的jti白名单注销时删掉对应jti。4.3 常见问题速查表与避坑心得最后把我在实际项目里遇到的问题整理一个速查表方便后面排查时对照。现象常见原因解决方案登录成功后请求仍然401请求头没带Token或带了但格式不对检查APP端是否设置了Authorization头检查Bearer后是否有空格Token未过期但提示过期客户端和服务端时间不同步调整设备时间与服务器校准解析时设置允许的时间偏差每隔一段时间用户就被踢下线access token过期且refresh token逻辑有bug检查续期接口是否成功检查refresh token是否被误删用户改密码后原Token仍有效JWT无状态服务端不知道密码变了维护Token黑名单按用户维度记录密码修改时间过期时校验APP从后台恢复后请求一直转圈Token过期静默续期失败检查refresh token状态增加请求队列和重试机制日志里偶现其他用户的数据ThreadLocal没清理线程池复用在拦截器的afterCompletion里清理用户上下文再说一个很多团队踩过的坑多端登录的踢人逻辑。如果产品要求“同一账号只允许一台设备登录”那么只靠JWT是做不到的。我的做法是Redis里存一个device:online:{userId}的映射value是当前设备分配的sessionId不是token每次请求校验时比对当前Token里的sessionId和Redis里存的是否一致不一致就返回踢下线状态码。这样旧设备一请求就会被顶掉而且踢人的实时性比等Token过期要好得多。关于Token长度还有一个性能上的经验JWT的Header和Payload都是Base64URL编码的如果塞太多自定义声明Token会变得很长。HTTP Header的存储空间其实是有限的网关和中间件通常有8KB或16KB的限制。实测下来当Token超过4KB时部分云网关和客户端HTTP库会出现无法发送或请求被截断的诡异问题。所以Payload字段宁可少而精不要贪多。最后再分享一个小技巧聊到这儿JWT登录认证的完整链路基本覆盖到了。最后分享一个小细节是我这个项目里加了之后线上事故率明显下降的做法在签发Token时把用户密码的版本号作为一个自定义声明放进去。用户每次修改密码密码版本号1老版本号签发的Token在校验时直接判断不合法强制重新登录。这个机制成本极低却能把“用户改密码后旧Token仍然有效”这个隐患彻底解决。还有一点关于调试技巧的建议排查认证问题的时候不要直接看业务日志先把请求入口到拦截器这一段链路单独输出调试日志打出URL、是否在白名单、Token是否存在、解析结果、被哪一步拦截。这样一套日志下来绝大多数认证相关的问题都能快速定位。调试完记得把调试日志级别关掉避免生产环境日志量爆炸。如果后续产品要接小程序端或网页端这套登录体系基本不用大改只要在客户端适配Token的存储和携带方式就行。这也是当初选JWT的一个隐性收益——后端的认证逻辑跟端完全解耦一套服务多端复用。