多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JWT Claims详解:Payload设计、标准字段与自定义规则

JWT Claims详解:Payload设计、标准字段与自定义规则 行内做后端接口鉴权最常用的就是JWT。很多人把JWT调通之后就复制粘贴一个生成和校验的函数到处用但对中间那一段Payload到底放了什么、能放什么、不该放什么其实没细看。等到要设计登录态、做权限控制或者多端互信的时候才发现Claims才是整个JWT的核心——签名保证的是“没人篡改”Claims才决定“你是谁、能干嘛、什么时候失效”。这篇文章就把JWT的Payload这部分彻底讲透。你会看到Claims的三种分类、每个标准Claim的语义和坑、自定义Claim的设计规则还有在jwt.io上实际编码、解码、校验的操作过程。项目里如果正在用JWT做会话或接口鉴权这篇应该能帮你少走几次弯路。1. JWT结构与Payload在整条链路中的位置1.1 三段式结构一句话说清一个JWT长成这种样子eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c三个部分用点号分隔分别是Header算法和token类型通常就是{alg:HS256,typ:JWT}。Payload真正携带业务数据的部分里面每一个键值对就是一个Claim。Signature对前两段做的签名或加密结果用来防篡改和验证来源。很多人以为JWT的“安全”体现在Payload里的数据加了密其实恰恰相反。常规JWT的Payload只是Base64Url编码任何人拿到token都能直接解码看到内容。这不能叫加密只能叫序列化。1.2 Payload不等于明文传输但也不等于安全传输在jwt.io页面的Decoded区域你能一眼看到Payload里的所有Claims这就是因为工具帮你做了Base64Url解码。所以任何需要隐藏的数据比如密码、身份证号、信用卡号绝对不要放进Claims。既然Payload是可见的那它存在的意义是什么是为了让服务端在无状态场景下拿到一组“自包含”的断言。服务端不再去查数据库直接信任签名和Claims里描述的过期时间、用户标识、角色信息。这把鉴权从存储查询变成了纯计算分布式环境下非常好用。1.3 Claims就是Payload里的一个个断言Claim翻译成“声明”或“断言”更准确。每个Claim就是一个“键值对”表达一种事实。例如sub:user_123表示这个token的主体是用户user_123role:admin表示持有者具有管理员角色。服务端校验token之后实际上是在逐条读取这些Claim然后基于它们做业务决策。理解了这一点你就明白为什么Claims的设计会直接影响系统的安全性。对于一个校验严密的系统来说只有经过签名验证的Claims才可信一旦某个环境漏掉了签名校验那Claims就只是用户自己写的便利贴什么都能往里面写。2. Claims的分类标准与设计约束JWT规范里把Claims分成三类注册Claim、公开Claim、私有Claim。分类的核心意义在于约定避免大家随便定义导致互操作混乱。2.1 Registered Claims规范定义的字段必须理解语义注册Claim是IANA注册表里定义好的标准字段。最常见的有七个iss、sub、aud、exp、nbf、iat、jti。这些字段的键名是保留的任何想用exp表达别的含义的做法都是反规范。关键不是记住缩写而是记住每个字段的取值规则iss签发者。通常是签发token的服务标识比如auth-service接受方可以用来判断是不是信任的签发方。sub主体。表示这个token属于谁一般是用户ID。规范要求在同一签发者范围内唯一。aud受众。表示这个token给谁用可以是一个字符串或字符串数组。服务端必须校验此字段防止一个token被别的服务重复使用。exp过期时间。Unix时间戳格式表示在这个时间点之后token无效。校验方必须检查当前时间是否小于exp。nbf生效时间。早于该时间token不可用常用于延迟生效的场景。iat签发时间。表达token是什么时候生成的便于排查和统计。jti唯一标识。每一个token的唯一ID用于防重放或吊销单个token。2.2 Public Claims公共约定字段需要避免冲突公开Claim通常是开发者、组织或标准机构在IANA注册表中登记的字段或者用类似UUID的命名避免冲突。比如常见的name、email、preferred_username就是OpenID Connect标准里定义的公开Claim。如果没有注册随意使用这些键名容易造成语义混乱。比如你定义一个name是用户的中文姓名别人定义name是用户昵称两边服务互调就会出现解析问题。所以公开Claim的使用原则是优先使用标准定义自定义字段名称尽量带命名空间或前缀。2.3 Private Claims自定义字段核心业务数据所在私有Claim是签发方和消费方私下约定的字段。比如role:admin、permissions:[read:order,write:order]、tenant_id:t123。这部分字段完全由业务控制没有统一标准。私有Claim是JWT最灵活的部分也是出问题最多的部分。比如把一个对象、一个数组、甚至一整个列表塞进Claims里导致token体积膨胀到几KB。HTTP头一般能放下但每个请求都背着这么大一个token带宽和解析成本都是浪费。后面我会详细讲怎么控制Claims的体积。3. 核心标准Claim逐项拆解与实战坑点3.1exp与nbf时间校验是安全命门exp和nbf的值都是数字时间戳单位是秒。很多人会在这里踩坑写代码时直接用new Date()的毫秒值赋值导致token生成后马上显示过期。因为exp期望的是秒级时间戳毫秒级数值比实际时间大了1000倍当前时间永远小于它token永远不会过期或者反过来校验方法里用秒级比较导致提前过期。比较规范的处理方式// Node.js示例 const nowInSeconds Math.floor(Date.now() / 1000); const token jwt.sign( { userId: 123, exp: nowInSeconds 3600 }, secret, { algorithm: HS256 } );更推荐的做法是不手动拼exp而是直接让库来算。大多数JWT库的sign方法里有expiresIn参数const token jwt.sign({ userId: 123 }, secret, { expiresIn: 1h });库会自动帮你加上exp。手动添加和库参数同时使用时要小心某些库可能会各自覆盖最好选一种。nbf常常用于定时发布场景比如优惠券或活动在特定时间点生效。如果系统时钟存在偏移nbf与exp之间的容错窗口要适当放宽。服务端校验时常见的容错设置是允许60秒以内的时钟偏差避免客户端和服务端时间不一致导致token短时间不可用。3.2sub标识稳定不要塞会变的字段sub是主体标识。设计原则是它应该是用户的唯一标识且永久不变。不要用手机号、邮箱这类可能被用户修改的字段否则用户改了手机号就相当于换了一个身份。在微服务架构下不同服务都依赖sub做用户维度数据关联如果sub不稳定会导致数据无法串联。推荐用数据库自增主键或全局UUID并且所有服务读取用户信息时都以sub为键。3.3aud多服务场景必须校验aud让我吃过一次亏。当时做了一个网关架构网关签发token后台多个服务各自校验。因为偷懒校验逻辑里只看了签名和过期时间没验aud。结果业务服务A签发的token拿到服务B的接口上也能通过校验——签名密钥相同的情况下所有下游服务都变成了完全互信。正确的做法是每个服务校验aud是否为自身服务名。签发时用数组指定多个受众也是允许的{ aud: [order-service, payment-service] }消费方校验时只要当前服务名存在于aud数组中即可通过。3.4iat与jti审计和吊销的基础iat用于记录签发时间。它本身不直接影响安全性但对于排查问题很有用比如看一个token是不是很久之前签发的、是否超过了业务允许的最长生命周期。jti是token的唯一标识适合用在以下几个场景吊销单个token服务端维护一个jti黑名单注销时把jti加进去校验时检查是否存在。防重放攻击同一个jti只允许被使用一次防止请求被截获后重复提交。日志关联把jti写在日志里可以精确追踪一次会话的完整请求链路。生成jti用UUID即可但要注意长度。如果每个请求都生成新tokenjti带来的存储和查询压力不可忽略。4. 自定义Claims的设计原则与权限模型实战4.1 Claims冗余与权限膨胀最常见的失控方式为了省事很多团队会把用户所有信息一股脑塞进Claims头像、昵称、性别、城市、积分、等级、优惠券数量甚至通讯录。这种做法的直接后果是token越来越大每次请求都要携带这么多冗余数据。更隐蔽的问题是权限膨胀。你把role:admin直接写进Claims一旦角色调整或用户被封禁已经签发的token在有效期内依然是管理员。这就是“沉没权限”。所以设计Claims时控制权限粒度很重要。4.2 权限信息放Claims还是放缓存这个问题没有标准答案取决于你对实时性的要求。如果权限变更频率低角色表相对固定可以把角色、权限码放入Claims。优点是服务端无状态校验快。缺点是修改权限后必须等token过期或强制刷新。如果权限变更要求实时生效Claims里只放userId服务端拿着userId去Redis查权限。优点是实时缺点是每个请求多一次查询。折中方案是把粗粒度角色放Claims细粒度权限动态加载。比如Claims只放role:admin具体操作权限能不能删除某个订单在服务端基于sub role动态校验。4.3 自定义Claim的命名与类型约定私有Claim一定要有命名空间。JWT规范虽然允许任意键名但跨团队协作时容易撞名。例如{ sub: user_123, tenant: tenant_abc, reader: { id: r_1, version: 2 } }如果业务复杂建议使用带前缀的扁平结构或者嵌套对象。但嵌套对象也会增加解析复杂度尤其在不同编程语言之间互调时各种库对嵌套JSON的支持不一致。我个人更倾向于将必要的业务标识扁平化例如{ sub: user_123, app_id: ios_app, plan: pro, region: cn-east-1 }类型上也要统一约定。不要在某个版本里把exp写成字符串又在下个版本里写成数字。数字时间戳统一为秒级整数布尔值统一为true/false不要用1/0代替。4.4 Claims大小控制的经验阈值JWT通常放在HTTPAuthorizationHeader里。根据HTTP协议和代理服务器的限制Header总大小一般不建议超过8KB。如果单token就占到4KB以上再加上其他Header有被网关拒绝的风险。控制Claims大小时你可以做这几件事只保留业务真正需要的字段。不把大字段放进去例如头像URL通常很长不推荐放入Claims。用短键名。例如user_id和uid差异不大但对压缩率有影响。考虑用压缩算法。有些场景可以在签名前对Payload做压缩但会增加处理逻辑非必要不建议。5. 在jwt.io上完成编码、解码与校验的完整实操5.1 页面区域与基本操作jwt.io是一个纯前端的JWT调试工具输入token会实时解码显示JSON结构。左侧是编码区右侧是解码区。页面下方可以自定义Header和Payload。用jwt.io做调试的典型流程是在左侧Header区域填入算法和token类型。在Payload区域填入Claims JSON。在Verify Signature区域填入密钥HS系列算法需要RS系列需要公钥或者私钥。页面会实时生成签名左侧顶部出现完整token。复制token粘到右侧输入框验证解码结果和签名结果。5.2 实操示例HS256签发一段带自定义Claim的token假设我们要签发一个有效期为2小时的token包含用户ID、角色和租户ID。Header保持默认的HS256。Payload填写{ sub: user_12345, iss: gateway, aud: order-service, iat: 1735689600, exp: 1735696800, jti: a7f9c2e0-4f5a-4b8d-9b1a-2d3e4f5a6b7c, role: admin, tenant_id: tenant_abc }在Verify Signature处输入共享密钥例如my_shared_secret_key。页面左侧的Encoded部分会立即生成一段三段式token。把这个token复制到右侧的Decoded输入框jwt.io会自动解析三段内容并给出签名是否有效的提示。如果密钥匹配提示是签名的有效性否则显示无效。5.3 RS256的公私钥校验操作RS256需要生成一对RSA密钥。jwt.io页面上可以直接点击生成公私钥的按钮也可以自己在终端生成openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem操作时在Verify Signature区域选择RSA256然后把私钥内容粘贴到图中填写的位置。jwt.io会自动用私钥签名。过期后把token粘贴到右侧在公钥区域粘贴public.pem内容校验签名是否通过。注意RS256签名算法是对Header和Payload进行签名私钥用于签发公钥用于验签两者不能混淆。5.4 在jwt.io上校验常见的Invalid签名错误当你遇到Invalid Signature提示时优先排查这几项算法是否匹配。Header中的alg与签名时使用的算法是否一致。密钥是否正确。HS256使用同一个对称密钥RS256使用私钥签名、公钥验证。Header和Payload是否被修改过。任意一个字符变化签名都通不过。复制token时是否带着多余空白或引号。jwt.io是个调试工具不能拿它替代服务端校验。真实环境下签名验证必须放在服务端完成而且密钥绝对不能暴露给前端。6. 常见问题与排查技巧实录6.1 token过期但客户端仍在请求症状是接口突然返回401刷新页面后偶发恢复。排查步骤先看系统时间和服务器时间是否一致。解码token查看exp和iat。确认服务端校验使用的是秒级时间戳。检查签发时设置的有效期是否太短。如果是正常的临时token过期通常的处理是让客户端捕获401后用刷新token接口换取新的token。不要简单粗暴地延长exp那等于无限期会话安全风险太大。6.2 自定义Claims解析后类型不对比如签发时用的is_admin:true到Java解析后变成了布尔值但代码却按字符串比较导致判断失败。这类问题高发于跨语言场景。解决方案是统一约定类型并在解析后打日志确认。Debug阶段可以把解析后的Claims打印出来看实际类型。另外注意JSON数字类型。某些语言会把大于2^31的数字解析为Long如果前端按Int处理可能溢出。处理这类问题最靠谱的是字段类型定义表——每个Claim是什么类型、允许哪些值写清楚。6.3 签名验证失败但Payload能解码很多人看到jwt.io右侧能解码出内容就以为token没毛病其实解码成功和签名有效是两码事。要养成先看Signature Verified区域是否提示Invalid Signature的习惯。线上排查签名问题可以写一个独立的小脚本用同一个密钥对原始Header和Payload重新签名再与token的第三段比对。如果结果一致说明签名没问题问题在传输或密钥配置。6.4 算法混淆攻击algnone与密钥泄露风险这是JWT安全中最常见的攻击方式。攻击者把Header里的alg改成none然后删掉第三段签名部分校验逻辑不完善的系统会直接信任这个token等于用户可以任意伪造身份。防御方式非常明确服务端严格检查alg只允许白名单内的算法。对alg值做统一判断拒绝none和其他未知算法。密钥强度足够HS256对称密钥要达到256位以上。另外一个隐蔽问题是算法切换漏洞。如果你同时支持HS256和RS256攻击者可以把RS256的token改为HS256然后用公钥当私钥来签名。因为公钥是公开的攻击者拿到公钥后用HS256加同一个公钥字符串生成签名服务端如果用公钥来当HMAC密钥验签就通过了。这种攻击非常经典解决办法是固定算法白名单禁止在运行时根据Header动态选择。6.5 Claims过大导致请求被网关拦截症状是本地调试没问题部署到测试环境后部分接口直接返回414或超时。原因通常是网关或代理服务器对Header大小有限制。如果token超过2KB就要警惕。处理办法精简Claims。改用短键名。如果授权信息真的很大考虑换成不透明token由授权服务通过sub去后端换取权限数据避免每个请求都带大token。6.6 时钟偏差导致的nbf/exp边缘问题容错配置是双刃剑。容错窗口太大token生效和过期都会变模糊太小客户端服务器时间不一致就会误伤。一个合理的经验值是3060秒同时要求各服务器都启用NTP时间同步。7. 服务端校验Claims时的关键注意点7.1 不要只验签名不验Claims签名验证是第一步但远远不够。token签名有效只能说明Header和Payload没有被篡改不说明Claims本身是可接受的业务状态。一个已经过期但从签名角度完全合法的token必须被拒绝。校验顺序应该是检查签名。检查exp、nbf。检查iss是否可信。检查aud是否包含当前服务。检查关键业务Claim如sub、tenant_id。根据业务执行细粒度权限判断。7.2 引入Claims字典或白名单验证对于敏感字段比如角色、租户ID建议在服务端做白名单匹配。你不能因为Claims里有role:admin就直接给管理员权限最好再校验这个sub对应的用户是否真的具有该角色。如果用的是无状态JWT这一步可以通过引入固定的角色-权限映射表或调用权限服务完成。经验做法JWT只作为“身份载体”权限决策以服务端动态数据为准避免纯靠Claims里自报的角色做授权。7.3 日志中不要把完整token打出来排查问题时要打日志但完整的JWT一旦泄露等于会话被劫持。日志里最多打jti、sub、过期时间和校验结果不要打完整token。线上审计时通过jti就能关联到具体会话没必要把整个token打印出来。8. Payload设计过程中容易被忽略的细节8.1 Base64Url编码的字符集处理JWT使用的是Base64Url变体普通Base64里的、/、会被替换成-、_和不填。如果你的代码里手动做Base64转换再拼token一定要用base64url编码而不是标准Base64。很多库自带支持但手写拼接时最容易出问题。8.2 JSON序列化顺序影响签名吗不影响。签名是对Header和Payload的原始字节做运算只要解析后的JSON结构和编码后的字符串一致即可。但同一个JSON对象不同语言序列化时键的顺序可能不同导致生成的token字符串不同验签时如果直接拿原始串比较就会失败。正确做法是解析后重新编码时保持原样或直接用标准库的sign和verify不要自己拼。8.3 Claims中的数组和嵌套对象数组和嵌套对象在Claims里是允许的比如多租户场景{ sub: u123, tenants: [t1, t2, t3] }但要注意数组越大token越大校验逻辑越复杂。需要保持最小编码长度时可以用逗号分隔字符串作为替代方案。举个例子tenants改为t1,t2,t3体积差不多但解析复杂度更可控。当然任何设计都需要团队达成一致不要混用。8.4 续签与刷新token的Claims设计刷新token和访问token的Claims设计不同。访问token有效期短Claims里可以放业务数据便于无状态校验刷新token有效期长Claims里最好只放sub和jti并且必须开启jti黑名单机制否则一旦刷新token泄露攻击者可以长期续签。在刷新逻辑里务必检查刷新token的aud确保它只被授权服务使用。9. 最后的实操经验我用JWT做了好几个项目最大的一个教训就是不要把JWT当成一个可以无限塞数据的容器。它本质上是“签名声明”不是“会话存储”。Claims设计得越精简系统越稳定出了问题时越好排查。还有一个小技巧在做新项目时把Claims的字段定义和示例写在一个共享文档里团队里后端、前端、测试都按这个文档对接。别看这个动作不起眼它能避免大量因为字段类型不对、命名不一致产生的联调问题。我在项目里就是靠这份Claims字典把多服务鉴权的沟通成本压到了很低。如果你正在调试自己的JWT链路建议先从jwt.io把各类Claims的取值都试一遍特别是exp、nbf、aud这些容易被忽略的字段亲手试过才知道每个字段在库里面是怎么被解析的。等把标准字段和自定义字段的边界理清楚再写服务端校验就顺手多了。
返回列表