
1. 从一次登录说起为什么我们需要JWT先从一个最基础的场景聊起。你在做一个Web项目用户输入用户名密码点登录后端验证通过给前端发一个凭证。这个凭证接下来要伴随用户访问所有需要鉴权的接口——查订单、改资料、下单支付。在没有JWT之前最经典的做法是什么Session。后端在服务器内存里存一份会话数据给你一个sessionId前端拿着这个id来认人。这在单体应用里很好用但一旦上了分布式、微服务问题立刻来了服务A存了session请求打到服务BB不认识这个sessionId怎么办要么做session共享要么把session落到Redis里。共享和集中式存储本身不复杂但每一次鉴权都变成一次网络IO而且会话状态全放服务器端容量和可靠性都有天花板。JWTJSON Web Token走的是另一条路把用户身份信息和有效期直接编码进一个字符串里服务端用密钥或公钥验证这个字符串的签名确认没被篡改就认这个人。服务器端不需要存任何会话记录这就是所谓“无状态认证”。你想想在微服务网关统一校验、App与Web统一鉴权、第三方授权登录这些场景里JWT这套思路有多省事。JWT这些年几乎成了Web后端开发绕不开的标配作为开发者的你应该见过类似这样的代码String token Jwts.builder() .setSubject(userId) .setExpiration(new Date(System.currentTimeMillis() 3600_000)) .signWith(secretKey) .compact();这一行代码背后是整个鉴权体系的核心。这篇内容我打算从一个完整的实操视角展开先拆JWT的内部结构和签名原理再讲清楚登录验证怎么落地接着处理SPA项目里常见的登录态刷新与续签问题最后把JWT的安全漏洞和坑一次性梳理清楚。适合正在做后端鉴权、或者被token续签折磨过的朋友参考。2. JWT的内部结构三段字符串背后的密码学原理2.1 Header、Payload、Signature分别装了什么JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c中间用两个点.分成三段。第一段是Header第二段是Payload第三段是Signature。我用Python拆开看看里面到底装了什么import base64 import json def decode_segment(segment): padding * (4 - len(segment) % 4) return base64.urlsafe_b64decode(segment padding) token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c header, payload, signature token.split(.) print(Header:, json.loads(decode_segment(header))) print(Payload:, json.loads(decode_segment(payload))) print(Signature (raw bytes):, base64.urlsafe_b64decode(signature ))输出大概是这样的Header: {alg: HS256, typ: JWT} Payload: {sub: 1234567890, name: John Doe, iat: 1516239022} Signature (raw bytes): b\x01R\xe5\xc3\x00D\x93\x1e2\x85Header里的alg是签名算法常见的有HS256HMAC-SHA256对称签名、RS256RSA非对称签名、ES256椭圆曲线签名。typ固定是JWT。Payload里装的是“声明”Claims这三种是标准字段subSubject令牌的主体通常是用户IDiatIssued At签发时间Unix时间戳expExpiration Time过期时间Unix时间戳当然你还可以放任意自定义字段比如role、nickname、tenantId。但有一条金科玉律不要把密码、手机号、身份证号这类敏感信息放进Payload。为什么因为Payload只是Base64编码不是加密谁拿到token都能解码看到内容。JWT的签名只保证内容没被篡改不保证内容不可见。2.2 签名是怎么算出来的HMAC与RSA两条路线Signature的生成过程可以概括成一句话对Header和Payload拼接后的字符串做哈希运算再用密钥处理。以最常用的HS256为例整个过程是这样的import hmac import hashlib import base64 def b64url_encode(data): return base64.urlsafe_b64encode(data).rstrip(b) header_b64 b64url_encode(json.dumps(header, separators(,, :)).encode()) payload_b64 b64url_encode(json.dumps(payload, separators(,, :)).encode()) signing_input header_b64 b. payload_b64 secret byour-256-bit-secret signature hmac.new(secret, signing_input, hashlib.sha256).digest() signature_b64 b64url_encode(signature) print(f{signing_input.decode()}.{signature_b64.decode()})HMAC的关键在于签名和验证用的是同一个密钥。也就是说谁拿到了这个密钥谁就能伪造JWT。所以在实际部署中HS256要求服务端妥善保护这个密钥Java里可以放application.yml的环境变量引用绝不能提交到Git仓库。RS256走的是另一条路私钥签名公钥验证。签发的服务持有私钥验证的服务可能是另一个微服务也可能是网关只持有公钥。好处是验证方不需要知道签发密钥可以放心地把公钥分发出去。多服务场景下A服务签发的tokenB、C、D服务用公钥一验就能通过不需要共享同一个对称密钥。代价是RSA签名运算比HMAC慢不少而且需要管理密钥对的生命周期。一个常见的推荐是内部服务间统一鉴权用RS256或ES256单体系统内用HS256。两者没有绝对的对错看你系统的信任边界。如果所有服务都在同一个信任域内共享一个HMAC密钥也不是不行但一旦有第三方接入或者你封了一个独立的Auth服务那必须上非对称算法。3. 登录验证实战从生成令牌到拦截器校验3.1 用户登录成功后JWT里该放什么、不该放什么这里先回答一个很多人纠结的问题JWT的Payload里到底放什么放少了后续业务每次都要查库补充信息放多了token体积膨胀且敏感信息裸奔。我的建议是放“不变”的稳定信息放“必须靠它鉴权”的关键信息。推荐放这些字段sub用户ID业务里一切鉴权的基础iat签发时间exp过期时间role角色标识如果权限检查很频繁或者直接放一个auth对象包含角色和权限码tenantId如果做多租户系统这个必须有tokenType固定写access_token便于与refresh_token区分不建议放的用户昵称可变、头像链接可变且可能很大、手机号敏感、数据库里的任何内部ID主键如果被解码看到可能拿去遍历泄露数据。用Java写一个签发逻辑基于jjwt库大概是这样SecretKey key Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRole()) .claim(tenantId, user.getTenantId()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 30 * 60 * 1000L)) .signWith(key, SignatureAlgorithm.HS256) .compact();这里设置了30分钟过期属于比较常见的配置。关键点是signWith这一行它绑定了算法和密钥。实际项目中密钥长度要求HS256要求密钥不小于256位32字节如果你传一个很短的字符串“secret”jjwt会直接抛异常。3.2 拦截器校验JWT三步走流程后端拿到请求怎么验证token以Spring Boot项目为例核心逻辑其实只有三步第一步从请求头里取token。惯例是放在Authorization头格式为Bearer tokenString authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new UnauthorizedException(缺失认证信息); } String token authHeader.substring(7);第二步解析并校验签名。这一步同时完成三件事验签确认token没被篡改、检查exp确认没过期、提取sub拿到当前用户IDClaims claims Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); String userId claims.getSubject();第三步把解析出的用户信息放进当前请求上下文。Spring里可以放RequestAttributes也可以放到自定义的ThreadLocal里这样后面的Service层直接读上下文拿userId不用每个方法都传参。有个极其常见的坑很多新人只做了验签没有单独判断过期时间就认为ok了。实际上绝大多数JWT库包括jjwt在解析时如果发现exp已经过去会抛ExpiredJwtException。你要做的是捕获这个异常返回一个明确的“登录已过期”语义而不是让用户看到一个500错误。3.3 无状态认证的服务端“记忆空白”问题JWT无状态确实省事但也带来一个天然的弱项签发之后你没法主动让某个token失效。Session存在服务器上你可以随时删掉JWT是自包含的只要签了名、没过期谁拿着它来都能通过。这就引出两个很现实的场景。第一个是用户改密码。用户在修改密码后原来签发的所有token理论上都应该作废不然盗号者拿着旧token还能继续操作。无状态JWT做不到自动失效常见补救是引入一个“token版本号”用户表里存一个token_version签发JWT时把它写进Payload校验时对比数据库中的版本号不一致就拒绝。第二个是用户被封禁或登出。JWT本身没有“登出”操作你需要像下面第5部分讲的那样要么引入Redis黑名单要么用短期token加刷新机制来解决。这两个问题我建议你在设计鉴权方案时就想清楚别等到线上出事故了再临时补丁。4. SPA项目里JWT的完整落地流程4.1 前端拿token存哪里localStorage还是内存SPA项目Vue、React这类接入JWT第一个逃不掉的问题是token放哪里我见过不少项目直接塞localStorage理由是“简单、刷新页面不丢”。但localStorage有一个致命问题任何注入到页面里的XSS脚本都能把它读走。一旦某个第三方脚本或输入点被利用用户token就裸奔了。那放sessionStorage呢同样逃不过XSS读取只是窗口关闭就没了。放内存变量里最安全但一刷新页面就没了需要配合刷新token机制来恢复登录态。这里没有完美答案实际项目里我常用的折中方案是普通管理系统、内部工具放localStorage结合CSP内容安全策略严格限制脚本来源。这是省事和安全的平衡。面向公网、资金交易类应用token放内存持久化用refresh_token放HttpOnly Cookie。刷新令牌走Cookie自动续期访问令牌走内存XSS拿不到。HttpOnly Cookie是关键它由后端通过Set-Cookie下发浏览器只会在请求时自动带上而JavaScript代码根本读不到它。这样即使用户被XSS攻击攻击者也拿不走Cookie里的刷新令牌。访问令牌即使被窃取也因为是短期令牌而很快过期危害窗口很小。4.2 请求拦截器自动携带Authorization头前端axios拦截器怎么配核心逻辑就是在每次请求前从存储里读出token塞进headeraxios.interceptors.request.use(config { const token localStorage.getItem(access_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });这段代码几乎是每个SPA项目的标配。但有一个细节你可能没注意你不需要在每个请求里都做“前置校验”。比如不用每次都在前端判断token过期时间再决定带不带——直接带上后端返回401了你再做统一拦截处理逻辑更干净。4.3 统一处理401自动续期与重放请求这是SPA里JWT体验好坏的分水岭。后端返回401可能意味着两件事token确实过期了或者token是伪造/被篡改的。前端拿到401怎么处理我的推荐做法是设计一个response拦截器axios.interceptors.response.use( response response, async error { const { config, response } error; if (response response.status 401) { if (config.url /auth/refresh) { // 刷新接口本身都401了说明refresh_token也过期了强制重新登录 router.push(/login); return Promise.reject(error); } // 标记这个请求已经重放过避免无限循环 if (!config._retry) { config._retry true; try { const newToken await refreshAccessToken(); config.headers.Authorization Bearer ${newToken}; return axios(config); } catch (e) { router.push(/login); return Promise.reject(e); } } } return Promise.reject(error); } );这里我加了一个config._retry标记这是踩过坑以后才加的。最开始没加结果刷新接口每次返回401拦截器就触发一次刷新刷新失败又触发拦截器……死循环浏览器控制台刷出一片红色报错。另一个容易忽略的点刷新令牌的过程中可能并发多个请求都拿到了401。它们会同时去调刷新接口导致刷新请求被重复发起几次。更合理的做法是加一个“刷新锁”多个请求共享同一个刷新Promiselet refreshPromise null; function refreshAccessToken() { if (refreshPromise) return refreshPromise; refreshPromise axios.post(/auth/refresh) .then(res { localStorage.setItem(access_token, res.data.access_token); return res.data.access_token; }) .finally(() { refreshPromise null; }); return refreshPromise; }这样在刷新请求还没回来之前所有401请求都在等待同一个Promise刷新接口只被调用一次。这个模式是我在各项目里见到过的最优解值得直接用。5. Token续签的三种方案续签不只有“延长过期时间”一种5.1 双重Token方案access_token refresh_token这是目前最主流的方式。access_token有效期短15分钟到2小时refresh_token有效期长7天到30天。用户带着access_token正常访问过期了就用refresh_token换一个新的access_token用户无感知登录态被“静默续期”。为什么要把两个token分开还是那句话token泄露的风险越大有效期就应该越短。access_token每次请求都带着暴露频率极高泄露风险也最高所以必须短命refresh_token不常使用只在续期时用可以相对长寿。但即使如此一般建议refresh_token最长不超过30天而且一定要在服务端做刷新记录限制。Refresh token的签发代码和access token差不多只是在Payload里用tokenType refresh_token区分并把有效期拉长String refreshToken Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(tokenType, refresh_token) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(key, SignatureAlgorithm.HS256) .compact();刷新接口的逻辑也很直白收到refresh_token验签确认tokenType确实是refresh_token防止有人拿access_token来换新的检查过期时间然后签一个新的access_token返回。如果refresh_token也过期了就让前端跳登录页要求用户重新登录。5.2 Redis滑动过期实现真正“活跃就永不掉线”双重token有一个体验上的问题哪怕用户一直在操作过了7天还是强制重新登录。如果希望“活跃用户永不掉线”就要用“滑动过期”思路——用户每次带着token访问就把过期时间往后推。无状态JWT本身做不到“滑动过期”因为token一旦签发内容就固定了。想实现必须借助Redis中间层签发token时在Redis里存一条记录key是token的jtiJWT IDvalue是用户IDTTL跟token有效期一致。每次校验token时如果Redis里这个key还有效就重新刷新TTL相当于续期。如果Redis里已经没有这个key了token即使没到exp也不认。这种方式相当于给无状态token加了一个“服务端状态层”。好处是你可以随时踢人下线删掉Redis记录即可也实现了滑动续期。坏处是JWT引以为傲的“不需要服务端状态”优势没了每次还要多一次Redis查询。适合登录态要求严格、需要随时能封禁用户的业务场景。很多做物联网、后台管理系统的项目会这样设计access_token短命比如15分钟Redis里额外续期用户在活跃时连续工作8小时都不会掉线但停下来超过15分钟再回来登录态就断了。这是我的个人偏好设置算是同时兼顾安全和体验。5.3 方案对比与选型建议三种方案没有绝对高低取决于你的系统和团队运维能力方案优点缺点适用场景单token长过期实现最简单token泄露风险窗口大内部工具、低安全要求双重token安全可控、体验好需要前后端配合、刷新逻辑复杂绝大多数Web/App应用Redis滑动过期支持踢人、滑动续期引入中间件、打破无状态优势后台管理、多端登录控制我个人对大多数新项目的建议直接用双重token方案配合Redis存refresh_token。这个组合在安全、体验、实现复杂度之间最均衡。Redis里存refresh_token还有个额外好处用户改密码时可以把旧refresh_token删掉实现“所有设备立即重新登录”。6. 绕过常见漏洞为什么你的token会被伪造或越权6.1 algnone最经典的降级攻击这是JWT最古老的漏洞。攻击者把Header里的alg改成none表示“我不签名了”再随便改一下Payload比如把sub改成别人的用户ID。如果服务端没有严格校验算法类型直接信任这个没有签名的token攻击者就完成了一次完美的身份伪造。很多JWT库在早期版本里默认允许algnone现在已经默认禁用了但如果你用的库比较老或者解析时没做白名单校验仍然可能中招。防御其实很简单解析时校验算法白名单只允许HS256、RS256这些你预先配置好的算法遇到none直接拒绝。JwtParser parser Jwts.parserBuilder() .setSigningKey(key) .build(); // 手动校验算法 String alg Jwts.header().get(alg).toString(); if (!HS256.equals(alg) !RS256.equals(alg)) { throw new UnauthorizedException(不支持的签名算法); }注意这种手动校验和库的默认解析是两个层次不要觉得用了库就万事大吉校验逻辑一定要显式写出来。6.2 算法混淆攻击RS256的公钥拿去验证HS256签名这个漏洞更隐蔽。服务端用RS256时验证方持有的是公钥。攻击者如果把Header里的alg改成HS256然后拿着RS256的公钥字符串作为HMAC的对称密钥来签名会怎么样如果服务端在校验时用的是同一个公钥字符串去做HS256的HMAC验证——巧了签名恰好能通过于是攻击者用“公开可获取的信息”完成了签名伪造。听起来像绕口令但历史上多家知名公司的JWT实现都中过招。防御方法就是严格区分算法类型不允许算法混用。在解析JWT时固定写死期望的算法不要从Header里读什么就用什么。这跟第6.1节的思路完全一致算法必须是白名单而且白名单里不能同时出现HMAC和RSA的算法防止混淆。6.3 高危Payload字段不要让“sub”直接等于主键很多教程喜欢把sub直接设成数据库用户表的主键id。看起来没毛病但有一个风险如果token被解码攻击者知道了你的用户ID规律就可以尝试构造伪造token去试探其他用户的身份——当然前提是他得拿到密钥。真正的问题在于Payload是可读的暴露主键容易给后续攻击提供线索。更稳妥的做法是JWT里的sub用一个公开IDUUID或者用户表的业务编号数据库主键只在对内的数据层使用。这样即使token内容泄露攻击者拿到的也只是一个不可预测的UUID无法直接推断数据库整体结构。6.4 密钥泄露的连环效应HS256模式下密钥一切。如果密钥泄露攻击者不仅能伪造任何用户的token而且你无法区分哪个token是真是假。密钥保护的几个红线不要硬编码在代码里更不要提交到Git仓库生产环境的密钥必须来自环境变量或配置中心且定期轮换不同环境dev、test、prod使用不同密钥密钥长度至少32字节用一个强随机数生成器产生如果怀疑密钥泄露立刻做的事换密钥、发布新版本同时清空所有已签发的token方式可以是更新全局token版本号让旧token全部失效。这一点结合前面讲的token版本号机制10分钟就能完成全网强制下线。7. 实测踩坑记录那些文档里不会写的细节7.1 时钟偏移问题JWT的exp校验依赖服务端系统时间。如果服务器时间不准确或者用户端和服务端时间差太大会出现两种尴尬情况token明明没过期服务端认为过期了或者过期了还能用。NTP时间同步是基础设施级方案代码层面也可以在解析时增加一个“允许偏移窗口”比如30秒内核库一般都有对应配置项。7.2 token体量膨胀有人喜欢把用户所有信息都塞进Payload角色、权限、昵称、头像、部门、菜单列表……塞到最后token编出来几百字节甚至超过1KB。这会带来两个问题每次请求header体积增大HTTP传输开销增加而且很多代理和网关对header大小有限制常见是8KB。我的建议是JWT只保留最小必要信息业务需要其他信息时根据sub现查或者用Redis做二级缓存。7.3 刷新令牌的并发问题前面提到的刷新锁是前端层面的防护。后端同样要注意同一个refresh_token被并发使用应该只成功一次。简单做法是在Redis里存refresh_token并保证刷新时原子性地删除旧token、写入新token。用Lua脚本或者SETNX等原子操作可以防止“重放攻击”——攻击者窃取refresh_token后即使成功换到新token也应该导致原token立即失效旧会话被迫下线。7.4 业务要求“记住我”时刷新token要不要续期很多系统有“7天自动登录”或“30天记住密码”的需求。这里有个容易踩的设计坑用户在7天里天天活跃结果第7天还是被踢下线了。如果你做“完全滑动续期”那“记住我”就变成了“永远登录”如果做“固定期限”理解上更符合业务直觉。我的建议是refresh_token采用“活跃续期最长上限”策略——每次刷新时更新refresh_token的过期时间但设置一个上限比如30天超过上限就必须重新登录。这样既照顾活跃用户也保证账号不会永久挂着不验密。8. 最后再补充一个生产环境的小技巧翻回头再说一遍认证流程的设计有一个层面很容易被忽略JWT解析失败时错误分类必须细。不要什么都返回401就去让前端刷新token。比如签名错误token被篡改跟过期token合法但到期性质完全不同——前者意味着可能有人伪造你的token需要告警并记录日志后者只是正常流程静默刷新就行。可以在后端定义统一异常映射catch (ExpiredJwtException e) { // 过期语义明确前端会拿refresh_token来换新的 throw new ApiException(401, TOKEN_EXPIRED, 登录已过期); } catch (JwtException e) { // 签名不合法这声是可疑情况记日志 log.warn(无效JWT签名: {}, e.getMessage()); throw new ApiException(401, TOKEN_INVALID, 无效的登录凭证); }前端拿到TOKEN_EXPIRED去走刷新流程拿到TOKEN_INVALID直接清空登录态跳登录页。这一层分类做好了线上排查问题时你会省掉大量时间。另一个实用建议统一令牌格式的日志脱敏。打印token时只显示前10位和后10位中间用***代替防止日志平台被拖库时token批量泄露。我自己就经历过一次日志系统泄露导致全量token被迫轮换的事故教训深刻。JWT本身不复杂复杂的是把它的生命周期管起来。从签发、校验、续期到应急吊销每一步都有对应的工程实践。上面的内容基本覆盖了日常开发中会遇到的主要场景希望能帮你少踩几个我已经踩过的坑。