几乎所有写过后端接口的开发者,都经历过这样一个场景:登录接口返回了一长串token,你把它粘贴到jwt.io上,中间那段Base64字符串里清清楚楚写着用户ID、角色、过期时间,有时候甚至能看到手机号和邮箱。那段字符串就是JWT的Payload载荷,里面一个个键值对就是Claims。可惜大多数人对它的理解停在"键值对"这个层面——到底应该放哪些字段、服务端必须校验哪些字段、哪些字段绝对不能往里放,很多人是踩了坑之后才真正搞明白的。
这篇文章我打算把JWT的Payload和Claims一次性讲透。从JWT的三段结构开始,讲清楚注册声明、公开声明、私有声明的边界,再把七个标准Claims逐个拆开看行为细节,最后用jwt.io完整走一遍编码、解码、校验的流程,并把我实际开发中遇到过的坑一并交代。写给自己备忘,也写给刚接触JWT的同学做参考。
1. 先理解JWT的三段结构:Header、Payload、Signature各管什么
1.1 一个Token到底长什么样
JWT的全称是JSON Web Token,它不是一个随机字符串,而是三段用英文句点分隔的Base64URL编码文本。随便找一个真实的Token,大概是这样的:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c把它按句点切开,就得到三个独立部分:
- 第一段
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9:Header,描述签名算法和类型。 - 第二段
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ:Payload,也就是本文的主角,携带实际业务数据。 - 第三段
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c:Signature,对前两段内容做的签名,用来防篡改。
用Base64解码第一段,你会看到类似这样的JSON:
{ "alg": "HS256", "typ": "JWT" }其实Header里的信息很少,就两个关键字段:alg告诉我们签名用什么算法,typ声明这是一个JWT。有些实现还会加kid(Key ID),用来标识服务端用哪把密钥验签。Header的作用是给验证方提供"如何验签"的元信息,它本身不携带任何业务数据。
1.2 Payload为什么被误认为"加密内容"
这是我在答疑时碰到最多的问题。很多同学看到Payload是一串看不懂的字符,就以为它是加密的,觉得把密码、手机号放进去也很安全。这个认知是错的,而且错得很危险。
Payload用的编码叫Base64URL,它和Base64是同一族的东西,只是把+替换成了-,把/替换成了_,并去掉末尾的=填充符,以便在URL里安全传输。它本质上就是一段"可逆的编码",你不需要任何密钥,拿任何一个在线工具都能把它解码回原始JSON。
Signature做的事情也不是加密,而是防篡改。只要Header或Payload中的任何一个字符被改动,用同样的密钥重新计算签名就会得到完全不同的结果,验签方就能立刻发现Token被人动过手脚。但请注意,签名只保证"没人篡改",不保证"别人看不懂"。这两件事经常被混为一谈。
所以开篇请先记住三条铁律:
- Header负责描述算法,Payload负责携带数据,Signature负责保证前两者不被篡改。
- Base64URL编码不是加密,任何人都能无损解码Payload。
- 敏感信息进入Payload之前,先问自己一句:明文发给客户端,我能接受吗?
2. Claims的三种来源:注册声明、公开声明、私有声明的边界在哪里
在JWT规范(RFC 7519)里,Payload被定义为"一组Claims的集合"。所谓Claim,就是"关于某个实体的一个事实断言",落到JSON里就是一个键值对。规范把Claims分成三类:注册声明、公开声明、私有声明。
2.1 注册声明:规范强制约定的字段
注册声明是一组由RFC 7519预先定义好的字段名,它们有标准化的语义,任何遵循规范的实现都应该理解这些字段。总共有7个:iss、sub、aud、exp、nbf、iat、jti。
这些字段不是可选的摆设。虽然规范里它们的约束级别大多是"建议使用"(RECOMMENDED)而非"必须"(REQUIRED),但在真实生产环境中,签发方和验证方只要协同工作,exp、iss、aud这几个基本就是标配。我在第3部分会逐个细讲。
2.2 公开声明:IANA注册表中的"公共词汇"
公开声明指的是在IANA的"JSON Web Token Claims"注册表中登记过的字段名,或者使用URI作为字段名以避免冲突的字段。
IANA注册表里有很多现成的字段可供复用,比如:
name:用户的全名given_name:名family_name:姓preferred_username:偏好用户名email:邮箱地址email_verified:邮箱是否已验证phone_number:手机号picture:头像地址locale:语言区域updated_at:资料最近更新时间
为什么要用注册表里的名字?因为大家都按同一套词汇表说话,字段语义一致,不同团队、不同系统之间对接时不会产生歧义。比如业务A自定义一个字段叫name表示用户昵称,业务B也定义name表示真实姓名,两边对接时就会出幺蛾子。而用IANA注册表里的name,大家默认就是"全名"。
另一种公开声明的做法是使用可解析的URI作为键名,比如"https://example.com/claims/tenant_id": "tenant_123"。这种命名方式能最大程度避免碰撞,因为域名是全局唯一的。很多跨公司、跨平台开放API场景下会采用这种写法。
2.3 私有声明:业务方自己定义的键值对
私有声明就是签发方和验证方私下约定的字段,不注册、也不做全局标准化。比如最常见的:
{ "user_id": 10086, "role": "admin", "permission": ["read", "write"] }这类字段是业务需求的直接产物,该怎么定义完全看系统设计。但使用私有声明要注意一个问题:键名可能和注册声明撞车。比如你自定义一个exp表示"体验分",那就和标准的"过期时间"冲突了,验证方拿到Token后会产生严重误解。规范里对这类情况的处理办法是:如果自定义含义,就不要使用标准字段名;如果确实需要类似语义,就给键名加命名空间前缀。
我个人见过不少团队在私有声明上踩的另一个坑是:字段越加越多,一个Token被塞进几十个键值对,最终导致整个Token体积飙到几KB。Claims数量应该克制,只放验证方真正需要的信息,具体我在第5部分再展开。
3. 七个标准Claims逐个拆解,以及它们之间的配合关系
注册声明的7个字段,我建议你按照"身份链、时间链、防重放"三个维度去理解。
3.1 iss、sub、aud:信任链的三块基石
iss(Issuer,签发者):表示Token是谁签发的,取值通常是一个字符串或URI,比如"https://auth.example.com"。验证方拿到Token后的第一步,往往是检查iss是不是自己信任的那台签发服务器。在微服务架构里,如果网关、订单服务、用户服务都各自校验Token,iss就是第一道身份过滤器——不是我家签发的Token,直接拒绝。
sub(Subject,主题):表示Token的主体是谁,通常放用户ID、用户唯一标识,取值是字符串。注意,sub必须在签发者范围内唯一。也就是说,同一台签发服务器签发的所有Token里,同一个sub必须对应同一个用户。使用sub时不要直接放主键自增数字,而应该放一个全局唯一且不对外暴露敏感信息的标识。
aud(Audience,受众):表示这个Token是"发给谁用的"。它的取值可以是一个字符串,也可以是一个字符串数组。比如一个Token同时要被订单服务和支付服务使用,aud可以写成["https://orders.example.com", "https://pay.example.com"]。资源服务器校验时,必须检查自己的标识是否在aud列表里,否则就拒绝。aud是防止Token被"张冠李戴"的关键手段——一个发给A服务的Token,不应该能直接拿去访问B服务。
这三者的配合逻辑通常是:iss决定"我信不信这个签发者",sub决定"这是谁",aud决定"这个Token有没有资格用在我这儿"。三者都不是可选的摆设,缺少任何一个都会留下信任链漏洞。
3.2 exp、nbf、iat:时间戳三兄弟的使用与教训
exp(Expiration Time,过期时间):最常见也最重要的字段。它的取值是NumericDate,即从Unix纪元(1970-01-01T00:00:00Z)开始经过的秒数。服务端校验时,只要当前时间大于等于exp,Token就视为过期。很多人习惯在客户端做过期判断,但安全上必须以服务端时间为准,并且要预留一定的时钟偏移容忍窗口,比如允许±30秒,避免服务器和客户端时钟不同步导致误判。
nbf(Not Before,生效时间):表示Token在何时之前不可用。它的语义和exp相反——nbf之前Token无效,nbf之后Token生效。这个字段在灰度发布场景里很好用:你可以提前发放一批Token,指定它们在某个时间点之后才允许访问新版本接口。
iat(Issued At,签发时间):记录Token是什么时候签发的。它本身不参与合法性判断,但非常有用——排查问题、估算Token生命周期、配合重放检测时都离不开它。
时间戳三兄弟最容易踩的坑是单位混淆。exp、nbf、iat的值必须是秒,不是毫秒。JavaScript里Date.now()返回的是毫秒数,直接用会得到一个比正确值大1000倍的时间戳,看起来"过期时间在千年之后",Token永远不过期,安全隐患极大。正确写法是:
// JavaScript生成JWT的Payload时 const payload = { iss: 'https://auth.example.com', sub: 'user_123456', aud: 'https://api.example.com', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 7200 // 2小时后过期 };另外还有一个工程细节:签发Token时服务器框架一般会自动带上iat,但exp必须由业务自己算好再放进去。我看到有的同事把exp和iat写成相同值,导致Token签发即过期,排查了半天才发现是时间戳算错了。
3.3 jti:容易被忽略的防重放标识
jti(JWT ID):Token的唯一标识,取值是字符串,用于防止Token被重复使用(重放攻击)。虽然jti在规范里是"可选"的,但在高安全场景下我强烈建议加上。
实现思路是:签发时给每个Token生成一个UUID作为jti,服务端维护一个"已使用jti"列表或"黑名单"。收到Token时先校验签名,确认有效后再查jti是否已经出现过,如果出现过就拒绝。配合短时效的Token,能有效抑制重放风险。不过jti的管理需要额外的存储和查询开销,普通内部系统可以不启用,但面向支付、提现这类操作时最好加上。
4. jwt.io实操:编码、解码、校验一条完整链路
工具层面,最常用、最直观的就是jwt.io这个在线站。它没有复杂的命令,打开页面就是你熟悉的三个输入框和一段实时拼装出来的Token。下面我按"编码、解码、校验"三个动作,把完整操作链路走一遍。
4.1 在jwt.io上手工编码一个Token
打开jwt.io首页,页面左侧从上到下依次是Header输入框、Payload输入框、Secret输入框,右侧是实时生成的Token和自动解码后的三段内容。
第一步,确认Header。默认值通常是{"alg":"HS256","typ":"JWT"},一般不用改。如果你想用非对称算法RS256,把alg字段改成RS256,页面会自动弹出公钥/私钥输入框。
第二步,填写Payload。我这里用一个包含标准Claims和私有Claim的完整示例:
{ "iss": "https://auth.example.com", "sub": "user_123456", "aud": "https://api.example.com", "exp": 1770000000, "iat": 1769992800, "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "role": "admin" }第三步,填写Secret。使用HS256时,Secret就是双方共享的对称密钥。注意HS256的密钥长度必须至少256位,也就是32字节。你要是填了123456这种短密钥,很多库运行时会直接报错,即便不报错,短密钥也很容易被暴力破解。任选一个32字符以上的随机字符串填入即可。
第四步,观察右侧。你会发现Token的三段内容随着你的输入实时变化:Header和Payload各自被Base64URL编码,第三段签名是这两段文本配合Secret计算出来的结果。你可以试着改一下Payload里的任意字符,右侧第三段签名会立刻变成完全不同的字符串——这就是"防篡改"的直观体现。
提示:HS256是对称签名,签发的密钥和验证的密钥是同一个,Secret一旦泄露,任何人都能伪造合法Token。生产环境绝对不要把Secret提交到代码仓库里,应该放在环境变量或密钥管理服务中。
4.2 解码已有Token,读懂它的Payload
手头有现成的Token时,直接把完整Token粘贴到jwt.io右侧的"Encoded"输入框,左侧会自动解码出Header、Payload和Signature三部分。解码动作本身不依赖任何密钥,谁都能做,这也是为什么反复强调"别把敏感信息放Payload"。
解码之后,页面会用JSON格式清晰展示每一个Claims。这时候你应该养成的习惯是:拿到一个Token,先看它的iss是不是自己信任的签发源,再看aud里有没有自己,再看exp是否已过期,最后才去读取sub和其他业务Claims。顺序反了就容易被表面信息带偏。
4.3 校验签名的正确姿势与算法选择
jwt.io左侧最下方有一个"签名校验"区。HS256时填入共享Secret,如果页面显示"Signature Verified",说明签名匹配;RS256或ES256时需要填入公钥进行验签。
但这里有一个非常关键的误区:jwt.io上的"Signature Verified"只表示签名算法和密钥能对上,并不代表这个Token可以放心使用。它不等于你完成了全部校验。真正的服务端校验至少应该包含:
- 签名是否有效(用正确的密钥/公钥验签)。
alg是否在允许列表中,拒绝none算法。iss是否可信。aud是否包含当前服务标识。exp是否已过期,nbf是否已生效。- 必要的
jti重放检查。
算法选择上,内部系统用HS256共享密钥简单方便,但密钥分发是隐患;跨系统、面向第三方开放时,用RS256(RSA非对称)更稳妥,私钥签名、公钥验签,公钥可以公开分发。无论哪种,服务端都要固定允许的算法集合,防止算法混淆攻击——攻击者把alg从RS256改成HS256,诱导服务端用公钥当对称密钥来验签,从而自己伪造Token。这类攻击在真实安全报告中多次出现,排查起来也很隐蔽。
5. 自定义Claims与Payload安全红线
5.1 自定义Claims的命名与取值建议
私有声明虽然自由,但命名上我建议遵守几条不成文规矩:
- 不要和注册声明的7个字段重名,更不要用
exp、sub这种名字去表达完全不同的含义。 - 键名使用小写蛇形或小写驼峰,团队内统一风格。
- 面向外部开放时,优先用URI命名空间,比如
"https://example.com/claims/tenant_id",避免和消费方自己定义的字段冲突。 - 取值尽量用标量(字符串、数字、布尔值),避免塞入大对象。Claims是给机器看的,不是给你存用户画像用的。
另外,千万别把Claims当成缓存数据库用。我见过有人把用户最近20条订单记录整个塞进Payload,Token直接膨胀到近10KB。客户端每次请求都要携带这串Token,网关、服务端每次都要解码、验签,请求延迟和带宽消耗都上去了。Payload里的Claims应该只放"验证时必须要用"的信息,其他数据要么不传,要么传到服务端后根据sub另行查询。
5.2 Payload不是保险箱:敏感信息红线
这个红线我在第1部分提过,这里再展开说透。由于Payload是Base64URL编码,等于明文,任何拿到Token的人都能解码看到里面所有内容。所以下面这类数据绝对不能放进Payload:
- 密码、口令、密钥
- 身份证号、银行卡号
- 完整的手机号、家庭住址
- 任何你觉得"不该给别人看"的业务数据
如果业务确实需要保密的Claim数据,正解是用JWE(JSON Web Encryption)对Payload做加密,而不是JWS签名。但JWE实现复杂度更高,大多数业务场景用不上。更常见也更合理的做法是:Token里只放一个不可猜测的引用ID,需要敏感数据时,服务端用这个ID去后端存储里取。这样即便Token泄露,泄露的也只是一个无意义的索引。
5.3 几个看着简单却容易踩的坑
最后把我自己和周围同事实际踩过的坑汇总一下,希望你绕过去。
坑一:改了Payload,忘了重新签名。在jwt.io上调试验签时,手动改了Payload字段,但签名还是旧的,服务端验签直接失败。这在联调阶段非常常见,记住一个原则:任何改动Header或Payload的行为,都必须重新生成完整Token,不是只改其中一段。
坑二:exp和nbf做过期判断时用错了时间源。服务端一定要用服务器时间做校验,不要信任客户端传过来的时间。多实例部署时,各服务器的时钟要同步(比如通过NTP统一校时),否则同样的Token在一台机器上有效、在另一台上就过期了,用户会感觉"时好时坏"。
坑三:拿到了Token就信任它。任何来自网络侧的Token,在验签之前都只是一串不可信的文本。先验签、再校验Claims,顺序不能反。特别是role这类权限字段,如果放在Payload里且服务端没有正确验签就读取,攻击者只要把Token的Payload解开,把"role":"user"改成"role":"admin",再重新打包成一个未签名的Token,就可能绕开权限控制——前提是服务端没有校验签名。
坑四:把HS256的Secret当成RS256的私钥用。HS256要求密钥至少32字节,但很多人用的是24位私钥直接转成HS256密钥,或者反过来。算法和密钥必须配套,否则要么密钥强度不够,要么验签必然失败。
做Token签发和校验时,我个人最后会反复检查三件事:密钥有没有泄露、exp校验有没有真的在服务端执行、Payload里有没有不该出现的敏感字段。这三条守住,大部分JWT相关的线上事故都不会找上门。