Python 中的 Base64 解码:完整指南
你的代码里刚落进来一串字母,看起来完全不像文本:一长段 A 到 Z,几个数字,偶尔冒出 + 或 /,也许还有 - 或 _,末尾可能还停着一两个 =。这串字符背后,可能是你的网关拒掉的某个 JWT 的载荷,可能是藏在一整页 HTML 里的一张图片,可能是某人用 .b64 附件发给你的文件,也可能是一个转手过三个客服工单的证书块。你的任务是原封不动地交还最初的字节。而 Python 做这件事正来劲,因为整套工具箱几十年来一直随标准库发货:一行 import base64,任何平台即刻就绪,不用安装任何东西,也不用配置任何东西。
趁你坐定,快速复习一下,因为谁都得一年复习一次:Base64 把数据每三个字节重写成四个字符,取自一个 64 字母的字符表,而当最后那组三个字节凑不齐时,就用 = 填充把这一组补满,让输出永远以四个为一组。这就是全部诀窍。它不是压缩,也不是保密,只是让二进制在只接受文本的通道里活下去的办法。本站首页已经把这个格式讲得足够透彻,包括字母表和填充算术,所以我们把力气花在真正疼的地方:解码的 Python 这一侧,以及让结果保持诚实。
有三个事实决定了后面的一切,值得在继续读之前先背下来。第一,解码器有两种脾气:一种礼貌又宽容的默认脾气,会悄悄丢掉它认不出的任何东西;一种严格模式,直接拒收这类输入。第二,解码的结果永远是一个 bytes 对象,绝不是字符串,而你想从中拿到真正的文本,那个时刻必须由你刻意做出决定。第三,有两种几乎一模一样的字母表,标准版和 URL 安全版,把它们搞混是丢数据却一个错误都不报的经典方式。这份指南会把这三条逐一讲透,这样下次一整墙天书落在你的终端里时,你可以微笑面对,而不是眯眼干瞪。
完整的解码菜单
打开 base64 模块,你会看到两代接口并排坐着。现代那一代以 b64decode 为中心,把 bytes 类对象(以及纯 ASCII 字符串)还原回字节,而且通晓 RFC 4648 定义的两种 Base64 方言。遗留那一代更老,而且面向文件:它操作文件对象,只认识标准字母表,是围绕 1996 年 MIME 邮件标准 RFC 2045 要求编码输出必须采用的 76 字符折行构建的。在那些上了年纪的代码里,你会经常遇到这些遗留名字,所以这是完整的解码菜单:
| 函数 | 它做什么 | 备注 |
|---|---|---|
base64.b64decode(s, altchars=None, validate=False) |
主力:把一块 Base64 还原回原始字节 | 接受 bytes 或 ASCII 字符串,永远返回 bytes |
base64.standard_b64decode(s) |
同一份活儿,锁定标准字母表 | 当你确定对方方言时很方便 |
base64.urlsafe_b64decode(s) |
读取带 - 和 _ 的 URL 安全字母表 |
读 JWT 的就是它 |
base64.decodebytes(s) |
解码一行或多行折行后的 Base64 | Python 3.1 加入,MIME 友好的路子,宽容 |
base64.decode(input, output) |
把 Base64 文件以流的方式还原进原始文件 | 遗留接口,逐行读取,宽容 |
base64.b32decode(s, casefold=False) |
解码更小的 Base32 近亲 | casefold 接受小写输入 |
base64.b16decode(s, casefold=False) |
解码 Base16,就是纯十六进制 | Python 3.14 中最多快六倍 |
binascii.a2b_base64(s, strict_mode=False) |
在 C 层干真正活儿的函数 | 直接控制严格程度的把手,strict_mode 自 Python 3.11 起可用 |
下面的一切都建立在第一行之上。在深入之前,有一件事值得知道:在官方文档里,这个模块归在"互联网数据处理"之下,就紧挨着 binascii,而这个位置绝非偶然。b64decode 是一个薄薄的包装层:它先转换字母表(当你传入 altchars 时),然后把重活交给 C 层的 binascii.a2b_base64。这就是这个函数快的原因,也是它的错误信息带着 C 语言那种干脆、不掺感情的味道的原因。
主力选手:b64decode
完整约定如下,短到能装进你的脑子。这个函数接收一个 bytes 类对象或 ASCII 字符串、一个可选的两位字母表替换,以及一个校验标志。它交还一个 bytes 对象。失败时它抛出 binascii.Error,后者是 ValueError 的子类,以便你哪天需要一次抓住一整个异常家族:
import base64
data = base64.b64decode("Zm9vYmFy")
print(data)
# b'foobar'
print(type(data))
# <class 'bytes'>
最后那行是这篇文章里最重要的一行。结果是字节,不是字符串,而 Python 只扶你走它该扶的那一段:打印这个对象会给你看 b'...' 表示,试图把它粘到字符串上则会抛出 TypeError。一旦你想要真正的文本,决定由你来做,下面的字符集章节会讲到:什么时候这个决定轻而易举,什么时候它是一个陷阱。
可选的 altchars 参数会把标准字母表里的 + 和 / 换成另一对字符。正是这个旋钮制造出 URL 安全方言,urlsafe_b64decode 就是在 b64decode 之上这么搭出来的。你自己很少会去碰 altchars,但知道这台机器在那里总归是好事。除此之外,这个函数只是干脆利落地把活儿干完,很快,用 C。
默认宽容,按需严格
默认情况下,b64decode 是个礼貌的健忘鬼。解码开始之前,任何不在 64 字母字符表里的字符(也不在你的 altchars 里)都会被悄悄扔掉,活下来的部分照常解码。没有警告,没有提示,没有可供检查的返回值,只有一个结果。这份宽容有一位高贵的祖先:RFC 2045 第 6.8 节告诉解码器"所有换行符以及未在表 1 中出现的其他字符都必须被忽略",因为 SMTP 历来会把长行折行,并沿途撒落一些杂字符。一份穿越过邮件客户端、聊天应用或 PDF 复制的载荷,往往不用任何准备就能解码成功,这确实是一项超能力。
同样的好心,也正是默认解码器当不了校验器的原因。RFC 4648 第 12 节把风险写得明明白白:忽略非字母字符、而不是拒绝整个编码,会打开一条可用于泄露信息的隐蔽信道,还会破坏字符串相等性检查,因为两个不同的输入可以解码成相同的字节。凡是你自己没有编码的东西,都传入 validate=True,并把异常当作答案。下面是损害报告,每一行都能在任何现代 Python 上复现:
| 输入什么 | 宽容(默认) | validate=True |
|---|---|---|
Zm9vYmFy(干净的载荷) |
b'foobar' |
b'foobar' |
Zm9v\r\nYmFy(中间有一个换行) |
b'foobar' |
binascii.Error |
Zm9v YmFy (多余的空格) |
b'foobar' |
binascii.Error |
Zm9v!YmFy(一个落单的感叹号) |
b'foobar' |
binascii.Error |
junkZm9vYmFy(载荷前面有个单词) |
b'\x8e\xe9\xe4foobar' |
b'\x8e\xe9\xe4foobar' |
Zm9v=YmFy(中间有个填充) |
b'foobar' |
binascii.Error |
=Zm9v(填充跑到了最前面) |
b'foo' |
binascii.Error |
====(四个填充,没有数据) |
b'' |
binascii.Error |
(空输入) |
b'' |
b'' |
看着宽容列默默干活。最先让人吃惊的是开头带单词的那一行:junk 的四个字母恰好都在 Base64 字母表里,所以这份"垃圾"被解码成三个实打实的字节,还一本正经地粘在你的载荷前面。严格模式在这行上救不了场,因为输入确实是合法的 Base64;被直接拒掉的是其他那些行,而拒绝只有一种形状:一个 binascii.Error,携带着少数几条过目不忘的信息之一:
Incorrect padding- 丢弃之后长度不是四的倍数,或者最后一组太短。像Zm9vYmE这样一点填充都没有的字符串会落在这里。Invalid base64-encoded string: number of data characters (N) cannot be 1 more than a multiple of 4- 输入恰好比下一组少一个字符。这是被截断或被复制粘贴弄丢字符的载荷的经典指纹。Only base64 data is allowed- 一个非字母字符活进了严格模式,哪怕只有一个换行也算一个。Excess padding not allowed- 填充出现在字符串中间,或者填充比最后一组允许的更多。Leading padding not allowed- 字符串以=开头。- 还有一条来自另一个家族:
ValueError: string argument should contain only ASCII characters,当你在字符串里传入了非 ASCII 字母时就会得到它。字符串是接受的,但只接受 ASCII 字符串。
在幕后,validate=True 根本不是另一条代码路径。模块把这个标志作为 strict_mode 参数转发给 binascii.a2b_base64,而严格检查正是 Python 3.11 加入 binascii 的那个。当你想要严格、又不想经过 base64 这一层时,就有了一个直接的把手:
import binascii
line = b"Zm9vYmFy"
print(binascii.a2b_base64(line, strict_mode=True))
# b'foobar'
在你盲目信任严格模式之前,有一个怪癖必须钉死:它连一个结尾换行都拒收,所以 MIME 折行的块是宽容路径或 decodebytes 的活儿,不是 validate=True 的活儿。把严格路径留给那些你预期完美干净的数据,比如刚从你自己代码里新鲜出炉的令牌。
base64url:装得进 URL 的字母表
标准字母表里有两个 URL 讨厌的字符。+ 号会被任何表单解码器读成空格,/ 号则被保留给路径分隔符。RFC 4648 第 5 节定义了那个表亲方言:+ 变成 -,/ 变成 _,并且只要数据长度能从上下文得知,填充就一并丢掉。RFC 甚至给了这个变体一个正式名字,base64url,并坚持它不应该被简称为"base64"。你最多会在 JSON Web Token 里遇到它,令牌的每一部分都是不带填充的 base64url;它也会出现在 OAuth 令牌和 API 游标参数里。
Python 为它配了专用函数 urlsafe_b64decode。它先把破折号和下划线翻译回加号和斜杠,再解码,但它不会替你补填充。不带填充的输入正是 JWT 的常态,所以算术行要放在前面,而它和 PyJWT 这类库在底层用的是同一行:
import base64
segment = "Zm9vYmE"
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'
表达式 "=" * (-len(segment) % 4) 看起来像个小技巧,但它就是全部活儿:它产生零、一或两个填充,绝不超过两个,所以已经带填充的字符串会原样通过。负数取模正是它对付所有长度字符串的诀窍,也是每个 Python 开发者迟早都要敲一遍的那行 Base64 算术。
接下来是危险的混淆,因为两种字母表长得太像了。把 base64url 字符串喂给标准解码器,那些破折号和下划线根本不在标准字母表里,于是宽容解码器把它们吞掉,解码剩下的东西。对某些载荷,那是一股被搅乱的字节流;对另一些,则什么都不是:
import base64
tricky = base64.urlsafe_b64encode(b"\xfb\xff\xfe")
print(tricky)
# b'-__-'
print(base64.standard_b64decode(tricky))
# b'' - 每个字符都被悄悄丢掉了
print(base64.urlsafe_b64decode(tricky))
# b'\xfb\xff\xfe'
反方向倒是宽容的,这正是混淆不被察觉的原因:urlsafe_b64decode 先转换自己的字母表,再宽容地解码,所以它会欣然接受带着 + 和 / 的标准字母表字符串。教训不是靠手感来。教训是每个方言只挑一个函数,然后守定它,就像对待外币:在哪种货币有效就在哪里花,别跑错兑换柜台。
输出是字节:字符集这件事
有句话能了结人们带给 Base64 的一半字符集疑问:b64decode 解码的是字节,不是文本。没有字符集参数,没有任何转换,输入里也没有任何东西告诉 Python 这些字节应该是什么意思。意思必须由你从上下文供给,而这个上下文几乎总是下面三种之一:一个说明它的头部,一份说明它的 API 约定,或者一个藏在字节本身的魔数。
import base64
raw = base64.b64decode("w6l0w6k=")
print(raw)
# b'\xc3\xa9t\xc3\xa9'
print(raw.decode("utf-8"))
# été
同样的想法,配上错误的标签,就是一次响亮的失败,而这反而是幸事。不是合法 UTF-8 的字节拒绝变成字符串,异常会精确地告诉你哪个字节冒犯了它:
import base64
raw = base64.b64decode("/w==")
try:
raw.decode("utf-8")
except UnicodeDecodeError as caught:
print(caught)
# 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
三条经验法则能让这一节不至于变成恐怖故事。一:解码后的数据如果是 JSON,你完全不需要手动解码,因为 json.loads 从 Python 3.6 起就直接接受 bytes,而且自己会探测 UTF-8、UTF-16 和 UTF-32。二:二进制不是文本,所以对一个 PNG 来说,"探测出"的字符集是撞大运的猜测,而不是事实;去查字节,别查标签。三:发送方如果告诉了你字符集,相信发送方,因为 content-type 头部或 API 文档永远排在任何探测器前面,每一次都是。
解码后的 Base64 出现在 Python 代码的哪些地方
日子久了,你开始认得这些形状。这是一份野外指南,讲解码后的 Base64 会在 Python 应用里出现在哪些地方,以及各自的单行配方。接下来的章节会把最常见的几种完整讲一遍:
| 你在哪里发现它 | 它是什么 | 怎么读它 |
|---|---|---|
| 一个 JWT | 头部、载荷和签名部分(RFC 7519) | 按点号切开,urlsafe_b64decode 加填充修复 |
一个 Authorization 头部 |
HTTP Basic 凭据,user:pass(RFC 7617) |
去掉 Basic 前缀,解码,按第一个冒号切开 |
一个 data: URI |
HTML 或 CSS 里的内联媒体(RFC 2397) | 在第一个逗号处切开,解码剩下的部分 |
| 一个邮件附件 | 一个 Content-Transfer-Encoding: base64 正文(RFC 2045) |
对消息部件调用 get_payload(decode=True) |
| 一个邮件头部值 | 一个 =?charset?b?...?= 编码词(RFC 2047) |
让 email 包替你解码 |
| 一个 PEM 文件 | 一个带护甲的密钥或证书(RFC 7468) | 去掉护甲行,把正文解码成 DER |
| 一个 JSON API 字段 | 伪装成字符串混进来的二进制 | 解码,然后把结果当字节,别当文本 |
| 一个 TEXT 列或环境变量 | 存在纯文本地里的二进制或 JSON | 解码,然后解析或写入,用你们约定好的字符集 |
阅读一个 JSON Web Token
一个 JWT 是三段用点号连起来的 base64url:头部、载荷和签名。前两段是纯 JSON,所以往里一瞥各只需一行,用上一节的填充修复:
import base64
import json
def read_part(segment):
padded = segment + "=" * (-len(segment) % 4)
return base64.urlsafe_b64decode(padded)
token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0."
"8Rmup2hf8jZvoBgoCRqRWlBFNtvUYmA0eR7YKellPMs")
head, body, _signature = token.split(".")
print(json.loads(read_part(head)))
# {'alg': 'HS256', 'typ': 'JWT'}
print(json.loads(read_part(body)))
# {'sub': '1234567890', 'name': 'John Doe'}
范围说明一句,因为这事要紧:这样检查令牌是调试工具,不是认证机制。载荷读得出来,不代表它是真的;攻击者可以伪造前两段,而完全不需要知道你的密钥。要做真正的验证,就把令牌交给 PyJWT(pip install pyjwt),它会检查签名,并且在没有显式算法列表时拒绝解码:
import jwt
# 短于 32 字节的密钥会招来 PyJWT 的 InsecureKeyLengthWarning(PyJWT 2.11+),对演示密钥来说算是一句合理的抱怨。
decoded = jwt.decode(token, "super-secret-key", algorithms=["HS256"])
print(decoded)
# {'sub': '1234567890', 'name': 'John Doe'}
用错密钥时,你得到的是异常而不是字典,而这正是生产代码里你想要的行为。如果令牌带着过期的时间戳到达,PyJWT 也会为此抛出异常,所以你永远不用自己去记那些声明的名字。
打开一个 data URI
data URI 把媒体直接嵌进 HTML 或 CSS,让浏览器不必再发第二个请求:data:、媒体类型、单词 base64、一个逗号,然后是编码后的字节。切分点在第一个逗号,句号,它后面的一切就是纯标准字母表的载荷:
import base64
uri = ("data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
mime, payload = uri.split(",", 1)
data = base64.b64decode(payload)
print(mime)
# data:image/png;base64
print(data[:8])
# b'\x89PNG\r\n\x1a\n'
结果开头那 8 字节的 PNG 签名,是一个便宜又开心的小检查,确认你解码对了东西。两个坑值得提一提。如果 URI 来自抓取的页面或聊天消息,先剥掉 HTML 实体和多余空白,因为宽容解码器会原谅一大堆垃圾,递给你一个损坏的图片而不是一个错误。而如果你要批量解码不受信任的输入,就传入 validate=True:一个过不了严格校验的 data URI,本来就不是格式良好的,你不想凭感觉把它写进磁盘。
拆开 Authorization 头部
Basic 认证(RFC 7617)是 HTTP 里最老的方案,至今仍然支撑着数量惊人的一批 API 集成、webhook 和 CI 流水线。客户端把凭据作为 user:pass 进行 Base64 编码,放在单词 Basic 后面发送:
import base64
header = "Basic amFuZTpwYTpzcw=="
decoded = base64.b64decode(header[len("Basic "):]).decode("utf-8")
user, _, password = decoded.partition(":")
print(user, password)
# jane pa:ss
注意那个 partition,它是日后救你的细节:密码里可以含冒号,用户 ID 里通常没有,而且只有第一个冒号才是分隔符。一句坦率话,因为 RFC 自己就说得很直白:Base64 不是加密。RFC 4648 说,base 编码"在视觉上隐藏了本容易辨认的信息,例如密码,但不提供任何计算意义上的保密性"。一个 Basic 头部能被任何看到流量的人解码,所以把它当作 TLS 保护连接上的便利,而不是安全边界。当你自己是发送头部的一方时,requests 可以用 auth=("jane", "pa:ss") 替你构建它,只要这个库已经在你技术栈里,就值得用。
邮件:最初的客户
Base64 在 1993 年被标准化,就为了一个活儿:让二进制在邮件里活下去。RFC 2045,也就是 MIME 标准,定义了 Content-Transfer-Encoding: base64 正文编码,直到今天它仍然是附件穿越互联网的默认方式。Python 的 email 包把整份活儿都替你干了:它解析头部,解码 RFC 2047 藏在头部字段里的 =?utf-8?b?...?= 编码词,你开口时它还会把正文做 Base64 解码:
import email
from email import policy
raw = (b"Subject: =?utf-8?b?w6l0w6k=?=\r\n"
b"From: sender@example.com\r\n"
b"To: reader@example.com\r\n"
b"Content-Transfer-Encoding: base64\r\n"
b"\r\n"
b"w6l0w6kgbWFpbA==\r\n")
msg = email.message_from_bytes(raw, policy=policy.default)
print(msg["Subject"])
# été
print(msg.get_payload(decode=True))
# b'\xc3\xa9t\xc3\xa9 mail'
get_payload(decode=True) 这个调用会读取 Content-Transfer-Encoding 头部并替你 Base64 解码正文,沿途把 76 字符的行拆开。policy=policy.default 参数选择现代接口,它从 Python 3.6 起可用,那时新的基于策略的 email API 结束了试验期,解码后的头部值开箱即得;遗留解析器依然能跑,只是你得亲手解码编码词。只有当你解析的不是完整消息、而是一段裸片段时,比如某人贴进工单里的一块内容,你才需要降落到 decodebytes。对于多部件消息,用 iter_attachments() 迭代,给每个部件同样的单行待遇。
PEM 护甲与 cryptography 包
一个 PEM 文件就是一行头、几行折行后的 Base64 和一行尾,再无其他。护甲只是装饰,Base64 才是全部故事,因为它解码出来就是底下的原始 DER 结构。cryptography 包(pip install cryptography)能直接加载这个结果,这正是它成为一切涉及证书和密钥的标准工具的原因:
import base64
from cryptography import x509
pem = b"""-----BEGIN CERTIFICATE-----
MIIBGzCBwaADAgECAgEBMAoGCCqGSM49BAMCMBcxFTATBgNVBAMMDGV4YW1wbGUu
dGVzdDAeFw0yNjA4MjkxNzIxMzZaFw0yNjA4MzAxNzIxMzZaMBcxFTATBgNVBAMM
DGV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPvNHjdF4b1n
SkBDT6UWtG2k8ICe45eL3kSkVfuhriev1uO9PBLMP50HWnrLbCXtl3lhWaVibctl
QbWRG4xqGLcwCgYIKoZIzj0EAwIDSQAwRgIhAKdFm5GLecg2fF7qUhSmKGtgNFaL
qVyKtDXK07N6GZd/AiEAtRXemnYqDMz77o9+VpM/NsNEwDi0yaVB+tKGLbdKJb0=
-----END CERTIFICATE-----
"""
body = b"".join(pem.splitlines()[1:-1])
der = base64.b64decode(body)
cert = x509.load_der_x509_certificate(der)
print(cert.subject.rfc4514_string())
# CN=example.test
在大多数生产代码里,你永远不需要亲手做拆护甲加解码:load_pem_x509_certificate 接受带护甲的字节,在底层替你处理 Base64 这一步。手动路径发挥价值的时候,是 DER 字节已经在手里(一个数据库列、一个配置文件、协议里的字节缓冲区),或者这块内容被包在字符串里到达、你想在信任它之前先看看里面的时候。密钥的方式完全相同,load_der_private_key 就等在同一次解码的另一侧。
文件、魔数与 .b64 习惯
解码只是半个活儿,字节通常想变成一个文件。套路是读、解码、检查、写,而检查很重要,因为一个损坏的载荷否则会悄悄产出一个错误的文件,几周之后你才会发现:
import base64
import binascii
with open("payload.b64", "rb") as handle:
encoded = handle.read()
try:
data = base64.b64decode(encoded, validate=True)
except binascii.Error:
data = base64.b64decode(encoded)
with open("payload.bin", "wb") as out:
out.write(data)
对于快速的一次性转换,遗留的文件到文件函数一次调用就跑完全程,折行也一并处理:
import base64
with open("photo.b64", "rb") as src, open("photo.png", "wb") as dst:
base64.decode(src, dst)
说真的,你刚解码出来的是什么?几乎每种常见格式的前几个字节都是固定签名,而因为 Base64 是确定性的,编码后的签名也是固定的。看到这些前缀之一,就像在远处认出了一块车牌:
| Base64 以什么开头 | 它大概是 |
|---|---|
iVBORw0KGgo |
一张 PNG 图片 |
/9j/ |
一张 JPEG 图片 |
R0lGODlh |
一张 GIF 图片 |
JVBERi0 |
一份 PDF 文档 |
UEsDBA== |
一个 ZIP 压缩包 |
UklGRg== |
一个 RIFF 容器(WAV、WEBP、AVI) |
LS0tLS1CRUdJTg== |
一个 ASCII 护甲块("-----BEGIN ...") |
趁文件还在写,把尺寸算术也做了,因为正是这个数字在磁盘写满时让人吃惊:编码会让数据膨胀大约三分之一,所以一个 300 KB 的文件会以约 400 KB 的 Base64 文本上路,而你解码回来的文件是较小的原始尺寸。你的磁盘,以及你一次读入整个文件时的内存,都应该为这个差值留好预算。
数据库、配置文件与环境变量
Base64 是走私二进制(或 JSON)穿过只接受文本的存储的惯用手段:一个 TEXT 列、一个 .ini 文件里的值、一条部署流水线里的环境变量。解码配方和文件一样,只是少了磁盘这一步:
import base64
import json
stored = "eyJyb2xlIjogImFkbWluIiwicHJvamVjdCI6Im15c2l0ZSJ9"
payload = json.loads(base64.b64decode(stored))
print(payload)
# {'role': 'admin', 'project': 'mysite'}
这个角落有两条备注。当存下的值是 JSON 时,跳过中间的 .decode("utf-8") 步骤,让 json.loads 直接收 bytes,它从 Python 3.6 起就这么干了。还有一句诚实的警告,因为整篇文章里最昂贵的误解就住在这里:环境变量或配置文件里的 Base64,是挡住瞟一眼文件的人的盾牌,挡不住真正读它的人。如果这个值真的敏感,先加密(cryptography 包自带 Fernet,正是为此而生),然后只有当你的存储要求文本时,再对密文做 Base64。
当载荷分片到达
标准库里没有增量式的 Base64 解码器:没有 update 加 finish 的一对函数,所以流式数据需要你自己做一点簿记。算术很简单,同时又很严格。四个编码字符构成三个字节,所以你只能解码完整的四字符组,而必须把余数带到下一个块去:
import base64
def chunked_decode(chunks):
out = []
leftover = b""
for chunk in chunks:
buffer = leftover + chunk
whole = len(buffer) // 4 * 4
if whole:
out.append(base64.b64decode(buffer[:whole]))
leftover = buffer[whole:]
if leftover:
out.append(base64.b64decode(leftover + b"=" * (-len(leftover) % 4)))
return b"".join(out)
喂给它一个套接字缓冲区、一个按 64 KB 分块读取的文件,或一个剥掉了换行符的行生成器,输出都与一次性解码整个东西完全相同。如果你的输入保证干净且未折行,就用 validate=True 解码每个完整组来保持严格,并记住最后的余数可能需要填充修复,这正是这个助手在最后解码之前补上它的原因。这是编码那一侧用的同一种接缝逻辑,只是接缝从三个字节换成了四个字符。
从命令行
base64 模块还能当一个小巧的命令行工具用,当载荷躺在你的终端里而不是代码里时,这很方便。默认是编码;-d(或它的孪生兄弟 -u)负责解码:
echo -n "hello world" | python3 -m base64
aGVsbG8gd29ybGQ=
echo -n "aGVsbG8gd29ybGQ=" | python3 -m base64 -d
hello world
你不给它文件时它从 stdin 读,给了就从你点名的文件读,而底层就是那个遗留的文件到文件接口,所以输出以 76 字符折行,每行带一个结尾换行。想在会话里粘贴载荷并把严格程度拉满时,解码器的单行版本是个好习惯:
import base64
import sys
print(base64.b64decode(sys.stdin.read(), validate=True))
九个容易翻车的坑
Python 里每一个 Base64 解码 bug 都是下面这些之一。把这张清单存在你恐慌时找得到的地方,因为它比你今年读到的任何其他单篇文档都救过更多的下午。前三个配了代码,因为见过残骸之后它们更好记:
缺失的填充。最常见的一次崩溃,通常是因为一个 JWT 分段或一个 API 值没有带着它的填充到达:
import base64
import binascii
segment = "Zm9vYmE"
try:
base64.urlsafe_b64decode(segment)
except binascii.Error as caught:
print(caught)
# Incorrect padding
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'
被截断的字符串。当错误说数据字符的数量"cannot be 1 more than a multiple of 4"时,载荷在途中被截断了,或是一次复制粘贴在末尾丢了字符。多少填充都救不了长度对四取余为一的字符串,数据就是不在那里,诚实的答案是再要一次载荷。
静默的垃圾。宽容模式会解码任何幸存的东西,而普通的英文单词里全是 Base64 字母表字符,所以载荷前面的一个落单单词会变成粘在你数据上的真实字节:
import base64
print(base64.b64decode("junkZm9vYmFy"))
# b'\x8e\xe9\xe4foobar' - 三个纯虚构的字节,然后才是真相
另外六个完全不需要代码:
- 你用标准解码器解码了一个 base64url 字符串。破折号和下划线不在标准字母表里,于是它们悄悄消失,载荷出来时被搅乱了,或者干脆是空的。用
urlsafe_b64decode加填充修复。 - 你忘了结果是字节。把它粘到字符串上会抛出
TypeError,把它推进 JSON 响应则会序列化出b'...'表示。在边界处调用.decode(encoding),刻意地,用你真正想要的那个编码。 - 你传入了非 ASCII 字符串。解码器接受字符串,但只接受 ASCII 的,其他一律
ValueError。如果你的载荷出自一个用错误编码读取的文本文件,修读取,别修解码。 - 你解码了两次。数据在上游已经被解码过了,或者它是 Base64 套 Base64,第二遍把你的密码变成了六个人类永远读不了的字节。
- 你对折行数据用了严格模式。一个换行就足以让
validate=True抛异常,所以 MIME 块和 PEM 正文属于宽容工具,不属于严格的那个。 - 你相信了一个中间的填充。在宽容模式下,字符串任何位置的
=都会被悄悄丢弃,所以一个填充放错位置的损坏载荷可以解码出"正确"的答案。只有严格模式会注意到,而它注意到的方式就是拒绝。
如果你的工作就是当看门人,这里有一个小助手,把两种脾气一起用起来:先严格,再填充修复,两者都不行就响亮地失败:
import base64
import binascii
def safe_decode(text):
candidate = text.strip()
try:
return base64.b64decode(candidate, validate=True)
except binascii.Error:
padded = candidate + "=" * (-len(candidate) % 4)
return base64.b64decode(padded, validate=True)
print(safe_decode("Zm9vYmE"))
# b'fooba'
print(safe_decode("Zm9vYmFy"))
# b'foobar'
注意这个助手依然信任你告诉它要信任的字母表。如果你的输入可能是 base64url,就改喂给 urlsafe_b64decode。校验是一份契约,而契约说明了数据用的是哪种方言。
一个安静模块的三十年
这个模块在标准库里住了四分之一世纪,大部分时间都纹丝不动。它偶尔挪动时,动作虽小却真实,并解释了老论坛里流传着的一些"在我机器上没问题"的故事:
- 1995 - Jack Jansen 重写
base64.py,把真正的活儿委托给 C 层的binascii模块。那条注释还在文件里,这种委托到今天依然成立。 - 2003,随 Python 2.4 发布 - Barry Warsaw 加入了完整的 RFC 3548 支持:
b16、b32和b64家族,加上你今天还在用的standard_*和urlsafe_*变体。 - Python 3.1 -
encodestring和decodestring被弃用,让位给encodebytes和decodebytes,也就是留下来的那两个名字。 - Python 3.3 - 解码函数开始接受 ASCII 字符串,结束了每一次解码都必须从 bytes 字面量开始的年代。
- Python 3.4 - 任何地方都接受 bytes 类对象(memoryview 在内),Base85 表亲
a85和b85也加入了模块。 - Python 3.9 - 被弃用许久的
encodestring和decodestring终于被移除。还调用它们的旧教程只需一个词的重命名。 - Python 3.10 -
b32hexencode和b32hexdecode带着扩展十六进制字母表到来,就是那个让编码数据保持字典序可排序的字母表。 - Python 3.11 -
binascii.a2b_base64获得了strict_mode,validate=True底层骑的就是它。 - Python 3.13 -
z85encode和z85decode把 ZeroMQ 的 Z85 方言带进标准库,古老的uu模块则在 PEP 594 之下被移除,附了一句毫不含糊的注记:改用base64。 - Python 3.14 -
b16decode快到了最多六倍:它的校验现在跑在bytes.translate上而不是正则表达式,模块也再不需要导入re。它的导入时间同样登上了改进模块的榜单。
这一切都不改变函数做什么,而这正是一个如此年长的模块的安静奢侈:2005 年解码 Base64 的代码,2026 年还在解码它,同一行,同样的结果。
边角料里的小乐趣
正经活儿干完了,来欣赏这个模块藏在边角里的小乐趣:
- 模块自己的文档做了十多年同一个演示:
b'data to be encoded'进去,b'ZGF0YSB0byBiZSBlbmNvZGVk'出来。如果你读过过去二十年里任何一版 Python 的 base64 页面,你都见过这对组合。 - 单词
junk是一个完全合法的 Base64 字符串。四个字母全在字母表里,所以载荷开头的落单单词会变成三个虚构字节而不是错误,宽容模式的绰号也由此而来。 urlsafe_b64decode意外地双语。它先转换自己的字母表,再宽容地解码,所以它也能读带着+和/的标准字母表字符串。一个函数,两种方言,零抱怨。- 错误信息是一套稳定的小词典,自从 C 实现以来就没挪过:
Incorrect padding、Only base64 data is allowed、Excess padding not allowed、Leading padding not allowed。学会它们,你不用跑一行代码就能给损坏的载荷做分诊。 - 空字符串是唯一得不到任何反应的输入:
b''进,b''出,两种脾气都一样。没有东西进来,没有东西出去,没有警报。 - 模块的 docstring 至今还写着 RFC 3548,也就是 2003 年版的规范。RFC 4648 从 2006 年起就是现行标准,模块忠实地跟着它,只是懒得更新那句话。
- Python 2 的解码侧没有类型墙:普通
str进去,普通str出来。Python 3 开发中 2007 年的 bytes 大改造改变了这一点,而大多数"为什么我的解码坏了"帖子至今指向的还是那些旧 Python 2 教程。
所以,整套哲学浓缩成四条规则。凡是你自己没有编码的东西,都传入 validate=True,把异常当作真实的答案,而不是建议。知道你手里拿的是哪种方言,标准、base64url 还是 MIME 折行,因为解码器不会告诉你,它只会通过丢掉不合身的东西来猜。在证明结果是文本之前,一直把它当字节,然后问一问字符集归谁所有。还要记住,这个函数最友好的特性,愿意解码那些并不完全是 Base64 的东西,正是让它危险的同一个特性,所以每一次调用都要决定,输入挣得了多少信任。
如果哪天你需要走反方向,把新鲜的字节重新包回那条友好的字母缎带,为了一个令牌、一个附件或一张内联图片,b64encode 的全部故事在本页底部那篇相关的 Base64 编码文章里讲得细细致致。两个方向互为镜像,但各有各的意外,而你现在已经把这一边背得滚瓜烂熟。祝解码愉快。
最后更新: 2026-09-08
相关文章: Python 中的 Base64 编码:完整指南