几乎所有写过后端接口的开发者都经历过这样一个场景登录接口返回了一长串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把它按句点切开就得到三个独立部分第一段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9Header描述签名算法和类型。第二段eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQPayload也就是本文的主角携带实际业务数据。第三段SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cSignature对前两段内容做的签名用来防篡改。用Base64解码第一段你会看到类似这样的JSON{ alg: HS256, typ: JWT }其实Header里的信息很少就两个关键字段alg告诉我们签名用什么算法typ声明这是一个JWT。有些实现还会加kidKey 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信任链的三块基石issIssuer签发者表示Token是谁签发的取值通常是一个字符串或URI比如https://auth.example.com。验证方拿到Token后的第一步往往是检查iss是不是自己信任的那台签发服务器。在微服务架构里如果网关、订单服务、用户服务都各自校验Tokeniss就是第一道身份过滤器——不是我家签发的Token直接拒绝。subSubject主题表示Token的主体是谁通常放用户ID、用户唯一标识取值是字符串。注意sub必须在签发者范围内唯一。也就是说同一台签发服务器签发的所有Token里同一个sub必须对应同一个用户。使用sub时不要直接放主键自增数字而应该放一个全局唯一且不对外暴露敏感信息的标识。audAudience受众表示这个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时间戳三兄弟的使用与教训expExpiration Time过期时间最常见也最重要的字段。它的取值是NumericDate即从Unix纪元1970-01-01T00:00:00Z开始经过的秒数。服务端校验时只要当前时间大于等于expToken就视为过期。很多人习惯在客户端做过期判断但安全上必须以服务端时间为准并且要预留一定的时钟偏移容忍窗口比如允许±30秒避免服务器和客户端时钟不同步导致误判。nbfNot Before生效时间表示Token在何时之前不可用。它的语义和exp相反——nbf之前Token无效nbf之后Token生效。这个字段在灰度发布场景里很好用你可以提前发放一批Token指定它们在某个时间点之后才允许访问新版本接口。iatIssued 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容易被忽略的防重放标识jtiJWT IDToken的唯一标识取值是字符串用于防止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共享密钥简单方便但密钥分发是隐患跨系统、面向第三方开放时用RS256RSA非对称更稳妥私钥签名、公钥验签公钥可以公开分发。无论哪种服务端都要固定允许的算法集合防止算法混淆攻击——攻击者把alg从RS256改成HS256诱导服务端用公钥当对称密钥来验签从而自己伪造Token。这类攻击在真实安全报告中多次出现排查起来也很隐蔽。5. 自定义Claims与Payload安全红线5.1 自定义Claims的命名与取值建议私有声明虽然自由但命名上我建议遵守几条不成文规矩不要和注册声明的7个字段重名更不要用exp、sub这种名字去表达完全不同的含义。键名使用小写蛇形或小写驼峰团队内统一风格。面向外部开放时优先用URI命名空间比如https://example.com/claims/tenant_id避免和消费方自己定义的字段冲突。取值尽量用标量字符串、数字、布尔值避免塞入大对象。Claims是给机器看的不是给你存用户画像用的。另外千万别把Claims当成缓存数据库用。我见过有人把用户最近20条订单记录整个塞进PayloadToken直接膨胀到近10KB。客户端每次请求都要携带这串Token网关、服务端每次都要解码、验签请求延迟和带宽消耗都上去了。Payload里的Claims应该只放验证时必须要用的信息其他数据要么不传要么传到服务端后根据sub另行查询。5.2 Payload不是保险箱敏感信息红线这个红线我在第1部分提过这里再展开说透。由于Payload是Base64URL编码等于明文任何拿到Token的人都能解码看到里面所有内容。所以下面这类数据绝对不能放进Payload密码、口令、密钥身份证号、银行卡号完整的手机号、家庭住址任何你觉得不该给别人看的业务数据如果业务确实需要保密的Claim数据正解是用JWEJSON 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相关的线上事故都不会找上门。