☰
JWT Claims详解:Payload设计、标准字段与自定义规则
2026/10/10 0:54:34 网站建设 项目流程

行内做后端接口鉴权,最常用的就是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_123;"role":"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即可,但要注意长度。如果每个请求都生成新token,jti带来的存储和查询压力不可忽略。

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做调试的典型流程是:

  1. 在左侧Header区域填入算法和token类型。
  2. 在Payload区域填入Claims JSON。
  3. 在Verify Signature区域填入密钥(HS系列算法需要,RS系列需要公钥或者私钥)。
  4. 页面会实时生成签名,左侧顶部出现完整token。
  5. 复制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,刷新页面后偶发恢复。排查步骤:

  1. 先看系统时间和服务器时间是否一致。
  2. 解码token,查看exp和iat。
  3. 确认服务端校验使用的是秒级时间戳。
  4. 检查签发时设置的有效期是否太短。

如果是正常的临时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 算法混淆攻击:alg=none与密钥泄露风险

这是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生效和过期都会变模糊;太小,客户端服务器时间不一致就会误伤。一个合理的经验值是30~60秒,同时要求各服务器都启用NTP时间同步。

7. 服务端校验Claims时的关键注意点

7.1 不要只验签名不验Claims

签名验证是第一步,但远远不够。token签名有效,只能说明Header和Payload没有被篡改,不说明Claims本身是可接受的业务状态。一个已经过期但从签名角度完全合法的token,必须被拒绝。校验顺序应该是:

  1. 检查签名。
  2. 检查exp、nbf。
  3. 检查iss是否可信。
  4. 检查aud是否包含当前服务。
  5. 检查关键业务Claim(如sub、tenant_id)。
  6. 根据业务执行细粒度权限判断。

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这些容易被忽略的字段,亲手试过才知道每个字段在库里面是怎么被解析的。等把标准字段和自定义字段的边界理清楚,再写服务端校验就顺手多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询