如何安全地解码 JWT、Base64url 与 URL 编码数据
在本地解码 JWT 声明、Base64 与 Base64url 字节,以及百分号编码的 URL 值;正确处理 Unicode,并搞清楚为什么编码既不是加密,也不是 JWT 验签。
JWT 很好读,也很容易读错。它看着像一串不透明的凭据,但一个标准的签名 JWT 不过是用点分开的三段 Base64url:
header.payload.signature
前两段是 JSON。拿到 token 的人,不需要任何密钥就能解开它们。调试登录流程、检查 exp 时间戳、确认某个 token 是签给哪个 aud 的时候,这很方便。而这也正是为什么:载荷里不该放密码、API key、私密的个人资料,或者任何你不愿意出现在日志里的东西。
实用原则很简单:解码 JWT 是为了查看它,信任它之前必须验签。
Base64 是编码,不是加密
Base64 是一种把字节转成可打印文本的编码方式。通常三个输入字节变成四个字符,所以在压缩之前,输出会大约膨胀三分之一。这里没有密码、没有密钥,也没有任何密码学证明。
btoa('hello'); // aGVsbG8=
atob('aGVsbG8='); // hello
浏览器的这两个函数处理的是"二进制字符串"这种字节形态,而不是任意 Unicode 文本。请显式走 UTF-8:
function utf8ToBase64(text) {
const bytes = new TextEncoder().encode(text);
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
return btoa(binary);
}
function base64ToUtf8(base64) {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
Base64 适合把二进制数据塞进 JSON、MIME、HTTP 头或其他文本通道传输。它不适合用来藏前端的 API key、存密码、在明文 HTTP 上保护 Basic Auth,或者让 URL 里的状态"保密"。传输安全靠 TLS,机密性靠带认证的加密,密码靠哈希,完整性靠签名或 MAC。Base64 可以把这些操作的产物包一层,但它本身不提供任何安全性。
快速答案
解码一个 JWT:
- 去掉可选的
Bearer前缀。 - 按点切分 token。
- 把第一段 Base64url 解码成头部 JSON。
- 把第二段 Base64url 解码成载荷 JSON。
- 第三段当作签名字节看待,它不是可读 JSON。
- 读取
sub、iss、aud、exp、nbf、iat这些声明。 - 在把这个 token 用于授权之前,先在服务端验证签名和必需的声明。
第 1 到第 6 步不需要任何密钥,只有验签那一步才需要。
JWT 长什么样
下面是一个简短的示例 token,用来练习解码,不是生产凭据:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsImlhdCI6MTUxNjIzOTAyMn0.X6sdNf_Fd-AFXKXrbTXHt3QuGJs6aTrwhYR6F4tZNIY
按点切开:
| 分段 | 解码成 | 用途 |
|---|---|---|
eyJhbGci... |
头部 JSON | 算法、类型、密钥提示 |
eyJzdWIi... |
载荷 JSON | 关于主体的各项声明 |
X6sdNf... |
签名字节 | 篡改检测 |
解码后的头部:
{
"alg": "HS256",
"typ": "JWT"
}
解码后的载荷:
{
"sub": "1234567890",
"name": "Ada Lovelace",
"iat": 1516239022
}
签名那段解不出什么好看的 JSON 对象。它是对"编码后的头部 + 编码后的载荷"做密码学运算的输出。解码器能告诉你签名段存在,但只有验签才能证明它是有效的。
一句话说清 JWT、JWS 和 JWE
这几个名字太像了,容易搞混:
| 术语 | 含义 | 光靠解码能读到载荷吗? |
|---|---|---|
| JWT | JSON Web Token,RFC 7519 定义的声明格式 | 通常可以 —— 当它以签名的 JWS 形式出现时 |
| JWS | JSON Web Signature,为可读载荷提供完整性保护 | 可以 |
| JWE | JSON Web Encryption,为保密而加密的载荷 | 不行,除非先解密 |
Web 应用里的大多数 JWT 都是三段式的短 JWS —— 它们是签名的,不是加密的。典型的 JWE 紧凑序列化有五段。普通的 JWT 解码器面对五段式 token 是读不出声明的,因为载荷被加密了。
在浏览器里解码 JWT
JWT 用的是 Base64url,不是普通 Base64。Base64url 把 + 换成 -、/ 换成 _,而且经常省掉 = 补齐。健壮的解码器要在解码前把这些细节还原回去。
下面这个版本还通过 TextDecoder 按字节解码,从而安全地处理 Unicode —— 这比直接把 atob() 的输出当文本用要靠谱。
function base64UrlToJson(part) {
const base64 = part
.replace(/-/g, '+')
.replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
const json = new TextDecoder().decode(bytes);
return JSON.parse(json);
}
function decodeJwt(token) {
const trimmed = token.trim().replace(/^Bearer\s+/i, '');
const parts = trimmed.split('.');
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Error('Not a JWT: expected header.payload.signature');
}
return {
header: base64UrlToJson(parts[0]),
payload: base64UrlToJson(parts[1]),
signature: parts[2] || ''
};
}
用它来查看:
const decoded = decodeJwt(token);
console.log(decoded.header.alg);
console.log(decoded.payload.sub);
console.log(decoded.payload.exp);
千万别把解出来的载荷当身份证明用 —— 上面这段代码压根没检查签名对不对。
在 Node.js 里解码 JWT
现代 Node 可以用 Buffer 直接解 Base64url。
function decodePart(part) {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
function decodeJwt(token) {
const trimmed = token.trim().replace(/^Bearer\s+/i, '');
const [headerPart, payloadPart, signature = ''] = trimmed.split('.');
if (!headerPart || !payloadPart) {
throw new Error('Not a JWT: expected header.payload.signature');
}
return {
header: decodePart(headerPart),
payload: decodePart(payloadPart),
signature
};
}
这仍然只是解码。生产环境里请用 JOSE/JWT 库来验证 token,并强制检查 iss、aud、exp、nbf 和允许的算法。
在 Python 里解码 JWT
Python 的 base64.urlsafe_b64decode() 能处理 URL 安全字母表,但补齐还得你自己还原。
import base64
import json
def decode_part(part):
padded = part + "=" * (-len(part) % 4)
decoded = base64.urlsafe_b64decode(padded)
return json.loads(decoded.decode("utf-8"))
def decode_jwt(token):
token = token.strip()
if token.lower().startswith("bearer "):
token = token[7:].strip()
parts = token.split(".")
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError("Not a JWT: expected header.payload.signature")
return {
"header": decode_part(parts[0]),
"payload": decode_part(parts[1]),
"signature": parts[2] if len(parts) > 2 else ""
}
如果这个 token 来自真实的 Authorization 头,别把它完整打出来 —— 只记录一小段前缀或者一个哈希,而不是整个凭据。
读懂头部
头部很小,但它告诉你这个 token 期望以什么方式被验证。
| 头部字段 | 示例 | 该检查什么 |
|---|---|---|
alg |
RS256 |
签名算法。别盲目相信它;在验证器里用白名单限定允许的算法。 |
typ |
JWT |
通常只是说明性的。有些系统拿它区分 token 类型。 |
kid |
2026-07-key-1 |
密钥 ID。只用它去可信密钥集里查找密钥。 |
cty |
JWT |
内容类型,有时用于嵌套 token。 |
两个有风险的字段是 alg 和 kid。
当服务端照单全收 token 里写的算法时,alg 就危险了。如果验证器配置不当,伪造的 token 可以声称 alg: "none",或者把非对称算法换成对称算法。好的库允许你传一个明确的白名单,比如 ['RS256']。
kid 同样是攻击者可控的输入。请把它当成"在固定 JWKS 里查找的提示",而不是文件路径、SQL 值或任意 URL。
读懂载荷里的声明
JWT 载荷的声明就是普通的 JSON 字段,一部分由 JWT 标准注册,另一部分是你应用自己的。
| 声明 | 含义 | 常见的排查问题 |
|---|---|---|
iss |
签发方 | 这个 token 是我信任的身份提供方签的吗? |
sub |
主体 | 这个 token 说的是哪个用户、服务账号或主体? |
aud |
受众 | 这个 token 本来就是发给我这个 API 的吗? |
exp |
过期时间 | token 已经过期了吗? |
nbf |
生效时间 | 是不是用得太早了? |
iat |
签发时间 | 它是什么时候签的? |
jti |
JWT ID | 这个 token 能被追踪或吊销吗? |
时间类声明是以秒为单位的 Unix 时间戳,不是 JavaScript 的毫秒。
const expiresAt = new Date(payload.exp * 1000);
忘了 * 1000,过期时间就会显示在 1970 年附近;而对一个本来就是毫秒的值再乘一次,它会飞到很远的未来。调试时先看一眼原始数字。
自定义声明可能长这样:
{
"tenant_id": "acme",
"role": "admin",
"scope": "read:invoices write:invoices"
}
这些字段没什么 JWT 魔法,它们只是应用策略。你的服务端仍然要自己判断:这个主体到底有没有权限做这次请求的事。
解码不等于验签
正是这个误解制造了安全漏洞:解码出来的载荷不是可信的载荷。
任何人都能写出这段 JSON:
{
"sub": "123",
"role": "admin",
"exp": 4102444800
}
任何人也都能把它 Base64url 编码成一个"看起来像 JWT"的 token。解码器会老老实实显示 "role": "admin"。而验签才是在检查:可信的签发方是否真的对这份头部和载荷签过名。
用解码来做:
- 调试 token 到底写了什么。
- 检查前端是否拿到了预期的声明。
- 查看过期时间和受众值。
- 判断一个 token 更像 JWS 还是 JWE。
用验签来做:
- 登录会话。
- API 授权。
- 角色或权限判定。
- 接受来自另一个服务的 token。
- 任何会改动数据的操作。
一份安全的验签清单
验签应该在服务端或可信的后端服务里完成。具体 API 取决于你用的 JWT 库,但清单是通用的:
- 在代码里指定预期的算法,别接受 token 里的任意
alg值。 - 从可信配置或可信 JWKS 里选取验证密钥。
- 把
kid当作不可信的密钥提示,而不是自由拼接的路径或 URL。 - 对原始的编码头部和编码载荷验证签名。
- 检查
exp和nbf,必要时只允许很小的时钟偏差。 - 拿
iss和预期的签发方比对。 - 拿
aud和你的 API 或 client ID 比对。 - 检查应用必需的声明,比如租户、角色、scope 或会话 ID。
- 缺少必需声明的 token 也要拒绝,而不只是拒绝过期的。
很多库默认会验证签名和时间声明,却把 issuer 和 audience 检查做成可选项。请去读你所用库的选项,别想当然。
JWT 载荷不是私密的
Base64url 是编码,不是加密。签名过的 JWT 能做到"篡改可发现",但拿到它的任何人都能读。
不要往普通 JWT 载荷里放这些:
- 密码
- API key
- 刷新令牌
- 完整的信用卡号
- 医疗或财务细节
- 不该出现在日志里的私密资料字段
如果你确实需要声明保密,请用 JWE;或者把敏感数据存在服务端,只下发一个短小的会话标识符。对很多 Web 应用来说,更简单的 session ID 方案,比自包含的加密 token 更容易做安全。
本文开头那节 Base64 讲的就是更普遍的编码误解,以及正确的安全边界该划在哪里。
在本地解码 JWT
需要快速查看一个 token 时,把它粘进 JWT 解码器。这个工具在你的浏览器里运行,返回一个包含 header 和 payload 的 JSON 对象。它也接受你直接复制来的 Bearer ...,解码前会自动去掉前缀。
因为真实 JWT 就是 bearer 凭据,所以请坚持本地优先的工作方式:
- 写 bug 报告时,优先用已过期的或测试环境的 token。
- 别把生产 token 粘到来路不明的第三方工具里。
- 截图和日志里的 token 要打码。
- 只需要看某一段的话,就单独解那一段 Base64url,而不是把整个 token 分享出去。
本地解码降低了暴露面,但它不会让 token 变得无害 —— 任何人拿到一个未过期的 bearer token,都有可能直接拿去用。
URL 编码是另一层
百分号编码把 UTF-8 字节表示成 %XX 序列,这样数据就不会被误当成 URL 结构。它不是 Base64,也不提供任何保密性。
按边界选择对应的 API:
// 单个路径段或查询值:转义 /、?、&、= 和 #。
encodeURIComponent('reports/2026?team=R&D');
// reports%2F2026%3Fteam%3DR%26D
// 一条完整 URL:保留结构分隔符。
encodeURI('https://example.com/search?q=hello world');
// https://example.com/search?q=hello%20world
构造查询串时,优先用 URL 和 URLSearchParams,别手工拼接:
const url = new URL('https://example.com/search');
url.searchParams.set('q', 'hello world');
url.searchParams.append('tag', 'R&D');
URLSearchParams 用的是表单风格的编码,空格会被序列化成 +。decodeURIComponent() 不会把 + 转成空格,而 URLSearchParams 会。解码原始查询值时,这个区别很要紧。
如果 %20 变成了 %2520,说明这个值多半被编码了两次 —— %25 就是编码后的百分号。请把流程改成"每个组成部分在自己的边界上恰好编码一次"。另外,不要对不可信输入反复解码"直到它不再变化":多解一层可能把数据变成 /、&、.. 这样的分隔符,而此时安全检查早已跑完了。
JWT 的紧凑 token 使用 URL 安全的 Base64 字母表,所以它的分段一般能安然无恙地待在路径或查询参数里。但你复制来的 token 仍可能被外层的 URL、表单或 JSON 字符串再做一层百分号编码。这种情况下,只剥掉你确定是传输层加上的那一层,然后再切分并解码 token。
JWT 解码错误排查
| 报错或现象 | 可能原因 | 怎么修 |
|---|---|---|
Not a JWT |
少了点、头部为空,或载荷为空 | 粘贴紧凑 token 本身,而不是 JSON 包装或带引号的字符串 |
Invalid JWT header |
第一段不是 Base64url 的 JSON | 检查是不是复制粘贴时被截断,或者 token 类型不对 |
Invalid JWT payload |
第二段不是 Base64url 的 JSON | 检查截断、URL 编码,或者它其实是加密的 JWE |
| 有五个点分段 | 这是紧凑 JWE,不是普通签名 JWT | 换用 JWE 库和解密密钥 |
| 过期时间显示成 1970 年 | 把秒当成了毫秒 | 改用 new Date(exp * 1000) |
| 载荷能读出来,但应用拒绝了它 | 签名、签发方、受众或过期时间没通过 | 用和服务端相同的配置去验证 |
最后一行的共性是:能读懂不等于有效。 一个已过期、被伪造、发给别的受众,或者由错误签发方签名的 token,照样能被完美地解码出来。
常见问题
怎么解码一个 JWT?
去掉可选的 Bearer 前缀,按点切分,把头部和载荷做 Base64url 解码,再把字节按 UTF-8 解码,最后各自解析成 JSON。签名那一段不是给人读的 JSON。
解码 JWT 需要密钥吗?
不需要。头部和载荷只是 Base64url 编码的,解码它们不需要任何密钥。只有在检查签名、证明 token 真实性时,才需要密钥或公钥。
解码 JWT 等于验证它吗?
不等于。解码告诉你 token 声称了什么;验证则检查密码学签名和必需的声明,比如签发方、受众、过期时间和生效时间。绝不要只凭解码出来的声明就放行。
任何人都能读到 JWT 里的数据吗?
对于常规的签名 JWT,是的。载荷没有被加密,它只是被 Base64url 编码并签了名。拿到 token 的人都能读出这些声明,所以别把机密或私密个人数据放进载荷。
exp 声明是什么意思?
exp 是以秒为单位的 Unix 过期时间戳。在 JavaScript 里用 new Date(exp * 1000) 转换。验证库应该在你的应用信任任何声明之前,就把过期 token 拒掉。
我的 token 有五段而不是三段,怎么回事?
五段的紧凑 token 通常是 JWE,也就是载荷被加密了。普通 JWT 解码器能把它切开,但没有解密密钥和支持 JWE 的库,就读不出声明。
kid 头部字段是干什么的?
kid 是 JWT 头部里的密钥 ID,帮验证方从可信的 JWKS 或密钥集中选出正确的密钥。请把它当作不可信输入,绝不要直接拿它当文件路径、SQL 值或 URL 用。
Base64 是加密吗?
不是。任何拿到编码值的人,不需要密钥就能解开它。Base64 擅长的是把字节表示成文本;而机密性 = 加密 + 妥善的密钥管理。
Base64 和 Base64url 有什么区别?
Base64url 把 + 和 / 换成 - 和 _,通常还省掉补齐。它对 URL 和文件名更友好,但安全属性和标准 Base64 完全一样。
该用 encodeURI 还是 encodeURIComponent?
单个路径段、查询键或查询值,用 encodeURIComponent()。只有当你手上已经是一条完整 URL、且必须保住它的结构字符时,才用 encodeURI()。构造查询串时,优先用 URL 和 URLSearchParams。
在本地查看 token
- JWT 解码器 —— 在浏览器本地解码 JWT 的头部和载荷。
- Base64 编码与解码 —— 手动解开某一段 Base64url。
- 在线修复 JSON —— 处理敏感 token 和载荷时更安全的本地流程。
- URL 编码与解码 —— 在本地查看百分号编码的各个组成部分。
参考资料
- RFC 7519 —— JSON Web Token。
- RFC 7515 —— JSON Web Signature。
- RFC 7516 —— JSON Web Encryption。
- RFC 7517 —— JSON Web Key。
- RFC 8725 —— JWT 当前最佳实践。
- RFC 4648 —— Base64 与 Base64url 编码。
- RFC 3986 —— URI 语法与百分号编码。
- MDN: URLSearchParams —— 查询串的编码与解析。
最后校订于 2026 年 8 月。