← 全部文章

如何安全地解码 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:

  1. 去掉可选的 Bearer 前缀。
  2. 按点切分 token。
  3. 把第一段 Base64url 解码成头部 JSON。
  4. 把第二段 Base64url 解码成载荷 JSON。
  5. 第三段当作签名字节看待,它不是可读 JSON。
  6. 读取 subissaudexpnbfiat 这些声明。
  7. 在把这个 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,并强制检查 issaudexpnbf 和允许的算法。

在 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。

两个有风险的字段是 algkid

当服务端照单全收 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 库,但清单是通用的:

  1. 在代码里指定预期的算法,别接受 token 里的任意 alg 值。
  2. 从可信配置或可信 JWKS 里选取验证密钥。
  3. kid 当作不可信的密钥提示,而不是自由拼接的路径或 URL。
  4. 对原始的编码头部和编码载荷验证签名。
  5. 检查 expnbf,必要时只允许很小的时钟偏差。
  6. iss 和预期的签发方比对。
  7. aud 和你的 API 或 client ID 比对。
  8. 检查应用必需的声明,比如租户、角色、scope 或会话 ID。
  9. 缺少必需声明的 token 也要拒绝,而不只是拒绝过期的。

很多库默认会验证签名和时间声明,却把 issueraudience 检查做成可选项。请去读你所用库的选项,别想当然。

JWT 载荷不是私密的

Base64url 是编码,不是加密。签名过的 JWT 能做到"篡改可发现",但拿到它的任何人都能读。

不要往普通 JWT 载荷里放这些:

  • 密码
  • API key
  • 刷新令牌
  • 完整的信用卡号
  • 医疗或财务细节
  • 不该出现在日志里的私密资料字段

如果你确实需要声明保密,请用 JWE;或者把敏感数据存在服务端,只下发一个短小的会话标识符。对很多 Web 应用来说,更简单的 session ID 方案,比自包含的加密 token 更容易做安全。

本文开头那节 Base64 讲的就是更普遍的编码误解,以及正确的安全边界该划在哪里。

在本地解码 JWT

需要快速查看一个 token 时,把它粘进 JWT 解码器。这个工具在你的浏览器里运行,返回一个包含 headerpayload 的 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

构造查询串时,优先用 URLURLSearchParams,别手工拼接:

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()。构造查询串时,优先用 URLURLSearchParams

在本地查看 token

参考资料

最后校订于 2026 年 8 月。