JWT看着像一串乱七八糟的字符,但拆开来看其实特别规整。它由三小节组成,中间用点隔开,像“xxx.yyy.zzz”这样。很多朋友天天用JWT,但从来没仔细看过最前面那一截“xxx”到底代表什么。今天咱们就专门把这块翻出来聊透,看看里面藏着哪些关键参数,以及它们是怎么变成漏洞入口的。

一、先看看JWT长什么样

1.1 以点分割的三段式

一个标准的JWT通常长这个样子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.tjW9zT_7nU9jHZtTboMIjFxD_eCWOe0BdCQK7BGmB1w

你可以把它理解成三截:第一截是Header,第二截是Payload,第三截是签名。每一截都是经过Base64编码的JSON字符串。点号的作用就是告诉程序“该分开了”。平时我们登录后把整个token放在请求的Authorization头里,后端拿到手会先按点拆开,再分别处理。

1.2 为什么Header放在最前面

Header就像一包方便面外面的配料表,它不负责提供营养,但告诉你这包面是什么口味、该用什么温度的水泡。同样,JWT的Header声明了本次令牌使用的签名算法和类型,让验签方知道“该用什么姿势去验证”。放在最前面,就是为了让解析器第一时间看到元数据,不用翻到后面去猜。

二、Header里有哪些关键参数

2.1 alg:算法那个“大当家”

alg是Header里最重要的参数,它的全称是“Algorithm”,也就是签名算法。常见的值有HS256(对称密钥)、RS256(非对称密钥)、ES256(椭圆曲线)等等。有时候还会看到none,这表示不签名。你可以简单理解成:

alg=HS256      // 我和对方用同一个密码,验证时用这密码算一遍
alg=RS256      // 我用私钥签名,大家用公钥验签
alg=none       // 我不签名了,你爱信不信

这里有个非常关键的认知:alg是放在Header里的,而Header是前端用户可以改的。如果后端不校验alg的值,光看着Header里写了什么就去用对应算法验签,那等于把验签规则的主导权交给了对方。

2.2 typ和cty:类型与内容类型

typ一般固定是JWT,表示这是一个JWT格式的令牌。它有点像你发邮件时填的“主题”,只是给解析器一个提示,实际用途不大。但也有极少数库会读取typ来决定解析方式,所以也不能乱写。

cty是“Content Type”的缩写,在JWT里不常用,但如果出现了,它可能表示Payload里的内容格式,例如application/json。这玩意儿在普通API认证中几乎用不到,但在特殊的嵌套JWT场景里才有意义。

2.3 kid:密钥的“门牌号”

kid是“Key ID”的缩写,它的作用是告诉服务端“该用哪把钥匙来验签”。就像你进酒店房间,门牌号告诉你该用哪把钥匙开门。但是,如果服务端把kid当作查询参数去拼SQL或拼文件路径,那就会变成注入漏洞。后面咱们在安全隐患里细说。

三、Node.js实战:解析Header信息

3.1 生成一个带Header的JWT

我们准备用Node.js里的jsonwebtoken库来做实验。先用它顺手造一个令牌,然后在控制台里把它的Header打印出来。

// 技术栈:Node.js + jsonwebtoken
const jwt = require('jsonwebtoken');

// 定义一个简单的密钥,实际生产环境可别用这么短的
const SECRET = 'my-super-secret-key';

// 要放到令牌里的用户信息
const payload = {
  userId: 123,
  role: 'admin'
};

// 生成JWT,指定用HS256算法,并且设置Header中的typ为JWT
const token = jwt.sign(payload, SECRET, {
  algorithm: 'HS256',
  header: { typ: 'JWT' }
});

console.log('生成的JWT完整字符串:');
console.log(token);

运行上面的代码,你会看到一个三段的字符串输出。拿第一段去Base64解码,就能看到Header的原始JSON内容。

3.2 手动解码Header

接下来我们不需要依赖库,自己动手把Header部分解码出来,这样你能更清楚地看到它是怎么被编码的。

// 技术栈:Node.js(不需要外部库,纯手写解码逻辑)
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.tjW9zT_7nU9jHZtTboMIjFxD_eCWOe0BdCQK7BGmB1w';

// 按点号切割token
const parts = token.split('.');
const headerBase64 = parts[0]; // 拿到第一段
console.log('Header的Base64Url:', headerBase64);

// Node.js的Buffer支持base64url直接解码
const headerBuffer = Buffer.from(headerBase64, 'base64url');
const headerJson = headerBuffer.toString('utf-8');
console.log('解码后的Header JSON:', headerJson);

// 执行结果应输出:{"alg":"HS256","typ":"JWT"}

这样你就亲眼看到了Header的内容:alg是HS256,typ是JWT。手动解码能帮助你理解为什么我们说“Header是可篡改的”——因为它只是Base64编码,不是加密,任何人都能解码甚至改内容再重新编码。

3.3 用jwt.decode快速查看

如果你不想手写解码,jsonwebtoken库自带的decode方法也能直接看到Header和Payload,而且不会校验签名,很安全。

// 技术栈:Node.js + jsonwebtoken
const jwt = require('jsonwebtoken');

// 随便一个JWT字符串
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.tjW9zT_7nU9jHZtTboMIjFxD_eCWOe0BdCQK7BGmB1w';

// 传入complete:true,返回的对象会同时包含header和payload
const decoded = jwt.decode(token, { complete: true });

console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);
console.log('签名原文:', decoded.signature);

decode方法纯粹是把JWT拆开给你看,不做任何安全校验,所以用于调试和排查非常方便。但也要注意,不能把它当“验证”来用。

四、安全隐患大排查

4.1 alg:none,零签名大盗

这是最经典的JWT攻击。如果服务端在验证签名时,没有对alg参数做白名单限制,攻击者可以把Header里的alg改成none,然后去掉签名部分,就能伪造任意token。

比如攻击者构造一个假的token:

// 技术栈:Node.js
const jwt = require('jsonwebtoken');

// 伪造的payload,想当管理员
const forgedPayload = { userId: 1, role: 'admin' };

// 使用alg:'none'生成token(注意jsonwebtoken库默认禁止none,这里为了演示所以临时设置)
try {
  const fakeToken = jwt.sign(forgedPayload, '', {
    algorithm: 'none',
    header: { typ: 'JWT' }
  });
  console.log('伪造的token:', fakeToken);
} catch (err) {
  console.log('库禁止了none,但很多旧库或自研代码不会:', err.message);
}

如果后端用的是一个更宽松的库,或者自己写了签名校验逻辑,只检查了这个token有没有签名,但没有检查签名算法是不是none,那这个伪造token就能直接通过验证。解决办法很简单:服务端在验证的时候,必须强制声明允许的算法列表,比如只允许HS256或者RS256,遇到none直接拒绝。

// 技术栈:Node.js + jsonwebtoken
// 安全验证示例:明确限定只接受HS256
const jwt = require('jsonwebtoken');

const SECRET = 'my-super-secret-key';
const forgedToken = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9.';

try {
  // 当algorithms里只写HS256时,alg为none的token会直接报错
  const decoded = jwt.verify(forgedToken, SECRET, { algorithms: ['HS256'] });
  console.log('验证成功,这个令牌有效', decoded);
} catch (err) {
  console.log('验证失败,幸好我们限制了算法:', err.message);
}

4.2 算法混淆攻击(RS256/HS256)

这个坑稍微隐蔽一点。假设你的服务端本打算使用RSA非对称签名(RS256),验证时会用公钥。但攻击者把Header里的alg改成HS256,然后用服务端的公钥作为HMAC的密钥来签名。如果服务端验证时没有限定算法,就会用同一个“密钥”去跑HS256,结果误以为这个token是合法的。

可以这样复现:

// 技术栈:Node.js + jsonwebtoken + crypto
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const fs = require('fs');

// 模拟服务端持有的RSA公钥
// 注意:公钥文件的内容是公开的,攻击者也能拿到
const publicKey = fs.readFileSync('./public.pem', 'utf8');

// 攻击者要伪造的payload
const forgedPayload = { userId: 1, role: 'admin' };

// 关键:把签名算法改成HS256,并且签名密钥用公钥内容
const forgedToken = jwt.sign(forgedPayload, publicKey, {
  algorithm: 'HS256',
  header: { alg: 'HS256', typ: 'JWT' }
});

console.log('使用公钥作为HMAC密钥生成的伪造token:', forgedToken);

// 服务端如果只用公钥验证,而不限制算法,就会中招
try {
  // 这里没有指定algorithms,默认会尝试根据header来选算法
  const decoded = jwt.verify(forgedToken, publicKey);
  console.log('糟糕!验证通过了:', decoded);
} catch (err) {
  console.log('验证失败:', err.message);
}

要防止这个漏洞,服务端在配置jsonwebtoken时一定要传入algorithms数组,明确指定允许的算法。

// 技术栈:Node.js + jsonwebtoken
// 安全加固:固定算法
const jwt = require('jsonwebtoken');

try {
  // algorithms只写RS256,任何HS256的token都会报错
  const decoded = jwt.verify(forgedToken, publicKey, { algorithms: ['RS256'] });
  console.log('验证通过', decoded);
} catch (err) {
  console.log('正确拦截了算法混淆攻击:', err.message);
}

4.3 kid参数注入风险

kid这个参数虽然不像alg那么致命,但很危险。因为它经常被服务端当作某个密钥文件的索引。比如:

kid = "server1"   -> 对应 public_key_server1.pem
kid = "server2"   -> 对应 public_key_server2.pem

如果服务端把kid拼进SQL查询或文件路径里,攻击者就可以通过kid做SQL注入或路径穿越。例如:

"kid": "../../etc/passwd"

或者:

"kid": "x' UNION SELECT 'xx'"

这些攻击的核心原因是:服务端过于信任Header里的kid,没有把它当成“不可信输入”来处理。因此,务必对kid做白名单校验,或者限制只能匹配已知集合。

4.4 Header里的敏感信息泄漏

有人会把一些无关信息塞进Header里,比如自定义参数,但这样做很容易造成信息泄漏。尤其是有时候开发图省事,把密钥版本、内部环境名、甚至明文密码放在Header里。这种操作等于把秘密写在明信片上寄出去。记住,Header只是Base64编码,任何人都可以解码。不要在JWT Header中存放任何敏感信息,哪怕它不会发送到客户端。

五、怎么避坑:最佳实践与排查清单

5.1 服务端必须设置算法白名单

这是所有防护里最重要的一条。无论你是用jsonwebtokenjjwt,还是别的库,验签时都要显式指定allowedAlgorithms。这样即使有人篡改Header中的alg,你的验签函数也会直接拒绝。

5.2 密钥管理要上强度

不要使用弱密钥,比如secret123456这种。同时,非对称密钥的私钥要放在安全的配置中心或KMS里。对称密钥的密钥长度也要够长,否则容易被暴力破解。很多攻击工具会用常见弱密钥表去爆破HS256的签名。

5.3 不要信任Header里的任何“可读”内容

Header里的kidalgtyp都是输入数据,不是安全配置。它们只能提示,不能决定安全策略。安全策略必须由服务端硬编码。比如你想用哪个密钥,应该根据业务域名或用户来源去查,而不是直接拿Header里的kid去找文件。

5.4 排查问题时的检查表

如果你正在排查为什么JWT验证出了问题,可以先按这个表自检:

1. 是否在验签时指定了algorithms?
2. 是否允许alg:none?
3. 是否用公钥内容当作HS256的密钥?
4. 是否把kid参数用于拼SQL/文件路径?
5. Header里是否出现了非JWT标准的自定义参数?
6. 密钥是否强度足够?

只要有一项答案是“是”,系统大概率存在风险。

六、应用场景与优缺点

6.1 适合用JWT的场景

JWT非常适合处理无状态API认证。比如客户端登录后拿到一个token,之后每个请求都带上它,服务端不需要保存会话信息,直接验签就行。这对分布式系统、微服务架构特别友好,因为任何服务节点只要拿到同样的密钥或公钥,都能独立完成验证。

6.2 JWT的优缺点

优点也很明显:结构简单、自包含、移动端和Web端通用、便于横向扩展。但缺点同样需要重视:Header和Payload只是Base64编码,没有加密,敏感信息不能放进去;token一旦签发,在到期前无法轻易作废,除非额外加黑名单;如果密钥泄漏,所有token都面临被伪造的风险。

七、总结

JWT的Header虽然只是一段简单的元数据,但里面的algkid等参数,直接决定了签名验证的流程是否安全。很多严重的JWT漏洞,根本不是因为加密算法被破解,而是因为服务端盲目相信了Header里写的“提示”。所以,我们要把Header当成“用户输入”来看待,而不是“系统配置”。只要对算法做白名单、对kid做校验、对密钥做强化,JWT依然是一个快速有效的身份认证方案。