☰
基于JWT/JWE的跨系统安全数据透传方案详解
2026/10/9 10:57:38 网站建设 项目流程

先说结论:这套“基于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曲线。

至于alg=none这种裸奔配置,那是必踩的重雷。网上流传的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的公钥加密CEK,B用私钥解开),内容加密用A256GCM(AES-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 jose

Node.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 Claims:

import { 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 token,access 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对应的答案哈希存在Redis,60秒过期。前端填写验证码后,把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相关的漏洞,下面列几个最典型的,你可以对照自查。

第一个是前面提过的alg=none。攻击者把header改成{"alg":"none"},删掉签名,服务端如果没做算法白名单校验就会放行。修复:验签逻辑里强制algorithms: ['RS256'],并且显式拒绝alg=none。

第二个是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头里的kid(key 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的规范都是公开的,库的封装也越来越成熟,但真正决定跨系统数据安不安全的,往往是算法白名单写没写、密钥轮换管没管、验证码删没删、日志标识有没有。这些都是细节,但恰恰是这些细节,决定了你的数据透传链路是“纸面安全”还是“真实安全”。

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

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

立即咨询