基于JWT/JWE的跨系统安全数据透传方案详解

发布时间:2026/10/9 10:57:43
基于JWT/JWE的跨系统安全数据透传方案详解
先说结论这套“基于JWT/JWE的跨系统安全数据透传方案”解决的是两个不同域、不同技术栈、甚至不同运维体系的服务之间如何安全地把一段结构化数据从A端交付到B端——既保证数据在“路上”不被看、不被改又保证接收方能够验证数据确实来自可信生产方而不是中间人伪造的。我经历过不少真实场景才觉得有必要把这个方案完整写下来。前几年做平台化改造多个业务系统要跟统一数据服务对接一开始大家图省事直接走内网HTTP加自定义加密结果每次对接都要重新捋一遍签名规则密钥管理更是各搞各的出了好几次数据被篡改的线上事故。后来统一收敛到JWT/JWE这套标准上整个链路才规范起来。这篇文章我把方案的设计思路、核心实现、常见坑点都摊开讲适合正在做跨系统对接、API网关透传或者需要把登录态与业务数据在服务间安全传递的团队参考。文章里用Node.js生态做代码演示jose库但方案本身是语言无关的Java、Go、Python都能照搬思路。1. 方案背景与核心思路拆解1.1 为什么是“透传”而不是“中转”跨系统数据交互通常有两种模式。中转模式是A先把数据交给中间平台中间平台完成解密、验签、重新封装后再发给B透传模式是A直接把封装好的数据包交给通道通道只负责搬运B收到后自行解封验证。中转模式的问题在于中间平台成了“信任锚点”。它必须要能读取甚至修改业务数据才能完成转发一旦中间平台被攻破或者内部人员违规操作所有跨系统数据都存在被泄露和篡改的风险信任边界被放得太大。而透传模式把信任边界收敛到A和B两端中间链路只做传输不接触明文业务数据安全性提升了一个量级。我强调一下“中间链路不做业务理解”这个理念。用透传方案时接入网关、消息队列、文件传输通道都成了“哑管道”。哑管道的好处显而易见它不需要为每种业务数据定制解析逻辑安全责任也不在它身上。实际项目中我甚至见过把JWE密文直接塞进HTTP header传的场景网关只做header白名单校验根本不需要知道里面是什么。这让整条链路在任何一端出问题时都容易定位因为通道本身不引入额外复杂度。1.2 整体链路拓扑生产端、通道与消费端的职责划分先给一张逻辑图文字版生产端系统A生成JWT负责身份与访问声明再把业务数据装入JWE负责完整加密两者一起发往接入通道消费端系统B先解密JWE拿到业务数据再验JWT签名确认生产端身份与权限最后把业务数据交给本地业务逻辑。A和B各自持有自己的密钥对通道只负责转发不参与任何加解密动作。这里有个关键选型你需要想清楚JWT和JWE不是二选一而是分工协作。JWT解决“谁发的、有没有权限发、是不是本人发的”这类身份与权限问题JWE解决“内容有没有被偷看、有没有被改动”这类数据机密性与完整性问题。一个管身份一个管数据两者合并使用才是完整的“安全数据透传”。在实际落地时你还要预留扩展能力。比如系统A可能同时对接多个下游B、C、D每个下游有独立的公钥和验签要求。遇到这种情况JWT里的aud声明就派上用场了它标注了这次透传的目标系统消费端验签时强制校验aud可以从机制上避免A签发的数据被非目标系统截获后误信。这种边界的细节往往是在真正出事之后才被重视的提前设计能省掉大量返工。2. 核心细节解析签名、加密与密钥管理2.1 JWT签名算法选型HS256、RS256还是ES256这是我见过踩坑最多的环节。HS256是对称签名签发和验签用同一个密钥通常是一串随机字符串实现简单速度快但它要求A和B共享同一个密钥只要任何一方泄露整个链路就完了。而且你很难审计“谁签的”因为双方都有能力签发。RS256是非对称方案A用私钥签发B用公钥验签私钥只留在A侧B无法伪造适合跨系统信任场景。ES256同样是非对称基于椭圆曲线性能比RSA好密钥更短JWT库的生态兼容性这些年也已经很完善。我的建议很直接跨系统透传优先选RS256或ES256除非你是同一进程内的两个模块在传数据否则不要用HS256。为什么因为HS256一旦被拿到密钥攻击者不仅可以验不了签甚至能反手签发新token。非对称方案里私钥只在生产端消费端拿到公钥也只能做验签攻击面小了一个量级。如果你选RS256密钥长度至少2048位想更极致的性能就直接上ES256的P-256曲线。至于algnone这种裸奔配置那是必踩的重雷。网上流传的jwt漏洞总结里排第一的就是“服务端信任客户端传入的alg字段”。攻击手法很简单把header里的alg改成none然后去掉签名服务端如果没做算法白名单校验直接按none处理等于没有任何签名保护。修复方式也非常简单验签前做死枚举只接受RS256/ES256其他算法一律拒绝。2.2 JWE信封加密流程与算法推荐JWE的加密体系可以理解成“信封加密”用内容加密密钥CEK加密真实业务数据再用密钥加密密钥KEK加密CEK最终把两层嵌套放进一个紧凑字符串里。这样做最大的好处是密钥分层管理。你可以频繁轮换CEK而不需要动KEK而且CEK每次加密都是随机生成的哪怕泄露一个历史CEK也只能解一条历史数据损失可控。JWE的紧凑序列化格式长这样header.encrypted_key.iv.ciphertext.tag五个部分分别对应受保护头、被KEK加密过的CEK、初始向量、密文、认证标签。实际开发中你不需要手拼这些字段jose库的EncryptJWT会全部处理好但你得知道每个部分的作用否则出问题时日志都看不懂。提示JWT的payload只是Base64url编码不是加密。很多初学者以为JWT天然是加密的把手机号、身份证、银行卡等信息直接塞进claims里等于明文传输。这一点在任何jwt漏洞总结里几乎都会提到但实际代码审查时依然频繁遇到。机密业务数据只应收录在JWE的明文部分这是本方案的核心红线。算法选型我推荐组合密钥加密用RSA-OAEP-256非对称A拿B的公钥加密CEKB用私钥解开内容加密用A256GCMAES-GCM同时提供机密性和完整性校验。如果A和B之间已经有安全信道可以交换预共享密钥更简单的做法是用dir模式——直接把CEK作为KEK密文头里不再单独放encrypted_key。dir模式少一层嵌套但牺牲了密钥独立轮换能力适合快速落地RSA-OAEP模式适合密钥需要分权管理、定期轮换的正式环境。2.3 SPA项目中的验证码校验设计SPA开发里“jwt验证码实现”这个热搜很常见。为什么SPA场景要专门谈验证码因为纯前端应用天然是公开代码攻击者可以直接扒JS、复制请求、写脚本刷接口如果跨系统透传的触发接口没有验证码或人机校验等同于给人肉撞库和批量打接口敞开了大门。我的落地做法是把验证码作为“入场券”集成进透传链路前端提交业务数据前先请求一个验证码图形验证码或行为验证码后端把验证码哈希和会话上下文绑定存到Redis设置60秒有效期前端把验证码字符串连同用户输入的JWT Claims提交给生产端API生产端校验验证码通过后才签发JWE密文给下游。验证码校验最重要的是“一次性”校验成功后立刻删除Redis里的记录防止同一个验证码被重复提交。我在生产环境见过最大的坑就是验证码校验通过后不失效结果脚本拿到一个有效验证码就能无限刷接口验证码彻底沦为摆设。围绕这一点随后在实操部分我会给出比较完整的实现思路。3. 实操过程从零搭建透传链路3.1 环境准备与依赖引入我用Node.js演示因为jose库对JWE的封装是目前各语言里最省心的。安装依赖就一行npm install joseNode.js内置的crypto也够用了不需要额外引唯一标识库。接下来是系统B消费端需要的配置样例可以通过环境变量或配置中心管理B_PUBLIC_KEY_RSA... # 系统A用该公钥做JWE密钥加密 B_PRIVATE_KEY_RSA... # 系统B持有用于解密JWE A_PUBLIC_KEY_RSA... # 系统A的公钥系统B用于验证JWT签名生产端A的配置反过来持有自己的私钥用来签JWT持有B的公钥用来加密JWE。密钥格式统一用PEM维护起来最通用。注意这里的关键点是“公钥和私钥绝对不能放在同一个服务里”一旦生产端拿到了B的私钥就等于同时拥有了加密和伪造的能力这个透明链路就名存实亡了。3.2 生产端JWT签发与JWE加密先写生产端核心代码。第一个函数负责给业务数据签名并封装为JWT Claimsimport { SignJWT } from jose; async function signTransmissionClaim({ dataId, fromSystem, scope }) { const privateKey await readPrivateKey(); // 从PEM文件读取A的私钥 return await new SignJWT({ dataId, fromSystem, scope }) .setProtectedHeader({ alg: RS256, typ: JWT }) .setIssuer(system-a) .setSubject(transmission) .setAudience(system-b) .setIssuedAt() .setExpirationTime(10m) .sign(privateKey); }这里的claims字段要按照最小化原则来设计。跨系统透传时JWT里不建议塞业务明细只放dataId、fromSystem、scope这类流转控制信息业务数据全部放JWE的明文部分。为什么因为JWT的payload可以被任何截获者解码查看它不是密文。把机密数据塞进JWT payload是jwt漏洞总结里反复出现的最高频问题没有之一。第二个函数负责把业务数据装进JWE密文import { EncryptJWT } from jose; async function encryptPayload(businessData, systemBPublicKey) { const payload { dataId: businessData.id, bizData: businessData, createdAt: new Date().toISOString() }; return await new EncryptJWT(payload) .setProtectedHeader({ alg: RSA-OAEP-256, enc: A256GCM, typ: JWE }) .setIssuedAt() .setExpirationTime(10m) .encrypt(systemBPublicKey); }注意这个函数入参是systemBPublicKey也就是系统B的公钥。生产端A用B的公钥去加密CEK只有B的私钥能解开。这套设计确保了数据在传输过程中即使被完整截获攻击者也看不到任何业务内容。系统B接到的JWE密文不依赖任何中间层的解密能力直接用自己的私钥解包这一点和透传模式的设计理念完全对齐。3.3 消费端JWE解密与JWT验签消费端的处理顺序要严格遵守先验JWT再解密JWE。如果JWT是塞在JWE里面的那就要先解密再验签但我这里按“并列两个字段”的请求设计来给代码JWT和JWE字段平级所以顺序是验签优先。因为JWT的声明信息里包含dataId、scope这类用于决策身份权限的数据只有先确认JWT是A签发的合法凭据才能信任这份数据。import { jwtVerify } from jose; import { jwtDecrypt } from jose; async function handler({ jwe, jwt }) { // 第一步验JWT签名 const { payload: claim } await jwtVerify(jwt, readPublicKeyA(), { issuer: system-a, audience: system-b, algorithms: [RS256] }); // 第二步解JWE const { payload } await jwtDecrypt(jwe, readPrivateKeyB(), { algorithms: [RSA-OAEP-256] }); // 第三步业务数据与声明信息核验 validateDataId(claim.dataId, payload.dataId); const bizResult handleBusiness(payload.bizData); return bizResult; }jwtVerify里我传了algorithms数组这就是算法白名单。很多人代码里没传导致库默认接受多种算法风险极大。另外issuer、audience、expiration这些标准声明jose默认都会校验但如果你用其他库大概率要自己调。我特别提醒一句验签时一定校验exp和nbf缺了这一步昨天的token今天还能用安全上是不可接受的。这里还有一个容易被忽略的细节dataId校验。JWT声明里的dataId和JWE明文里的dataId必须对得上这个校验能防“身份声明与业务数据分离”的替换攻击。攻击者如果拿到一个合法JWT和一个合法JWE交换两者的dataId组合就可能造成数据错乱。虽然生产端正常使用不会出现这种情况但消费端必须做防御性校验这是跨系统安全方案里非常重要但很多人会漏掉的一环。3.4 Token续签机制的落地实现热搜词里“jwt实现token续签”排得很靠前说明这个需求非常普适。跨系统透传场景里access token有效期通常很短比如10分钟但一次透传任务可能因为下游处理超时、重试、批量预生成等原因要超过这个时间所以必须有续签机制。常见方案有三种方案一是refresh token加access token双token方案。客户端保存access token和refresh tokenaccess token过期后用refresh token换新access token。refresh token必须是一次性的、只能换一次且必须绑定设备或会话标识轮换后旧refresh token立即作废。这个方案灵活但要求你实现一个存储来管理refresh token状态Redis或数据库复杂度略高。方案二是滑动过期方案。只要用户处于活跃期每次携带旧token请求时服务端签发一个全新token并重置滑动窗口实现无感续期用户体验最好。缺点是每个请求都多一次签发和验签开销而且如果客户端离线时间超过滑动窗口还是得重新登录。滑动过期适合交互频繁的SPA管理界面。方案三是透传场景特有做法为任务生成一个短期JWT作为任务凭证task token不追求延续登录态而是延续“任务态”。比如数据透传任务可能耗时15分钟那JWT有效期设20分钟任务完成即废弃。这个做法不需要额外存储refresh token简单粗暴代价是长任务必须预估准确预估偏短会导致任务中断。我实际落地时的组合是登录态用双token加refresh token轮换跨系统透传任务用task token加滑动窗口延长两套并行互不干扰。代码上给一个refresh token换发简版import { SignJWT, jwtVerify } from jose; async function refreshAccessToken(refreshToken) { const { payload } await jwtVerify(refreshToken, refreshKey, { algorithms: [HS256] }); const storedToken await redisClient.get(rt:${payload.jti}); if (storedToken ! refreshToken) { throw new Error(refresh token已失效需重新登录); } await redisClient.del(rt:${payload.jti}); // 一次性使用 const newAccessToken await new SignJWT({ sub: payload.sub }) .setProtectedHeader({ alg: RS256 }) .setIssuedAt() .setExpirationTime(10m) .sign(accessPrivateKey); const newJti crypto.randomUUID(); const newRefreshToken crypto.randomUUID(); await redisClient.set( rt:${newJti}, newRefreshToken, EX, 7 * 24 * 3600 ); return { newAccessToken, newRefreshToken, newJti }; }这段代码里有三个容易被忽略的点refresh token必须轮换轮换后旧的立即删除refresh token配套的jti也要一并更新防止Redis里存的是旧映射。如果不做一次性消费验证攻击者拿到一个refresh token就能无限换发新access token整个登录态等于失守。3.5 SPA验证码集成到透传链路前面原理讲了这里补实操。SPA端先向“验证码服务”申请一个captchaId后端生成验证码图片并把captchaId对应的答案哈希存在Redis60秒过期。前端填写验证码后把captchaId和用户输入与业务请求一起提交。生产端API在处理透传前校验验证码async function verifyCaptcha(captchaId, answer) { const key captcha:${captchaId}; const hash await redisClient.get(key); if (!hash) return false; // 过期或不存在 if (await bcrypt.compare(answer.toLowerCase(), hash)) { await redisClient.del(key); // 一次性消费立即删除 return true; } return false; }这里答案用bcrypt哈希存储不是明文。即便Redis被dump攻击者也拿不到原始验证码。校验完必须删除记录否则就是一个可以反复使用的“万能钥匙”。很多团队在这里偷懒觉得验证码过期就行但真实攻击者根本不会等到过期拿到一个能用的验证码就会立刻批量打接口。删除操作是防重放攻击的关键千万别省。注意验证码本身一定要绑定会话上下文。只校验“验证码对不对”是不够的还要绑定IP、设备指纹或会话ID。否则攻击者可以自己注册一个正常账号拿到验证码后挂到脚本里复用批量刷透传接口可能引发数据越权或资源滥用。4. 常见漏洞与排查技巧实录4.1 典型JWT/JWE漏洞场景与修复方案我在实际审计和应急响应里处理过不少JWT相关的漏洞下面列几个最典型的你可以对照自查。第一个是前面提过的algnone。攻击者把header改成{alg:none}删掉签名服务端如果没做算法白名单校验就会放行。修复验签逻辑里强制algorithms: [RS256]并且显式拒绝algnone。第二个是HS256混淆攻击。攻击者拿到服务端的RSA公钥公钥本身就是要公开的把算法改成HS256然后用这个公钥作为HMAC密钥重新签名。如果服务端同时允许HS256和RS256并且验签时直接用公钥字符串做HMAC key攻击者的token就能骗过验签。修复算法白名单只留RS256或者严格区分非对称公钥和HMAC密钥永远不要用RSA公钥当HMAC密钥。第三个是敏感信息泄露。JWT的payload只是Base64url编码不是加密。把身份证号、手机号、业务机密放进payload等于明文裸奔。修复机密字段只放JWE部分JWT只放dataId、scope这类流转控制字段并设置短有效期。第四个是密钥硬编码在前端代码里。SPA项目里出现过把JWT签发私钥直接写进前端公共JS的情况这相当于把家门钥匙插在门外鞋垫下面。修复私钥只存在服务端给前端的不应该是私钥而只是access token本身和验证码交互接口。第五个是篡改kid参数。JWT头里的kidkey id用于服务端选择密钥如果服务端用kid去拼文件路径且没做过滤可能被注入恶意路径。这个漏洞在不少jwt漏洞总结里都出现过。修复把kid映射到白名单里的固定密钥不要直接拿kid拼路径密钥存储推荐走KMS或者配置中心。还有一个我处理过的比较隐蔽的场景JWE密文被重放。攻击者不去破解加密而是把之前截获的合法JWE包原封不动地重新提交如果消费端没有做幂等校验同一个业务请求会被执行两次。修复JWE的payload里加requestId和createdAt消费端对requestId做去重Redis存一份“已处理requestId”的集合保留窗口设1小时隔重放。4.2 密钥轮换与多环境管理跨系统透传方案里密钥管理才是真正的持久战。我建议至少做到以下三点。密钥不能进仓库。开发环境可以用.env或本地PEM文件但测试和生产环境一定要走密钥管理服务或配置中心至少做到git仓库里不出现私钥文件名。密钥一旦泄露不只是未来数据不安全之前所有存量数据也可能被解密影响范围是历史性的。密钥要有版本。JWT和JWE标准头里都有kid字段生产端签发时带上kid标识当前用的哪一版密钥消费端验签或解密时用kid查到对应的公钥或私钥。这样轮换时旧密钥还能继续解开存量数据不会造成“一换密钥全链路闪断”的尴尬。轮换策略上建议先发新公钥让生产端开始签发新版本token同时保留旧公钥验签24小时等存量token全部过期后再移除旧公钥。多环境管理上我吃过一个亏测试环境的公钥和生产环境的公钥搞混导致生产透传数据在测试环境“碰巧”解开了排查了两天才定位到是配置中心的key路径写错。后来我在kid里加环境前缀比如test-20241001、prod-20241001从机制上杜绝跨环境密钥混用。这个习惯看起来简单但在多环境并行、多人协作的项目里极其好用。4.3 排查思路与常见问题速查表我整理一个自己在排查JWT/JWE问题时用的速查表遇到问题先对着看现象常见原因排查方向解密JWE时报decryption failed公私钥不匹配、CEK被篡改核对PEM密钥对、确认header里的alg和enc与库默认一致验签报signature verification failed私钥签发的token被错误公钥验签检查kid选中的密钥版本别混测试环境和生产环境token过期后仍可访问exp校验被关闭检查jwtVerify参数里是否显式校验exp验证码永远报过期删除时机不对确认一次性消费逻辑删除必须在校验成功后立即执行接口被刷但验证码无感知验证码未绑定会话上下文验证码需要绑定IP或设备指纹或会话ID否则可复用性极强JWE密文长度异常短CEK处理环节出问题或用了dir模式核对加密算法dir模式没有独立encrypted_key段还有个实际经验排查JWE问题最有效的手段不是先看代码而是先看密文长度。JWE密文长度是header长度加CEK密文长度加iv长度加ciphertext长度加tag长度的总和。如果密文长度异常短大概率是CEK处理环节出了问题或者用了dir模式导致encrypted_key段为空。这个经验在我最近三次线上排查里都一枪命中比抓日志有用得多。排查阶段我还会用一个小技巧在本地把同一份业务数据用公钥加密两次如果两次得到的密文完全一样说明你的加密实现可能没有随机填充。AES-GCM模式下iv每次都应该是新生成的两次加密结果必然不同。如果相同要么随机数源有问题要么是你把固定iv写进了配置这比大多数签名漏洞更隐蔽也更危险。4.4 日志链路给透传数据加一个“航标”最后分享一个我自己压箱底的小技巧给JWT和JWE都加一个自定义header比如x-lane: system-a-to-b网关层拿到这个header后作为链路标识打印日志。一旦某个透传任务出问题用这个x-lane全局搜索日志可以一秒还原整条链路所有环节的先后顺序。这个header不会落在token payload里所以不涉及敏感信息也永远不会被access token的过期时间卡住。它只是链路追踪的一个锚点但在跨系统排障时价值极大。我有一次排查生产事故系统A说自己发出去了系统B说自己没收到两边各执一词。后来用x-lane一搜发现通道层某个节点在转发时丢了header导致系统B直接按“未带链路标识”的请求拒绝了。如果没有这个航标这种问题可能要掰扯半天才能定位到通道层。我在多个项目里落地这个方案后最大的体会是安全方案的价值不在标准选得多高级而在于能不能被长期稳定执行。JWT和JWE的规范都是公开的库的封装也越来越成熟但真正决定跨系统数据安不安全的往往是算法白名单写没写、密钥轮换管没管、验证码删没删、日志标识有没有。这些都是细节但恰恰是这些细节决定了你的数据透传链路是“纸面安全”还是“真实安全”。