Base64、URL 编码与 JWT 详解
只要数据需要以文本形式传输,比如在 URL、HTTP 头、JSON 和登录令牌中,就会遇到这三者。但它们都不会隐藏任何内容。本指南说明它们各自的工作原理、出现的场合,以及导致大多数问题的错误。
编码不是加密
编码是改变数据的书写方式,让它能通过只接受特定字符的通道。由于不涉及密钥,任何人都能把它还原。加密也会改变数据,但只有持有密钥的人才能还原。哈希又不一样:它是一种无法还原的单向指纹,详见哈希函数详解。
Base64 和 URL 编码都属于编码。JWT 通常带有签名,可以证明是谁签发的、内容没有被改动,但其内容本身只是经过了编码。如果某个值必须保密,需要的是加密,而不是这些格式。
Base64 的工作原理
Base64 每次读取 3 个字节(24 位),把它们分成 4 组,每组 6 位。每组是 0 到 63 之间的一个数,用 64 个字符组成的字母表中的一个字符来表示:0–25 是 A–Z,26–51 是 a–z,52–61 是 0–9,然后是 + 和 /。用单词“Man”来看看各个步骤:
| 步骤 | M | a | n |
|---|---|---|---|
| 字节值 | 77 | 97 | 110 |
| 二进制位 | 01001101 | 01100001 | 01101110 |
| 4 组 6 位 | 010011 · 010110 · 000101 · 101110 = 19 · 22 · 5 · 46 | ||
| Base64 | T W F u → TWFu | ||
当输入长度不是 3 的倍数时,最后一组会被补齐并用填充标记:剩下 1 个字节时得到 2 个字符和 ==,剩下 2 个字节时得到 3 个字符和 =。“Ma”变成 TWE=,“M”变成 TQ==。
由于每 3 个字节变成 4 个字符,Base64 会让数据增大约三分之一,某些格式还会再加上换行。作为邮件附件发送的 3 MB 照片,传输时大约是 4 MB 的文本;以数据 URL 嵌入样式表的图片,也比原文件大约重三分之一。
在哪里会遇到 Base64
- 邮件附件(MIME)是每行 76 个字符换行的 Base64 文本。
- 数据 URL,例如
data:image/png;base64,iVBORw0…,把小文件直接放进 HTML 或 CSS。 - HTTP 基本认证把
user:password转成 Base64 放在 Authorization 头中发送。这只能让密码不被一眼看出,因此只有在 HTTPS 下才安全。 - 证书和密钥的 PEM 文件,是位于
-----BEGIN …-----和-----END …-----两行之间、每行 64 个字符的 Base64。 - JSON 和 XML 没有二进制类型,因此用 Base64 字符串承载签名、缩略图等二进制值。
Base64URL 与填充
标准 Base64 中的 + 和 / 在 URL 和文件路径里有特殊含义,因此第二种字母表 Base64URL 改用 - 和 _。由于解码器能算出长度,它通常也省略 = 填充。JWT、许多 API 令牌和一些文件名都使用 Base64URL。要把标准 Base64 转成 Base64URL,把 + 换成 -、/ 换成 _,并删去末尾的 =;反过来时,撤销替换,再补上 = 直到长度是 4 的倍数。
URL 中的百分号编码
URL 只能包含有限的字符,其中一些还用来表示地址的结构:/ 和 ? 分隔路径和查询,& 和 = 分隔参数,# 表示片段的开始。当一个值包含这些字符或任何非 ASCII 字符时,就要进行百分号编码:把它的 UTF-8 形式的每个字节写成 % 加两位十六进制数。é 是 C3 A9 两个字节,所以“café”会变成 caf%C3%A9。
字母、数字以及 - . _ ~ 永远不需要编码。最常引起问题的字符如下:
| 字符 | 编码后 | 为什么重要 |
|---|---|---|
| 空格 | %20 | URL 中不允许出现 |
| & | %26 | 会开始一个新参数 |
| = | %3D | 会结束参数名 |
| ? | %3F | 会开始查询部分 |
| # | %23 | 会开始片段,而片段不会发送到服务器 |
| / | %2F | 会增加一级路径 |
| + | %2B | 在表单数据中会被当作空格 |
| % | %25 | 会开始一个转义序列 |
先逐个编码各个值,再拼装 URL。在 JavaScript 中,encodeURIComponent 用于编码单个值,也会编码 / ? & = #;而 encodeURI 用于整个地址,会保留这些字符。对单个值使用 encodeURI 是查询字符串出错的常见原因。
加号、空格与重复编码
HTML 表单以 application/x-www-form-urlencoded 格式发送字段,其中空格会变成 +。而在一般的 URL 语法中,+ 只是一个加号。因此,把“C++”的搜索以 q=C++ 发送时,在解码表单数据的服务器上会变成“C”后面跟两个空格;正确的写法是 q=C%2B%2B。手动解码查询字符串时,先弄清楚它是按哪种规则生成的。
重复编码是指把已经编码过的值再编码一次:% 本身会变成 %25,所以 %20 会变成 %2520。如果在 URL 中看到 %25 后面跟着两位十六进制数,说明链路中某处编码了两次;先解码一次,再查找多出来的那一步在哪里。
JWT 里有什么
JSON Web Token(JWT)把各种声明(例如用户是谁、令牌在什么时间前有效)从签发它的系统传给接受它的系统。常见的已签名形式由点分隔的三段 Base64URL 组成:头部、载荷和签名。前两段解码后就是普通的 JSON:
Header: {"alg": "HS256", "typ": "JWT"}
Payload: {"sub": "user-42", "role": "editor", "iat": 1767225600, "exp": 1767229200}
标准(RFC 7519)定义了几个注册声明;应用程序还会添加自己的声明,例如上面的 role。
| 声明 | 含义 |
|---|---|
| iss | 签发者:创建令牌的一方 |
| sub | 主体:通常是用户或账户 ID |
| aud | 受众:令牌面向的服务 |
| exp, nbf, iat | 过期时间、生效时间、签发时间:以秒为单位的 Unix 时间 |
| jti | 令牌 ID:用于发现重复使用或吊销单个令牌 |
由于载荷只是经过编码,持有令牌的任何人都能读到它。不要在其中放入个人数据和机密信息,并让令牌保持小巧:每个请求都会带上它,而许多服务器会限制请求头的大小。
有签名不等于保密:如何校验 JWT
签名是根据头部和载荷计算出来的。使用 HS256 时,签发方和接收方共享同一个密钥,采用基于 SHA-256 的 HMAC。使用 RS256 或 ES256 时,签发方用私钥签名,接收方用对应的公钥校验,因此接收方无法自行创建令牌。
接收令牌的服务器在信任任何声明之前,必须用预期的算法和密钥校验签名,并检查 exp、nbf、iss 和 aud。像 LeedyBox 工具那样只做解码,会跳过所有这些步骤:它显示令牌写了什么,而不是令牌是否真实。库还必须拒绝表示未签名令牌的“none”算法,除非本来就预期使用未签名令牌。如果内容本身必须隐藏,就要对令牌加密(JWE),这时令牌由五段组成,没有密钥就无法读取。
常见问题与解决办法
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Base64 解码后是乱码 | 数据是二进制,或是其他编码的文本 | 确认编码的是什么;二进制数据请另存为文件 |
| 解码时提示“无效字符” | 带有 data: 前缀、引号,或使用了 Base64URL 字母表 | 删去逗号之前的所有内容;使用接受 - 和 _ 的解码器 |
| 参数被截断 | 值中有未编码的 & 或 # | 用 encodeURIComponent 逐个编码各个值 |
| URL 中出现 %2520 | 值被编码了两次 | 只在构建 URL 的地方编码一次 |
| exp 还没到,令牌却被拒绝 | 受众或签发者不对、时钟偏差或密钥不匹配 | 把 aud、iss 和签名密钥与服务器的期望值逐一比对 |
| 过期日期在几千年后 | exp 写成了毫秒而不是秒 | 以秒为单位存储 exp、nbf 和 iat |
常见问题
Base64 能用来保护密码或 API 密钥吗?
不能。任何人都能在一秒钟内还原 Base64。放在配置文件或 URL 中的 Base64 编码凭据,和明文一样暴露。
可以把个人数据放进 JWT 载荷吗?
尽量避免。拿到令牌的任何一方都能读到载荷,包括浏览器扩展、日志和能看到请求的代理。令牌中只放一个 ID,详细信息在服务器端查询。
URL 编码能防止注入攻击吗?
只是在“值无法破坏 URL 结构”这一意义上有效。服务器仍须针对解码后的值的实际用途(如 SQL、HTML 或 shell 命令)进行校验和转义。
为什么 Base64 字符串比原文件还长?
每 3 个字节变成 4 个字符,所以文本会大约增大 33%,换行还会再增加一些。对于大文件,直接发送字节比使用 Base64 更高效。