- 为什么后端说签名不对?
HS256、RS256、ES256、PS256到底怎么切?- 公钥私钥是
PEM还是JWK,到底该贴哪种? - 改了 payload 之后,怎么重新生成一个能用的 JWT?
所以这篇不只讲 JWT 原理,我会直接结合这个实际的页面实现来拆:
- 页面实际能做到什么;
- 一次完整的 JWT 排查流程怎么走;
- 前端页面是怎么实现解码、验签、生成的;
- 哪些代码你可以直接 copy 过去自己用。
这页工具不是“只能 decode 一下”的简单版,而是一个JWT 工作台:
- 支持
decode / encode双模式; - 支持
HS256 / HS384 / HS512 / RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / PS256 / PS384 / PS512; - 支持
PEM / JWK两种密钥格式; - 支持自动生成
RSA / ECDSA / RSA-PSS密钥对; - 支持实时验签、过期判断、Claims 语义化展示;
- 基于浏览器原生能力完成解码、签名和验签,不需要额外起一个后端中转服务。
一、先看效果:这个 JWT 工具到底能做什么
1. 粘贴一个 token,立刻拆成三段
JWT 本质上就是三段Base64URL字符串:
header.payload.signature例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 └────────── header ──────────┘└────────── payload ─────────┘└──────── signature ────────┘页面会马上把它拆开,并分别展示:
- Header
- Payload
- Signature
而且Header、Payload会自动做Base64URL 解码和JSON 格式化。
2. 自动识别算法,并显示 token 是否过期
页面会从 Header 中读出alg,比如:
{"alg":"HS256","typ":"JWT"}然后:
- 识别当前 token 使用的是哪种算法;
- 读取
exp / iat / nbf这些标准 Claims; - 自动把 Unix 时间戳转换成可读时间;
- 如果
exp已经过期,直接高亮提示。
这一步对接口联调很有用。很多时候你以为是签名错了,结果只是token 过期了。
3. 直接输入密钥,实时验证签名
这页真正有价值的地方,不是“能看 payload”,而是能验签。
不同算法对应不同输入:
HS256 / HS384 / HS512:输入shared secretRS256 / RS384 / RS512 / PS256 / PS384 / PS512:输入公钥ES256 / ES384 / ES512:输入公钥
而且密钥格式既支持:
PEMJWK
输入正确后,页面会实时显示:
- 签名验证通过
- 签名验证失败
- 密钥格式错误
4. 不只是解码,还能直接生成新的 JWT
切到encode模式后,你可以直接:
- 改 Header JSON
- 改 Payload JSON
- 选择算法
- 填 Secret 或 Private Key
- 实时生成新的 token
这对于下面这些场景非常实用:
- 本地模拟登录态
- 测试网关 / API 鉴权
- 复现线上 bug
- 验证第三方系统发来的 JWT 格式
5. 基于浏览器原生能力直接完成计算
这页实现上比较实用的一点,是直接使用浏览器原生Web Crypto API做签名和验签,而不是再额外走一层后端服务。
- 前端页面自己就能把解码、验签、生成这条链路跑通;
- 不需要为了一个调试页再补一层服务端接口;
- 算法切换、密钥格式切换都能实时反馈结果。
二、一个真实排障流程:为什么这个 JWT 验签失败
如果你是后端、前端、测试,最常见的问题通常不是“JWT 是什么”,而是:
我手里明明有 token,为什么服务端还是说 unauthorized?
这时可以用这页工具按下面顺序排。
第一步:先确认 JWT 格式是不是完整
一个合法 JWT 必须有三段:
header.payload.signature如果只有两段,或者多了空格、换行、前后缀,页面会直接提示格式错误。这一步能先排除很多复制粘贴问题。
第二步:看 Header 里的算法是不是你以为的那个
很多问题不是“签名算错了”,而是算法根本不一致。
比如你以为后端在用:
{"alg":"RS256","typ":"JWT"}结果 token 实际上是:
{"alg":"PS256","typ":"JWT"}RS256和PS256看起来只差两个字母,但它们不是同一个签名方案,直接混用一定验不过。
第三步:确认你喂进去的是对的密钥
这里是排障最常见的坑:
HS256需要的是secret,不是公钥RS256 / ES256 / PS256验签时需要的是public key,不是private key- 某些系统给你的是
JWK,不是PEM - 某些 HMAC secret 实际是base64 编码后的字节,不是普通字符串
这页工具里专门做了两件事来降低误用:
- 提供
PEM / JWK切换; - HMAC 提供
base64 encoded开关。
这两个开关很小,但对真实联调非常关键。
第四步:看是不是过期,不要误判成签名错误
很多同学第一反应是“验签失败”,但实际上问题可能是:
exp已过期nbf还没到生效时间- 服务端时钟和客户端时钟有偏差
页面会把exp转成可读时间,并高亮“已过期 / 未过期”,这个细节能少走很多弯路。
第五步:如果 payload 改过,必须重新签名
JWT 默认不是加密,而是签名。
这意味着:
- Header、Payload都能被任何人读出来;
- 但只要你改了其中任意一个字节,原来的Signature就失效。
所以正确流程不是“我改完 payload 再拿原 token 去请求”,而是:
- 修改 Header / Payload
- 用正确算法和密钥重新签名
- 拿新生成的 token 去请求
这也是为什么页面里一定要同时提供decode和encode两个模式。只做解码,排障链路是不完整的。
三、JWT 到底是什么:用「防伪火车票」讲明白
现在再回来看 JWT 原理,就会更容易理解。
很多人第一次接触 JWT,会误以为它是一段“加密后的字符串”。其实不准确。
JWT 更像一张带防伪码的车票:
| JWT | 火车票 |
|---|---|
| header | 票种说明 |
| payload | 乘客、车次、座位、发车时间 |
| signature | 钢印 / 防伪码 |
JWT 不是为了“防偷看”,而是为了“防伪造”。
也就是说:
- Header 和 Payload 通常都能被解码看到;
- Signature 的作用是证明前两段没有被篡改。
服务端收到 token 后,会把:
header.payload重新按约定算法签一遍,再去和 token 里自带的signature比较:
- 一样:说明内容没被改过
- 不一样:说明 token 被伪造或密钥不匹配
一句话总结:
JWT 默认是“可读取但不可篡改”,不是“谁都看不懂”。
这也是为什么 payload 里绝对不能放密码、身份证号、银行卡号这类敏感明文。
四、页面实现思路:前端怎么把 JWT 工作台做完整
下面这部分才是和页面最贴合的内容。不是泛泛聊 JWT,而是拆我们这个工具页为什么能工作。
整体流程图
用户输入 Token / 编辑 Header Payload
拆分三段
Base64URL 解码 Header 和 Payload
JSON 格式化展示
读取 header.alg
选择 HMAC / RSA / ECDSA / RSA-PSS
导入 Secret / PEM / JWK
Web Crypto 签名或验签
解析 exp iat nbf 等 Claims
显示验签结果
显示过期时间与状态
前端直接完成计算
对应到页面能力,大致可以拆成 5 层:
- Token 解析层
- Base64URL 编解码层
- 算法与密钥导入层
- 签名 / 验签层
- UI 展示层
1. Token 解析层
核心逻辑其实很直接:先按.拆分,再分别解码 Header、Payload。
function parseJwt(token) { const parts = token.trim().split('.') if (parts.length !== 3) throw new Error('JWT 格式错误') const header = JSON.parse(b64UrlDecode(parts[0])) const payload = JSON.parse(b64UrlDecode(parts[1])) return { header: parts[0], payload: parts[1], signature: parts[2], headerObj: header, payloadObj: payload, } }这个阶段只做三件事:
- 格式校验
- Base64URL 解码
- JSON 解析
如果这里报错,后面所有流程都不用继续。
2. Base64URL 编解码层
JWT 用的不是普通 Base64,而是 Base64URL:
+换成-/换成_- 去掉结尾
=
浏览器里可以这样处理:
function b64UrlDecode(str) { let s = str.replace(/-/g, '+').replace(/_/g, '/') while (s.length % 4) s += '=' return decodeURIComponent(escape(atob(s))) } function b64UrlEncodeStr(str) { return btoa(unescape(encodeURIComponent(str))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') }别小看这层。JWT 联调里非常多“明明看起来一样但验签失败”的问题,本质就是base64和base64url混了。
3. 算法与密钥导入层
页面真正麻烦的地方,不是 decode,而是要统一处理不同算法族:
- HMAC
- RSA
- ECDSA
- RSA-PSS
而且每种算法需要的密钥格式又不一样。
页面里比较实用的做法是先把算法抽象成配置对象:
const ALGORITHMS = [ { value: 'HS256', type: 'hmac', hash: 'SHA-256' }, { value: 'RS256', type: 'rsa', hash: 'SHA-256' }, { value: 'ES256', type: 'ecdsa', hash: 'SHA-256', namedCurve: 'P-256' }, { value: 'PS256', type: 'rsapss', hash: 'SHA-256' }, ]这样后面导入密钥、签名、验签时,就不需要写一堆零散if else。
对于非对称算法,还要同时兼容:
PEMJWK
这一步很关键,因为很多OAuth / OIDC / 网关系统给出来的就是JWK。
4. 签名与验签层
浏览器端我们直接用原生crypto.subtle。
HMAC 的签名逻辑大致这样:
async function signHmac(data, secret) { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)) return b64UrlEncode(sig) }RSA / ECDSA / RSA-PSS 则先导入公私钥,再调用不同参数的sign或verify。
这里有两个实现细节很值得写进文章:
decode模式下,非对称算法要用public key验签encode模式下,非对称算法要用private key签名
很多实现做不全,就是卡在这里,最后只能“看”不能“用”。
5. Claims 展示层
如果只是把 payload 原样打印出来,其实还不够好用。
更实用的做法是做一层“语义增强”:
exp标成“过期时间”iat标成“签发时间”nbf标成“生效时间”- Unix 时间戳自动转人类可读时间
- 过期状态直接高亮
这部分不复杂,但对排障体验很有帮助。用户不用自己再把时间戳复制出去换算,也更容易第一眼看出问题到底出在签名、时间还是Claims。
五、可以直接复制的核心代码
如果你想把这个能力放进自己的项目里,下面几段就够你起步。
1. 50 行 Node.js:手写 HS256 签发 + 验签
// jwt-demo.js const crypto = require('node:crypto') function b64url(input) { return Buffer.from(input) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } function hmacSign(data, secret) { return b64url(crypto.createHmac('sha256', secret).update(data).digest()) } function sign(payload, secret) { const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const body = b64url(JSON.stringify(payload)) const signature = hmacSign(`${header}.${body}`, secret) return `${header}.${body}.${signature}` } function verify(token, secret) { const [h, p, s] = token.split('.') const expected = hmacSign(`${h}.${p}`, secret) const a = Buffer.from(s) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b) } const SECRET = 'my-super-secret-key' const payload = { userId: 42, role: 'admin', exp: Math.floor(Date.now() / 1000) + 3600 } const token = sign(payload, SECRET) console.log(token) console.log(verify(token, SECRET)) // true console.log(verify(token, 'wrong-key')) // false这段代码的意义主要是把 JWT 最核心的机制讲明白:Header + Payload 先编码,再对header.payload做签名。
2. 浏览器端:原生 Web Crypto 做 HMAC
<!doctype html> <html> <body> <pre id="out"></pre> <script> const out = document.getElementById('out') const b64url = buf => btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') const b64urlStr = str => b64url(new TextEncoder().encode(str)) async function sign(payload, secret) { const header = b64urlStr(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) const body = b64urlStr(JSON.stringify(payload)) const data = new TextEncoder().encode(`${header}.${body}`) const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, data) return `${header}.${body}.${b64url(sig)}` } ;(async () => { const token = await sign({ user: 'alice', role: 'admin' }, 'browser-demo-secret') out.textContent = token })() </script> </body> </html>这就是为什么这个页面可以直接在前端完成 HMAC 签名和验签,而不用再补一个服务端调试接口。
3. 为什么还要补非对称算法支持
如果你只做HS256,会发现很多真实用户其实用不上。因为很多现代认证系统、OIDC、第三方平台、企业网关默认给你的往往是:
RS256ES256PS256JWK
所以实现上不能只停在HS256 decode,而是要把非对称算法、密钥格式和验签流程一并补齐,不然覆盖到的只是很小一部分真实场景。
六、真实项目里最容易踩的 6 个坑
1. JWT 不是加密,是签名
Payload 能读,不代表不安全;Payload 能被随便看,才是默认状态。
所以别把下面这些放进去:
- 密码
- 身份证号
- 银行卡号
- access key / secret
2. 不要相信客户端传来的alg
错误示例:
function badVerify(token) { const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString()) return verifyByAlg(token, header.alg) }正确做法是服务端强制使用自己的配置算法,而不是跟着 token 头部走。
3.base64和base64url不能混
JWT 一定是base64url,不是普通base64。差别虽然只有几个字符,但一旦混用,签名必然不一致。
4.RS256和PS256不是一回事
很多人以为“都是 RSA 家族,应该能通用”。实际上不能。
RS256:RSASSA-PKCS1-v1_5PS256:RSA-PSS
尤其RSA-PSS还要显式处理saltLength,跨语言联调时很容易出问题。
5.HMAC secret可能是原始字符串,也可能是base64 字节
这也是为什么页面里要有base64 encoded开关。很多用户不是不会验签,而是把secret 的表达形式用错了。
6. 过期判断是业务校验,不只是签名校验
签名通过,只代表 token 没被改过;
不代表:
- 没过期
- 已生效
- 受众正确
- 签发者可信
所以真实服务端里,验签只是第一步。
七、为什么这种工具比纯解码器更好用
很多 JWT 页面只能做一件事:把 Payload 解出来给你看。
但真实项目里,光“看见内容”通常不够,你还会继续碰到这些问题:
- 签名到底有没有通过
- 算法是不是用错了
- 这个 token 是不是已经过期
- 改完 Payload 之后怎么重新生成
- 公钥、私钥、Secret 到底该填哪个
所以更完整的做法应该是把几个动作连起来:
- 先解码
- 再验签
- 再判断 Claims
- 必要时重新生成
这样才更接近日常联调和排障场景,而不是只适合“看一眼内容”。
八、最后给一句最实用的判断标准
如果一个 JWT 页面只能把Payload解出来,它最多算“查看器”。
如果它还能:
- 切算法
- 验签
- 切
PEM / JWK - 处理 HMAC secret 的不同输入形式
- 自动生成非对称密钥对
- 识别
exp / iat / nbf - 前端直接完成计算
那它才更接近真实项目里能反复用到的JWT 工作台。
这也是我们这页想解决的核心问题:不是只让你“看见” JWT,而是让你把 JWT 调通。
如果你平时调试 JWT 的流程不只停留在“解码看看”,通常还会连着处理这些步骤: