JWT登录鉴权实战:从HTTP无状态到Token签发与安全实践
做后端这些年几乎每个新同事都会问同一个问题登录都登录了为什么每个接口还要带token服务器记一下我不行吗这个问题背后的答案就是HTTP的“无状态”特性。HTTP协议本身不认识你是谁它像一家永远不记人的酒店前台——你早上出门晚上回来它照样问你要房卡。而JWT就是那张房卡。这篇指南我要把“酒店入住”这个比喻贯彻到底从HTTP为什么脸盲、JWT这张房卡怎么造怎么验到登录签发、前端携带、过期续签、常见漏洞完整过一遍。代码示例用Spring Boot前端讲Axios拦截器适合刚接触认证的初级开发者也适合想系统梳理鉴权方案的进阶同学。1. 先算清楚账HTTP为什么“脸盲”JWT为什么适配1.1 HTTP协议天生没有“记忆”HTTP是基于请求-响应模型的协议每一个请求服务器默认都当作一个“陌生人”来处理。它不关心这个请求是不是上一条发来的也不记录客户端任何状态处理完响应连接层面的事情就算完了。这也是一切登录鉴权的根源问题——如果我是一个普通用户登录成功后下一次我再请求“获取我的订单列表”接口服务器收到一个GET /orders它怎么知道这个请求是我发的而不是别人冒充的HTTP本身没有答案它不认人。打个比方HTTP像一家全天营业、客流很大的酒店前台。前台不记脸、不记名、不登记你的习惯每次你走到前台它都把你当一个全新客人。你说“我是302的住客请帮我开门”前台凭什么信你它只会礼貌地回一句请出示房卡。所以问题就变成了如何让服务器在“不记住你”的情况下依然能确认“你是你”。1.2 从“前台帮你记着”到“房卡自己拿着”早期的解决方案是Session Cookie。登录成功之后服务端在内存或者Redis里给你开辟一块空间存你的用户信息同时生成一个sessionId通过Set-Cookie返回给浏览器。浏览器之后的每次请求都会自动带上这个Cookie服务端拿到cookie里的sessionId再去自己的存储里查一下就知道你是谁了。这个方案很直观——相当于酒店前台真的帮你记着“302住的是张三”。但它有一个硬伤服务端必须维护这份“记忆”。一旦系统做大了部署多台服务器用户的Session存在A机器上下次请求负载均衡到了B机器B机器查不到用户就掉线了。解决办法是搞Session共享比如把Session集中存到Redis但这又给架构引入了额外的复杂度。移动端、小程序这类没有传统Cookie机制的客户端处理起来也别扭。JWT的出现把事情反转了过来服务端不存状态而是把你的身份信息直接交给客户端你自己拿着每次请求出示一下服务端只负责验证这张“卡”的真伪和有效期。这种方案有几个明显优势服务端无状态随便横向扩容多端接入容易不依赖Cookie身份信息打包在一个自包含的字符串里一次解析就知道是谁。这就是为什么现在前后端分离、微服务架构普遍选JWT做登录鉴权。这里要插一句JWT不完美它没法主动踢人下线也就是“房卡一旦发出去酒店没法远程把卡作废”。但这个问题有补救方案后面我会专门讲续签和吊销。2. JWT这张“房卡”里到底装了什么2.1 三段式结构眼睛一看就懂JWT的全称是JSON Web Token它长这样eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDAxIiwidXNlcm5hbWUiOiJ6aGFuZ3NhbiJ9.pZgPxrmjM8I2e_Jc1qP7vM-3nG0OXkA4yUzLcH8XlGw用英文句点拆开正好三段Header头部、Payload载荷、Signature签名。第一段Header是编码后的JSON通常包含{ alg: HS256, typ: JWT }alg表示签名算法typ声明这是一个JWT。第二段Payload是核心业务数据也就是你写在房卡上的内容{ sub: 1001, username: zhangsan, role: admin, iat: 1710000000, exp: 1710007200 }sub是用户标识iat是签发时间exp是过期时间剩下的字段都可以自己定义但也别乱塞后面会说。第三段Signature是签名由Header、Payload、密钥和算法共同生成用来保证前面两段内容没有被篡改。2.2 重点理解签名不是加密刚接触JWT的人最容易犯的一个错误是以为JWT内容是加密的、别人看不了。事实恰恰相反。Header和Payload只是做了Base64URL编码任何人在任何JWT解析网站上粘贴一下都能直接看到里面写了什么。它“防读”吗完全不防。它真正防的是“篡改”——如果你改了Payload里的userId签名就对不上了服务端一验签直接拒绝。你可以把签名理解为房卡上的防伪标记卡面上的字谁都能看但磁性纹路是制卡系统用密钥生成的仿造不出来。你改了卡面上的“302房”过闸机时纹路对不上照样进不去。验证流程的核心逻辑服务端拿到token后用同样的Header和Payload结合自己手里的密钥重新算一遍签名如果算出来的签名和token自带的第三段一模一样说明内容没被人动过再去检查exp等时间字段判断是否过期这也是所有JWT库内部帮你做的事但你理解它的原理排查问题的时候思路会顺很多。2.3 房卡上能写什么、不能写什么既然Payload是明文可见的写什么就得非常谨慎。能写用户ID、用户名、角色、部门等等不敏感的身份标识。 不能写密码、身份证号、手机号、银行卡号、密钥任何你想保护的信息都不要放进去。我见过真实项目把用户密码哈希写进Payload的理由是“省得每次查数据库”这属于典型的把房卡当保险箱用一旦token泄露密码哈希等于白给。正确的做法是Payload里只放最小必要信息比如userId和role其他资料按需查库。还有一个容易忽略的坑Payload体积会影响请求头大小。JWT通常是放在HTTP请求头Authorization里传输的而很多网关、代理服务器对请求头长度有限制。往Payload里塞一堆大字段搞不好就是414 URI Too Long或者400 Bad Request。3. 完整实操从登录签发到接口鉴权一次跑通3.1 技术选型和项目准备示例就用Java Spring Boot这也是国内后端使用量最大的组合。JWT的Java库有好几个我常用的是auth0的java-jwtAPI简洁、文档全如果你的项目里已经集成了Hutool直接用Hutool的JWT工具类也行少引一个依赖。先加上依赖dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency或者Hutool版本dependency groupIdcn.hutool/groupId artifactIdhutool-jwt/artifactId version5.8.25/version /dependency然后在配置文件里准备两个基础参数jwt: secret: your-secret-key-change-in-production expire-minutes: 120关于secret一定要用足够长的随机字符串长度不要低于32个字符生产环境放在配置中心或者环境变量里不要硬编码进代码库。这里多说一句secret是整个JWT安全的核心一旦泄露等于任何人都能仿冒你的制卡系统签发房卡。3.2 登录接口办入住发房卡登录接口只做两件事校验用户名密码签发token返回给客户端。先封装一个JwtUtil工具类负责创建和验证tokenComponent public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire-minutes}) private long expireMinutes; public String createToken(Long userId, String username, String role) { Algorithm algorithm Algorithm.HMAC256(secret); Date now new Date(); Date expireAt new Date(now.getTime() expireMinutes * 60 * 1000); return JWT.create() .withSubject(String.valueOf(userId)) .withClaim(username, username) .withClaim(role, role) .withIssuedAt(now) .withExpiresAt(expireAt) .sign(algorithm); } public DecodedJWT verify(String token) { Algorithm algorithm Algorithm.HMAC256(secret); return JWT.require(algorithm) .build() .verify(token); } }核心逻辑就两个createToken负责“发卡”verify负责“验卡”。withSubject放userIdwithClaim放自定义字段withExpiresAt设置过期过期时间单位是毫秒所以分钟数要乘以60再乘以1000。接着写登录接口RestController public class AuthController { Autowired private UserService userService; Autowired private JwtUtil jwtUtil; PostMapping(/login) public ResultLoginResponse login(RequestBody LoginRequest req) { User user userService.checkLogin(req.getUsername(), req.getPassword()); String token jwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return Result.ok(new LoginResponse(token)); } }checkLogin内部负责查库、比对密码。需要提醒的是数据库里存的密码绝不能是明文或者简单MD5至少要用BCrypt这类自适应哈希算法这是登录安全的基本盘和JWT无关但同样重要。对了如果用的是Hutool创建token更简洁过期时间在setExpiresAt里设置签名密钥用setKey传入String token JWT.create() .setPayload(userId, user.getId()) .setExpiresAt(new Date(System.currentTimeMillis() 7200 * 1000)) .setKey(secret.getBytes()) .sign();3.3 鉴权拦截器每个接口先查一次房卡token发出去之后服务端就要在受保护接口上做统一的“查卡”动作。Spring Boot里最常用的手段是HandlerInterceptor所有请求进Controller之前先过一遍拦截器。public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public JwtInterceptor(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { writeUnauthorized(response, 缺少token或格式不正确); return false; } String token header.substring(7); try { DecodedJWT jwt jwtUtil.verify(token); request.setAttribute(userId, jwt.getSubject()); request.setAttribute(username, jwt.getClaim(username).asString()); request.setAttribute(role, jwt.getClaim(role).asString()); return true; } catch (JWTVerificationException e) { writeUnauthorized(response, token无效或已过期); return false; } } private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\ message \}); } }这里有两个细节值得注意。一是“Bearer ”前缀是行业惯例RFC 6750里定义的Bearer Token方式服务端看到这个前缀就知道请求头里携带的是访问令牌不是别的凭据语义清晰也方便对接网关、OpenAPI工具。二是拦截器里把token里的userId、role放进了request attributeController方法里只要加一个HttpServletRequest参数就能直接取省得每个接口重复解析token。拦截器写完之后注册进WebMvcConfigurerConfiguration public class WebConfig implements WebMvcConfigurer { Autowired private JwtUtil jwtUtil; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor(jwtUtil)) .addPathPatterns(/**) .excludePathPatterns(/login, /register); } }这样配置之后除了登录和注册接口其他所有接口都会先验token验不过就返回401。3.4 前端怎么存Token才不会“掉卡”token签发出来了前端拿到之后放哪也是个有讲究的问题。常见选项有三个。localStorage或者sessionStorage是最简单的存法Axios拦截器里取出token塞进请求头就行。优点是实现快、接口调试方便缺点是localStorage对XSS攻击是裸奔的页面一旦被注入了恶意脚本token随手就被偷走。HttpOnly Cookie是更安全的选择把token放进Cookie并设置HttpOnly属性脚本读不到能挡住大部分XSS窃取。但代价是要处理CSRF问题因为浏览器发Cookie是自动带上的攻击者可以诱导用户发请求。前后端分离场景下Cookie的跨域配置也是一堆事。内存变量最安全页面刷新就丢但刷新后要重新登录体验太差。我的建议是如果做对外公开的产品优先考虑HttpOnly Cookie加CSRF防护如果是内部系统localStorage加严格输入过滤也够用。不管用哪种前端可以统一封装一个请求拦截器避免每个请求手工加头axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); axios.interceptors.response.use( response response, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );3.5 Token过期续签房卡到期怎么处理JWT一旦签发在过期之前都是有效的服务端如果要改状态就得另想办法。这就引出了最常见的“token续签”问题。最推荐的方案是双Token一个短期access_token一个长期refresh_token。access_token有效期短比如15分钟用来访问接口refresh_token有效期长比如7天只用来换取新的access_token。用户操作过程中access_token过期了前端自动拿refresh_token去换新token用户无感。刷新接口示意PostMapping(/refresh) public ResultLoginResponse refresh(RequestBody MapString, String body) { String refreshToken body.get(refreshToken); // 先校验refreshToken再查一下Redis里有没有被吊销 DecodedJWT jwt jwtUtil.verify(refreshToken); if (!jwt.getClaim(tokenType).asString().equals(refresh)) { return Result.error(401, refresh token类型错误); } // 根据refresh_token里的userId重新签发access_token String newAccessToken jwtUtil.createToken( Long.valueOf(jwt.getSubject()), jwt.getClaim(username).asString(), jwt.getClaim(role).asString()); return Result.ok(new LoginResponse(newAccessToken)); }注意签发refresh_token时要加一个自定义声明区分类型比如tokenTyperefresh避免有人拿refresh_token去当access_token访问接口。如果业务简单、不想维护双token可以退一步做滑动续期在拦截器里判断剩余有效期如果剩余时间少于某个阈值比如30分钟就在响应头里返回一个新的token前端收到后替换本地token。这种方式实现成本低但没法主动踢人下线因为服务端没有留存任何状态。有些场景还需要服务端能主动把某个用户的登录态干掉比如用户改密码、管理员封号。这时候一般是引入Redis做登录态管理登录时把token的jtiJWT ID或userId写入Redis设置和token一致的过期时间续期时更新Redis的TTL要踢人时删掉Redis里的记录拦截器里同步查一下Redis即可。这几个方案没有绝对的优劣关键看业务需求。需要无感续期就上双token需要能踢人就上Redis。4. 实战踩坑与排查手册4.1 签名验证失败八成是密钥或算法不统一线上最经典的“能登录但是一会儿好一会儿坏”的问题排查半天发现是多实例部署时每个实例的jwt.secret配置不一样。A机器发的token分流到B机器验签B机器的密钥不同签名自然对不上。另外一类是算法不统一签发时用的HS256验证时用了RS256或者Hutool和java-jwt对同一token的验证行为不一致都可能报JWTVerificationException。排查建议按顺序来先看配置中心的secret是否一致尤其注意有没有环境变量覆盖默认值再看签发和验证用的算法名称是否一致多实例部署时确认所有实例拿到的是同一个配置源最简单的验证方式用同一个token在jwt.io在线解析如果jwt.io右侧签名校验显示Invalid Signature说明你的密钥和token对不上问题一定出在签发和验证两端参数不一致上。4.2 Token过期时间为什么总差8小时时区问题在JWT里特别常见。JWT里的iat和exp是Unix时间戳本身不携带时区信息标准情况下应该统一用UTC。但很多开发者习惯用本地时间本地时间构造Date和客户端把时间戳转换成UTC展示中间就差出8个小时视觉上就像“token提前8小时过期”。解决方式不是去改前端而是统一约定时间标准后端生成token、判断过期统一基于UTC时间数据库存时间字段统一用UTC展示层再转本地时区客户端解析token里的exp时不要自己拼时间格式直接用Date对象小团队项目里这类问题经常出现并消耗大量排查时间提前约定好标准能省很多事。4.3 续签方案的三个坑与前端并发刷新问题双token方案里坑也不少。第一个坑是refresh_token带了过长的有效期而且没有吊销手段。一旦泄露攻击者可以用很久。解决思路是refresh_token有效期别设太长比如7天同时绑定设备、IP等信息服务端还能用Redis吊销。第二个坑是刷新接口没有区分token类型。如果refresh_token和access_token用同一套签发逻辑验证时也不检查tokenType那攻击者拿到access_token也能调刷新接口。所以签发refresh_token时务必加上类型声明验证时检查。第三个坑是前端并发刷新。页面上多个接口同时请求access_token过期后所有请求都返回401如果每个401都去调一次refresh接口会发出大量重复刷新请求服务端压力大不说还可能因为旧refresh_token被轮换导致互相顶掉。正确做法是前端做个单例刷新let refreshPromise null; function refreshAccessToken() { if (!refreshPromise) { refreshPromise axios.post(/refresh, { refreshToken: getRefreshToken() }) .then(res { localStorage.setItem(token, res.data.accessToken); return res.data.accessToken; }) .finally(() { refreshPromise null; }); } return refreshPromise; }所有401的请求先进入等待队列等refreshPromise返回新token后再用新token重放之前失败的请求。这个模式实现一次后面所有接口都受益。4.4 JWT安全漏洞这几个坑一定不能踩JWT本身不复杂但历史上踩过的安全坑特别多列几个最典型的。第一是algnone攻击。早期一些库允许不签名攻击者把Header里的alg改成none删掉签名段服务端如果没验证alg白名单就接收了伪造token。修复办法是服务端固定算法、显式校验alg参数不要信任token里自报的alg。第二是算法混淆攻击。最典型的是把RS256非对称的token用HS256对称方式验证攻击者拿公钥内容当HMAC密钥来签名如果服务端没区分算法类型校验就被绕过了。修复办法同样是在验证时锁定算法不允许token自行切换。第三是弱密钥爆破。HS256是对称密钥如果secret设置的太短比如“123456”“secret”这种攻击者拿到一个真实token离线爆破就能把密钥试出来。修复办法是用至少32字节的随机密钥或者干脆用RS256非对称签名私钥只在服务端持有。第四是Payload明文泄露。前面反复强调过Header和Payload只是Base64编码不是加密任何能拿到token的人都能看到内容。不要在Payload里放敏感信息也不要在日志里打印完整token。第五是过期校验缺失。有些手写校验逻辑的开发者忘了检查exp导致token永久有效。建议用成熟JWT库它们内置过期校验不要自己手写Base64解码就当作验签。4.5 常见问题速查表现象可能原因处理办法接口返回401token过期、签名错误、请求头缺少Authorization检查exp、密钥配置、请求头格式请求头带了token仍401token放在Cookie里后端只读Header统一用Authorization头传递tokenjwt.io能解析但显示Invalid Signature密钥不一致或算法不一致核对secret和alg修改Payload后token仍通过验证没有验签只做了Base64解码改用标准JWT库并调用verifytoken已过期但没提示校验逻辑漏了exp确认使用了库的过期校验用户登录后状态无法主动失效JWT无状态特性引入Redis管理登录态或黑名单我在实际项目里最常用的组合是access_token加refresh_token加Redis登录态管理既要无状态的快速校验又要踢人下线的能力。踩过几轮坑之后我的体会是JWT本身不难难的是围绕它的过期策略、续期方式、吊销机制和安全边界要怎么设计。动手之前先想清楚业务需要多长的登录态、允不允许服务端强制下线再决定用哪种方案比一上来就把JWT跑通重要得多。最后分享一个小技巧项目里所有时间字段统一用UTC存储和传输展示层再做时区转换所有签发token、刷新token的接口都打操作日志记录userId、IP、时间和token的jti。上线之后这俩习惯能帮你排查掉一大半诡异的鉴权问题。