JWT这个缩写,后端面试里出现频率快赶上HashMap了。你只要打开招聘软件搜Java后端或者Go后端,十有八九会在职位描述里看到它;真到了面试现场,面试官也爱从“认证怎么做”切入,聊着聊着就问你 JWT 结构到底是什么。网上的教程很多,但大部分要么只讲三段式编码,要么直接甩一个封装好的工具类,看完还是不懂为什么这么设计。这篇我按自己的理解把 JWT 拆成三条线来讲:它解决了什么问题、它内部长什么样、在真实项目里怎么落地,最后再把面试官真正想听的考点串一遍。不管你是准备面试的中级开发者,还是正在做登录权限模块想找个顺手方案,这篇都值得花十分钟看完。
1. JWT到底是什么:为什么它让 Session 方案显得笨重
1.1 先搞清楚 Session 和 Token 的本质区别
要理解 JWT,得先回到一个问题:HTTP 协议是无状态的。什么意思?就是你上次请求是什么样,服务器根本记不住,每个请求都是“陌生人”。最早大家用 Cookie + Session 解决记忆问题:用户登录成功后,服务器生成一个 sessionId,存到自己的内存或者 Redis 里,同时把 sessionId 种到浏览器的 Cookie 里。下次请求浏览器自动带上 Cookie,服务器拿 sessionId 去自己那边查一下,查到就算登录过。
这个方案很直观,但坑也很明显:服务器得保存大量会话数据。你的用户量一上来,单机内存顶不住,就得做 Session 同步或者把 Session 抽到 Redis。分布式环境下,每台服务器都得知道“这个人有没有登录”,要么依赖共享存储,要么做黏性会话,怎么搞都麻烦。
Token 的思路反过来了:服务器不保存会话,用户登录成功后,服务器把用户信息、过期时间这些数据按约定格式做成一个凭证字符串,发给客户端。客户端每次请求都带上这个字符串,服务器只需要校验字符串本身是否合法、是否过期,就能断定“这个请求是谁发出来的”。存储压力为零,水平扩容轻轻松松。
JWT 就是这种 Token 的一种标准化格式。它不是唯一的 Token 方案,但绝对是最流行的一种。
1.2 JWT 和普通 Token 不是一个东西
很多初学者会把“Token”和“JWT”划等号,这个是面试里最容易翻车的地方。普通的 Token 可以是一串完全随机的字符串,服务端收到后要去数据库或者 Redis 里查这串字符串对应的用户是谁,本质上还是“有状态”的,只是把 SessionId 换了个名字。而 JWT 是三段式的结构,用户信息、过期时间都写在 Token 里面,服务端拿到后直接解码就能知道用户是谁,不需要再查存储。
换句话说:普通 Token 是“凭据 + 服务端查询”,JWT 是“凭据 + 自包含数据 + 签名校验”。JWT 值钱的地方在于它把数据直接塞进了凭证里,同时用签名保证数据没被改过。这一点理解了,后面很多面试题都能迎刃而解。
1.3 JWT 适合哪些场景,不适合哪些场景
先说适合的:前后端分离项目、移动端 App、微服务之间调用、第三方开放平台授权。这类场景的共同点是客户端类型多、服务端实例多,用 Session 做共享会话会非常痛苦,JWT 的无状态特性正好踩在点上。
不适合的也有:如果你的业务要求“管理员能立刻把某个用户踢下线”,或者用户改密码后所有旧 Token 必须立刻失效,纯 JWT 做起来很难受,因为它在过期之前天然是有效的。这时候要么引入黑名单机制,要么干脆用回 Session。我说这些是想强调一个观点:JWT 不是银弹,它是最佳方案和妥协方案的结合体,选型的时候一定要看到它的边界。
2. JWT 结构拆解:Header、Payload、Signature 三段存了什么
2.1 先看一个真实 Token 长什么样
JWT 看起来就是一长串由点号分隔的字符串,分三段,格式永远是 xxx.yyy.zzz。随便给你一个真实例子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c第一段是 Header,第二段是 Payload,第三段是 Signature。很多人第一反应是“这玩意能不能解密出内容?”答案是:前两段根本不是加密,只是 Base64URL 编码。你随便找个在线解码工具,或者用 Python 里 base64 解一下,马上能看到明文 JSON。这也是后面安全部分要重点强调的:JWT 默认不加密,别把手机号身份证塞进去。
2.2 Header:告诉服务器“我用什么算法签的名”
Header 是一个 JSON 对象,最常见的两个字段是 alg 和 typ。typ 通常固定是 JWT,alg 是签名算法,常见的有 HS256、RS256、ES256。举个例子:
{ "alg": "HS256", "typ": "JWT" }alg 字段是整个 JWT 安全性的关键。因为在服务端验签的时候,必须知道这个 Token 是用什么算法生成的,才能用对应的算法重新计算签名。万一服务端没校验算法,直接信任客户端传过来的 alg,就会出现经典的 alg=none 攻击。这部分后面单独聊,面试高频。
有的 Header 里还会带 kid 字段,表示“用哪一把密钥来验签”。这在多密钥轮换、多应用共用一个验证服务时非常有用。不加 kid 的话,密钥一换,旧 Token 全废,线上事故就是这么出的。
2.3 Payload:真正有用的业务数据都在这
Payload 也是一个 JSON 对象,用来放用户相关信息和一些标准字段。JWT 规范预定义了一些字段,叫 Registered Claims,实际项目中经常用到的是这几个:
| 字段 | 全称 | 含义 |
|---|---|---|
| sub | Subject | 面向的用户,一般放用户ID |
| iss | Issuer | 签发方,比如你的应用名 |
| aud | Audience | 接收方,表示这个 Token 给谁用 |
| exp | Expiration Time | 过期时间,Unix 时间戳 |
| nbf | Not Before | 在这个时间之前不可用 |
| iat | Issued At | 签发时间 |
| jti | JWT ID | Token 唯一 ID,用于吊销和防重放 |
除了这些标准字段,你还能往里放自定义字段,比如 userId、userName、role。我在生产项目里通常只放最小必要数据:用户ID、用户名、角色,其他信息一概不放。因为每次请求都会带上这个 Token,Payload 越大,请求头越肥,而且这些数据等于是半公开的。
2.4 Signature:JWT 防篡改的核心机制
Signature 是整个 JWT 的灵魂。它的计算过程可以理解为:把 Base64URL 编码后的 Header 和 Payload 用点号拼起来,再用 Header 里声明的算法和一把只有服务端知道的密钥,对这个字符串做签名。以最常用的 HS256 为例:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )计算出来是一段二进制字节,再经过 Base64URL 编码,拼到前两段后面,就成了第三段。验签的时候,服务端拿到 Token,拆出前两段,用同样的 secret 重新算一遍签名,然后和 Token 自带的 Signature 做比对。一致就说明前两段内容没被改过。
用生活化的类比就是:你在支票上填好金额,然后盖上银行预留的印鉴。别人可以抄你的支票内容,但盖不出你的印鉴。只要印鉴对不上,银行就知道这张支票被动过手脚。这就是“防止数据被串改”的底层逻辑。攻击者不知道 secret,就算他把 Payload 里的 userId 改成别人的,重新编码后的签名也对不上,服务端直接拒绝。
2.5 HS256 和 RS256:对称密钥和非对称密钥的区别
这两个算法是面试常客。HS256 是对称算法,签名和验签用同一把 secret。优点是计算快、实现简单,缺点是 secret 必须在所有需要验签的服务之间共享,适合内部系统。
RS256 是非对称算法,私钥签名,公钥验签。签名方持有私钥,验签方只要拿到公钥就能校验,公钥可以随便分发。第三方开放平台、跨公司服务调用,基本首选 RS256,因为每个下游服务不需要知道私钥,泄密面小得多。代价是计算更慢、Token 也更大。
面试时如果被问倒,可以补一句“为了防止密钥泄露影响全部服务,RS256 在微服务场景下更安全,因为验证方即使被拖库,拿到的也只是公钥”。这句话很能体现你的架构意识。
3. JWT 认证流程:从登录到接口放行,整条链路到底怎么走
3.1 最典型的登录认证流程拆解
结合真实项目,JWT 最常用在“登录后访问受保护接口”这个场景。完整流程大概分七步:
- 用户提交账号密码到登录接口。
- 服务端校验用户名密码,通过后生成一个 JWT,把用户ID、过期时间、角色等数据放进 Payload。
- 服务端把 JWT 返回给前端,前端保存起来。
- 前端每一次请求,在 HTTP Header 里带上
Authorization: Bearer <token>。 - 服务端收到请求,先取出 Token,拆出 Header 和 Payload,用密钥重新计算 Signature。
- 签名一致,再检查 exp 是否过期,检查 nbf 是否已经到了可用时间。
- 全部通过,服务端从 Payload 里拿出 userId,放行接口逻辑。
这里有一个特别容易误解的地方:服务端如何知道用户是谁?答案就是 Payload 里的 sub 或 userId 字段。因为签名校验通过,说明这个 Token 是服务端自己签发的,Payload 里的可信的。所以后续接口直接用这个 userId 查业务数据就行,不需要再走一遍登录逻辑。
3.2 Token 存在哪里最安全:前端存储方案对比
前端拿到 JWT 后,到底放哪里?这个问题面试也能问出一堆。常见的三个位置:localStorage、sessionStorage、Cookie。
localStorage 的优点是好用,JavaScript 直接读写,缺点是任何 XSS 脚本都能偷走 Token。sessionStorage 差不多,但关掉浏览器标签页就没了。Cookie 的好处是可以设为 HttpOnly,JavaScript 读不到,能挡掉大部分 XSS 偷 Token 的问题,缺点是容易踩 CSRF 的坑,需要配合 SameSite 属性。
我在项目里的习惯是分场景:如果是纯前端项目,Access Token 放在内存变量里,页面刷新后通过 Refresh Token 再换一次;Refresh Token 放在 HttpOnly Cookie 里。如果项目复杂度不高,也可以把 Access Token 放 Cookie 并设置 SameSite=Lax,换取简单。核心原则是:能不放 localStorage 就不放,XSS 面前 localStorage 等于裸奔。
3.3 服务端校验代码演示:Java 和 Python 各来一个
Java 生态最常用的是 jjwt 库,引入依赖后校验代码非常简洁:
String secret = "your-256-bit-secret"; String token = request.getHeader("Authorization").replace("Bearer ", ""); try { Claims claims = Jwts.parserBuilder() .setSigningKey(secret) .build() .parseClaimsJws(token) .getBody(); String userId = claims.get("userId", String.class); // 校验通过,继续处理业务 } catch (JwtException e) { // 签名错误、Token 被篡改 throw new UnauthorizedException("token invalid"); } catch (ExpiredJwtException e) { // 过期 throw new UnauthorizedException("token expired"); }Python 用 PyJWT 更简单:
import jwt try: payload = jwt.decode(token, secret, algorithms=["HS256"]) user_id = payload["userId"] except jwt.ExpiredSignatureError: # 过期 pass except jwt.InvalidTokenError: # 签名不对或被篡改 pass很多人写代码的时候喜欢自己手写签名逻辑,我建议别这么干。成熟的库已经处理好了各种边界情况,比如 exp 检测、算法白名单,自己造轮子容易漏。面试时如果被问到底层原理,能手写 HMAC 计算过程会很加分,但生产代码请相信库。
3.4 Token 续签的三种主流方案
JWT 最大的痛点之一就是过期了怎么办。纯前端项目不可能让用户每 15 分钟重新输入一次密码,所以必须实现续签。目前主流的方案有三种:
第一种是前端拦截 401,用 Refresh Token 换新的 Access Token。用户登录时拿到两个 Token:Access Token 有效期短,Refresh Token 有效期长。Access Token 过期后,前端拿着 Refresh Token 请求刷新接口,换取新的 Access Token。这个方案灵活,缺点是要处理并发刷新,多个接口同时 401 时不能重复调用刷新接口。
第二种是滑动过期。只要用户在有效期内持续操作,服务端每次请求都顺带签发一个新的 Token,把过期时间往后推。像极了健身房会员到期前一直续卡。缺点也很明显:频繁签发新 Token,而且一个 Token 可以一直“续命”,安全边界很模糊。
第三种是双 Token 加 Redis 白名单。Refresh Token 签发给前端之外,同时把它的 jti 存到 Redis,设置好过期时间。每次刷新时要校验 Redis 里有没有这个 jti。这样一旦用户被踢掉,只要删掉 Redis 里的 jti,Refresh Token 立刻作废。这是我个人最推荐的生产级方案,兼顾了 JWT 的无状态优势和主动吊销的诉求。
4. 面试考点:面试官真正想听的其实是这八个问题
4.1 高频面试题速查表
| 问题 | 考察点 | 回答要点 |
|---|---|---|
| JWT 和 Session 有什么区别? | 基础认知 | Session 服务端存储、有状态;JWT 客户端存储、无状态、自包含 |
| JWT 三段结构是什么? | 基础结构 | Header、Payload、Signature,前两段 Base64URL 编码,第三段签名 |
| 为什么 JWT 能防止数据被篡改? | 原理理解 | HMAC 签名验签,secret 只有服务端知道,Payload 改动后签名对不上 |
| Token 过期了怎么办? | 工程能力 | Refresh Token 续签、滑动过期、黑名单 |
| 如何让 JWT 主动失效? | 工程能力 | Redis 黑名单、维护 jti、缩短过期时间 |
| JWT 安全风险有哪些? | 安全意识 | alg=none、密钥泄露、Payload 明文、XSS 偷 Token |
| 为什么要 Access Token + Refresh Token? | 架构设计 | 缩短 Access Token 暴露窗口,Refresh Token 用于续签 |
| Payload 里能放密码吗? | 安全意识 | 不能,Base64URL 是编码不是加密 |
这张表基本覆盖了 JWT 方向 80% 的面试问题,而且都是能往深里聊的题。面试官只要顺着其中一个问题往下追问,你如果没有完整理解,很容易露馅。
4.2 用一个故事把 JWT 讲清楚
面试时不要干巴巴背概念,试着用场景把原理串起来。我经常举一个酒店门卡的例子:你办入住的时候,前台确认你的身份,然后给你一张门卡。门卡上写好了你的房号,还有一个只有酒店系统能识别的校验信息。你走到房间门口,门锁只需要检查门卡上的校验信息和有效期,不需要打电话问前台“这个人订过房吗”。
这个故事对应到 JWT 上:前台是认证服务,门卡是 Token,房号是 Payload 里的用户ID,校验信息是签名,门锁是业务服务。门锁不依赖中央系统,这就是无状态。如果门卡上写的是“总统套房”,但你改成了“员工通道”,门锁一校验签名就知道卡被动过,直接拒绝。故事讲完,再补一句“所以 JWT 解决的是分布式场景下认证凭证的验真问题”,面试官对你的印象会很深。
4.3 手写一个 JWT 生成与校验的最小实现
不少面试官喜欢让候选人现场写 JWT 生成过程。虽然生产环境要用库,但手写能证明你真的理解原理。用 Python 演示最直观:
import base64 import hashlib import hmac import json import time def b64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def b64url_decode(data: str) -> bytes: padding = "=" * (-len(data) % 4) return base64.urlsafe_b64decode(data + padding) # 1. 构造 Header 和 Payload header = {"alg": "HS256", "typ": "JWT"} payload = {"userId": "1001", "role": "admin", "exp": int(time.time()) + 3600} # 2. 分别做 Base64URL 编码 header_part = b64url_encode(json.dumps(header, separators=(",", ":")).encode()) payload_part = b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{header_part}.{payload_part}" # 3. 用 HMAC-SHA256 计算签名 secret = b"my-secret-key" signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest() signature_part = b64url_encode(signature) # 4. 拼接完整 JWT token = f"{signing_input}.{signature_part}" print(token) # 5. 校验:重新计算签名并比对 received = token.split(".") received_signature = b64url_decode(received[2]) expected_signature = hmac.new(secret, f"{received[0]}.{received[1]}".encode(), hashlib.sha256).digest() print(hmac.compare_digest(received_signature, expected_signature))这段代码删掉注释也就二十行。面试时写出来,比背概念强得多。注意最后那行用了 hmac.compare_digest 而不是直接用==,这是防时序攻击的习惯,写出来也是加分项。
4.4 什么话一说出来,面试官就觉得你懂
概念题谁都背过,区分度在于你有没有踩过坑。面试官问完“JWT 好用吗”之后,你可以主动补两句:
第一句:JWT 把认证信息从服务端存储转移到了客户端,但同时也把吊销问题甩给了自己。Token 在过期前天然有效,所以纯 JWT 不等于无状态银弹,生产里必须配合短过期时间和刷新机制。
第二句:Base64URL 不是加密,是编码。敏感数据一定不能放 Payload,要么把数据放到服务端存储,要么用 JWE 做加密 JWT。
第三句:密钥管理比 Token 本身更重要。HS256 的 secret 一旦泄露,攻击者可以自己伪造任意身份的 Token;RS256 的私钥泄露同理。
这些话不是教程里抄的,是真实项目里被坑出来的。面试官听到这种表述,至少知道你上过生产环境。
5. 常见坑与安全风险实录:这些都是线上踩出来的教训
5.1 alg=none 漏洞:最经典的 JWT 攻击
这是 JWT 漏洞总结里出现频率最高的一条。攻击方法很简单:把一个合法 Token 的 Header 里 alg 改成 none,Payload 改成自己想要的用户ID,然后删掉第三段签名,看看服务端会不会放行。
有些服务端在验签时,完全信任 Header 里的 alg 字段。如果代码里没做算法白名单,遇到 none 就直接跳过验签,攻击成功。修复也简单:验签时明确指定允许的算法,不允许 none。用 jjwt 的话,可以在 parserBuilder 里显式设置算法:
Jwts.parserBuilder() .setSigningKey(secret) .setAllowedClockSkewSeconds(60) .build()同时,服务端代码里要有一个算法白名单校验,比如只接受 HS256。很多库新版本已经默认拒绝 none,但如果你用了老版本库,或者自己拼字符串解析 Token,中招概率极大。这个坑值得反复提醒。
5.2 密钥泄露与离线暴力破解
HS256 是对称密钥,只要 secret 泄露,攻击者就能用同一个 secret 伪造任意身份的 Token。更隐蔽的风险是:如果 secret 太短,攻击者拿到一个合法 Token 后,完全可以在本地离线跑字典,暴力猜解 secret。曾经有人把 secret 设成 “secret”,几秒钟就被跑出来。
所以生产环境对 secret 的要求是:足够长、足够随机,定期轮换。而且密钥要放进配置中心或者环境变量,绝对不能提交到代码仓库。我见过一次事故,就是开发把 secret 写死在配置文件里,结果代码仓库泄露,所有 Token 全部要重签,线上服务瘫痪了一个小时。
5.3 Token 失效和注销困境
JWT 过期之前,服务端怎么让它立刻失效?答案是:不好办。我把这块单独拎出来说,因为很多人用 JWT 做登录后,某天产品提了个需求:“管理员要能踢用户下线”,结果发现 JWT 根本踢不掉。
常规解法是引入黑名单机制:每个 Token 生成时带一个唯一的 jti,写进 Redis,TTL 设成和 Token 剩余有效期一致。正常情况不查,一旦需要踢人,把 jti 加进黑名单,下次请求校验时先查一下黑名单。这相当于给无状态凭证加了一个“临时状态”,牺牲了一点无状态性,但换来了可控性。
如果你的业务里踢人是常态操作,比如后台管理系统频繁调整权限,那 JWT 未必是最好的选择。这点在技术选型时就要想清楚,而不是上线之后补窟窿。
5.4 续签与多端登录的坑
刷新 Token 也有坑。最典型的是并发刷新:Access Token 过期后,前端同时发 5 个请求,全部 401,然后每个请求都触发一次刷新,Refresh Token 被重复使用。有些服务端规定 Refresh Token 只能换一次,新的换出来旧的立刻作废,那并发刷新直接连环失败。
解决思路有两种:前端把刷新请求做成单例,401 后只发一个刷新请求,其他请求排队等待新 Token;服务端允许 Refresh Token 在短时间窗口内重复使用,但返回同一个新的 Access Token。
多端登录是另一个坑:用户在一台手机上退出登录,服务端如果只删了本地 Token,用户在其他设备上的会话不受影响。要做到“踢掉所有设备”,得给每个会话分配独立的 jti,退出时把对应 jti 扔进黑名单。不做这一步,用户体验就是在 A 设备退出后,B 设备还能继续操作,然后你会收到一堆“为什么不能退出”的客诉工单。
5.5 别把 JWT 当加密用
这个坑太常见了。Base64URL 是一种编码,不是加密。任何人拿到你的 JWT,分解出中间那一段,用几行代码就能秒解出原始 JSON。你把手机号、身份证号、家庭地址塞进 Payload,等于把这些信息明文送给了所有能看到请求的人。
正确做法是:Payload 里只放用户ID、角色这种不敏感标识,其余信息需要的时候再查接口。如果业务确实要在 Token 里携带敏感数据,应该用 JWE(加密 JWT)而不是普通 JWT。另外,前端开发调试工具都会显示请求头,如果 Token 里有敏感字段,开发的时候截图一贴,数据就泄出去了。
5.6 第三方登录与 Token Exchange 常见报错诊断
现在很多项目接入了第三方授权登录,报错里经常出现 token exchange failed 这类信息。除了 JWT 本身,还有 OAuth2 协议里的 Token Endpoint 调用失败。最常见的几个原因:授权码已过期或已被使用、redirect_uri 和注册的不一致、client_id 或 client_secret 写错、系统时间和服务器时间偏差太大导致 nbf 和 exp 校验失败、网络代理拦截了 Token Endpoint 请求。
遇到这类报错,我的排查顺序是:先看请求参数里授权码是否有效,再看 redirect_uri 是否和注册时完全一致,然后确认 client 凭证没写错,最后检查服务器时间同步。80% 的 exchange 失败都出在这四步。这条经验在接入微信扫码登录、企业微信登录、各类第三方 SSO 时都通用,建议大家收藏。
结尾:关于 JWT,我自己的几个体会
文章写到这里,最后说点个人心得。JWT 从来不是一个“用就完了”的东西,它背后的设计哲学是:把验证成本从服务端转移给密码学。这本身很优雅,但代价就是你必须更谨慎地管理密钥、过期时间、刷新策略。我在实际项目里看到过太多人把 JWT 当成万能钥匙,一套方案通吃所有场景,结果不是 Token 没法吊销,就是续签逻辑写成面条。
如果让我给你一个最实用的建议,那就是:先想清楚“我这个 Token 允不允许在真实业务中被主动作废”,再决定要不要用纯 JWT。如果你选了 JWT,那就在第一版就把 jti、过期时间、刷新 Token 的 Redis 存储一起做进去,不要等上线后被需求追着改。最后分享一个小技巧:给生成的 JWT 里固定塞一个 jti,即使不用黑名单,排查线上问题时也能靠它精确定位到是哪一个客户端、哪一次登录发出去的 Token。这个字段很不起眼,关键时刻比什么都好使。