Decodificación Base64 en Python: una guía completa
En algún lugar de tu código acaba de aterrizar una cadena de letras que no parece texto en absoluto: una larga ristra de A a la Z, algún que otro dígito, un + o un / de cuando en cuando, quizá un - o un _, y tal vez uno o dos signos = aparcados al final. Detrás de esa cadena puede esconderse el payload de un JWT que tu pasarela rechazó, una imagen oculta dentro de una página de HTML, un archivo que alguien te envió por correo como adjunto .b64, o un bloque de certificado en un ticket que ha pasado por tres mesas de ayuda. Tu trabajo es devolver los bytes originales, exactamente como estaban. Y Python está de maravilla para este trabajo, porque toda la caja de herramientas lleva décadas incluida en la biblioteca estándar: una línea de import base64 y estás listo en cualquier plataforma, sin nada que instalar y sin nada que configurar.
Un repaso rápido mientras te instalas, porque todo el mundo lo necesita una vez al año: Base64 reescribe cada tres bytes de datos como cuatro caracteres extraídos de un alfabeto de 64 caracteres, y cuando el grupo final de tres bytes está incompleto, el relleno = lo completa para que la salida salga siempre de cuatro en cuatro. Ese es todo el truco. No es compresión y no es secreto, solo una forma de dejar que los datos binarios sobrevivan por canales que no aceptan nada que no sea texto. La página de inicio de este sitio repasa el formato a fondo, incluido el alfabeto y la matemática del relleno, así que gastaremos nuestra energía donde duele de verdad: en la parte de Python del decodificado, y en mantener el resultado honesto.
Tres hechos dan forma a todo lo que sigue, y merecen ser memorizados antes de leer una línea más. Primero, el decodificador tiene dos estados de ánimo: un modo por defecto cortés y perdonavidas que descarta en silencio lo que no reconoce, y un modo estricto que rechaza esa entrada sin más. Segundo, el resultado de un decodificado es siempre un objeto bytes, nunca una cadena, y el momento en que quieres sacarle texto de verdad es una decisión que tienes que tomar a conciencia. Tercero, existen dos alfabetos que se parecen casi tanto como gemelos, el estándar y el seguro para URLs, y confundirlos es una de las formas favoritas de perder datos sin que aparezca ni un solo error. Esta guía te guía a través de los tres, para que la próxima vez que un muro de garabatos aterrice en tu terminal, puedas estar sonriendo en lugar de entrecerrando los ojos.
El Menú Completo de Decodificado
Abre el módulo base64 y encontrarás dos generaciones de interfaz sentadas una junto a otra. La moderna, centrada en b64decode, convierte objetos tipo bytes (y cadenas ASCII planos) de vuelta a bytes, y habla los dos dialectos de Base64 definidos en RFC 4648. La heredada es más vieja y orientada a archivos: trabaja con objetos de archivo, solo conoce el alfabeto estándar, y fue construida en torno a las líneas dobladas de 76 caracteres que RFC 2045, el estándar MIME de correo de 1996, exigía a la salida codificada. Encontrarás los nombres heredados en un montón de código con años de vida, así que aquí va la parte de decodificado del menú, completa:
| Función | Lo que Hace | Notas |
|---|---|---|
base64.b64decode(s, altchars=None, validate=False) |
el caballo de batalla: un trozo de Base64 de vuelta a bytes crudos | acepta bytes o una cadena ASCII, siempre devuelve bytes |
base64.standard_b64decode(s) |
el mismo trabajo, pero atado al alfabeto estándar | útil cuando conoces el dialecto a ciencia cierta |
base64.urlsafe_b64decode(s) |
lee el alfabeto seguro para URLs con - y _ |
el que lee JWTs |
base64.decodebytes(s) |
decodifica una o varias líneas dobladas de Base64 | añadido en Python 3.1, el camino amigable con MIME, tolerante |
base64.decode(input, output) |
transfiere un archivo de Base64 a un archivo crudo | heredado, lee línea por línea, tolerante |
base64.b32decode(s, casefold=False) |
decodifica el pariente menor, Base32 | casefold acepta entrada en minúsculas |
base64.b16decode(s, casefold=False) |
decodifica Base16, que es hexadecimal plano | hasta seis veces más rápido en Python 3.14 |
binascii.a2b_base64(s, strict_mode=False) |
la función a nivel C que hace el trabajo real | un control directo sobre la estrictez, con strict_mode desde Python 3.11 |
Todo lo de abajo se construye sobre la primera fila. Un hecho merece saberse antes de profundizar: en la documentación oficial el módulo vive bajo "Manipulación de datos de Internet", justo al lado de binascii, y esa ubicación no es casualidad. b64decode es un envoltorio fino que traduce el alfabeto (cuando pasas altchars) y luego deja que el binascii.a2b_base64 a nivel C haga el trabajo pesado. Por eso la función es rápida, y por eso sus mensajes de error tienen ese sabor nítido y sin sentimentalismo, propio de C.
El Caballo de Batalla: b64decode
Aquí va el contrato entero, corto para caberte en la cabeza. La función recibe un objeto tipo bytes o una cadena ASCII, un intercambio opcional de dos caracteres del alfabeto, y una bandera de validación. Devuelve un objeto bytes. En caso de fallo lanza binascii.Error, que es una subclase de ValueError por si alguna vez necesitas atrapar a toda una familia de excepciones de un golpe:
import base64
data = base64.b64decode("Zm9vYmFy")
print(data)
# b'foobar'
print(type(data))
# <class 'bytes'>
Esa última línea es la más importante de todo el artículo. El resultado son bytes, no una cadena, y Python te toma de la mano exactamente hasta donde debe: imprimir el objeto te muestra la representación b'...', e intentarle pegar una cadena lanza un TypeError. El momento en que quieres texto de verdad, la decisión es tuya, y la sección de charsets de más abajo cubre cuando esa decisión es fácil y cuando es una trampa.
El argumento opcional altchars intercambia el + y el / del alfabeto estándar por otro par de caracteres. Ese es precisamente el tornillo que produce el dialecto seguro para URLs, y así es como urlsafe_b64decode se construye encima de b64decode. Raramente recurrirás a altchars por tu cuenta, pero es bueno saber que la maquinaria está ahí. Para todo lo demás, la función simplemente hace el trabajo, rápido, en C.
Tolerante por Defecto, Estricto bajo Petición
Por defecto, b64decode es un olvidadizo educado. Cualquier carácter que no esté en el alfabeto de 64 caracteres (y no esté en tus altchars) se tira a la basura en silencio antes de que empiece el decodificado, y lo que sobrevive se decodifica. Sin advertencia, sin aviso, sin valor de retorno que comprobar, solo un resultado. Esa tolerancia tiene un ancestro noble: la sección 6.8 de RFC 2045 les dice a los decodificadores que "todos los saltos de línea u otros caracteres no encontrados en la Tabla 1 deben ser ignorados", porque SMTP históricamente ha doblado las líneas largas y espolvoreado caracteres sueltos por el camino. Un payload que ha cruzado un cliente de correo, una app de chat o una copia de un PDF a menudo se decodifica sin ninguna preparación, y eso es un auténtico superpoder.
La misma bondad es la razón por la que el decodificador por defecto es inútil como validador. La sección 12 de RFC 4648 deja el riesgo claro: ignorar caracteres no del alfabeto en lugar de rechazar toda la codificación abre un canal encubierto que se puede usar para filtrar información, y puede romper las comprobaciones de igualdad de cadenas, porque dos entradas diferentes pueden decodificarse a los mismos bytes. Para cualquier cosa que no hayas codificado tú, pasa validate=True y trata la excepción como la respuesta. Aquí va el parte de daños, cada fila reproducible en cualquier Python moderno:
| Lo que Entra | Tolerante (por defecto) | validate=True |
|---|---|---|
Zm9vYmFy (un payload limpio) |
b'foobar' |
b'foobar' |
Zm9v\r\nYmFy (salto de línea en medio) |
b'foobar' |
binascii.Error |
Zm9v YmFy (espacios de más) |
b'foobar' |
binascii.Error |
Zm9v!YmFy (un signo de exclamación perdido) |
b'foobar' |
binascii.Error |
junkZm9vYmFy (una palabra delante del payload) |
b'\x8e\xe9\xe4foobar' |
b'\x8e\xe9\xe4foobar' |
Zm9v=YmFy (un relleno en medio) |
b'foobar' |
binascii.Error |
=Zm9v (relleno al principio) |
b'foo' |
binascii.Error |
==== (cuatro rellenos, sin datos) |
b'' |
binascii.Error |
(entrada vacía) |
b'' |
b'' |
Mira cómo trabaja en silencio la columna tolerante. La fila que sorprende primero es la de la palabra al principio: las cuatro letras de junk resultan estar en el alfabeto de Base64, así que la "basura" se decodifica en tres bytes de verdad y se pega a tu payload sin que se le note nada. El modo estricto no es el salvador en esta fila, porque la entrada de verdad es Base64 válido; las otras filas son las que reciben un no rotundo, y los rechazos tienen una forma exacta: un binascii.Error cargando uno de un puñado de mensajes memorables:
Incorrect padding- la longitud no es múltiplo de cuatro tras descartar, o el grupo final es demasiado corto. Una cadena comoZm9vYmEsin ningún relleno termina aquí.Invalid base64-encoded string: number of data characters (N) cannot be 1 more than a multiple of 4- a la entrada le falta exactamente un carácter para el siguiente grupo. Esta es la huella dactilar clásica de un payload truncado o copiado y pegado.Only base64 data is allowed- un carácter no del alfabeto ha sobrevivido hasta el modo estricto, y un solo salto de línea ya cuenta como uno.Excess padding not allowed- rellenos en medio de la cadena, o más rellenos de los que permite el grupo final.Leading padding not allowed- la cadena empieza con=.- Y uno de otra familia:
ValueError: string argument should contain only ASCII characters, que te llevas cuando pasas una cadena con letras no ASCII. Se aceptan cadenas, pero solo las ASCII.
Detrás de las escenas, validate=True no es un camino de código separado en absoluto. El módulo reenvía la bandera a binascii.a2b_base64 como su parámetro strict_mode, la comprobación estricta que se añadió a binascii en Python 3.11. Eso te da un control directo cuando quieres estrictez sin pasar por la capa de base64:
import binascii
line = b"Zm9vYmFy"
print(binascii.a2b_base64(line, strict_mode=True))
# b'foobar'
Un capricho que conviene fijar antes de confiar a ciegas en el modo estricto: rechaza incluso un único salto de línea final, así que un bloque doblado MIME es un trabajo para el camino tolerante o para decodebytes, no para validate=True. Guarda el camino estricto para datos que esperas perfectamente limpios, como un token recién acuñado que sale directamente de tu propio código.
base64url, el Alfabeto que Cabe en las URLs
El alfabeto estándar tiene dos caracteres que a las URLs les dan miedo. El signo + se lee como espacio en cualquier decodificador de formularios, y el signo / está reservado para separadores de ruta. La sección 5 de RFC 4648 define el dialecto primo, donde + se convierte en - y / en _, y el relleno se descarta cuando la longitud de los datos se conoce por contexto. El RFC incluso le da a la variante un nombre propio, base64url, e insiste en que no se la llame solo "base64". Lo encontrarás sobre todo dentro de JSON Web Tokens, donde cada parte del token es base64url sin relleno, y también aparece en tokens OAuth y en parámetros de cursor de APIs.
Python trae una función dedicada para ello, urlsafe_b64decode. Traduce las rayas y los guiones bajos de vuelta a pluses y barras y luego decodifica, pero no volverá a rellenar por ti. La entrada sin relleno es el caso normal en JWTs, así que la línea aritmética va primero, y es la misma que usan por debajo bibliotecas como PyJWT:
import base64
segment = "Zm9vYmE"
padded = segment + "=" * (-len(segment) % 4)
print(base64.urlsafe_b64decode(padded))
# b'fooba'
La expresión "=" * (-len(segment) % 4) parece un truco, pero es todo el trabajo: produce cero, uno o dos rellenos, nunca tres, así que una cadena ya rellenada pasa sin que se le toque. El módulo negativo es lo que la hace funcionar para cadenas de cualquier longitud, y es la línea aritmética de Base64 que todo desarrollador de Python acaba tecleando al menos una vez.
Ahora el peligroso intercambio, porque los dos alfabetos se parecen lo suficiente como para confundirse. Pasa una cadena base64url por el decodificador estándar y las rayas y los guiones bajos simplemente no están en el alfabeto estándar, así que el decodificador tolerante se los traga y decodifica lo que queda. Para algunos payloads eso es una corriente de bytes destrozada; para otros, nada de nada:
import base64
tricky = base64.urlsafe_b64encode(b"\xfb\xff\xfe")
print(tricky)
# b'-__-'
print(base64.standard_b64decode(tricky))
# b'' - cada carácter se descartó en silencio
print(base64.urlsafe_b64decode(tricky))
# b'\xfb\xff\xfe'
La dirección contraria es perdonavidas, y por eso el intercambio pasa desapercibido: urlsafe_b64decode traduce primero su alfabeto y luego decodifica en modo tolerante, así que aceptará con gusto una cadena del alfabeto estándar con + y /. La lección no es improvisar. Es elegir una función por dialecto y ceñirse a ella, como harías con una moneda extranjera: gasta el yen donde el yen vale, no en la casa de cambio equivocada.
La salida es bytes: la conversación del charset
Aquí va la frase que resuelve la mitad de las preguntas de charset que la gente trae a Base64: b64decode decodifica bytes; no decodifica texto. No hay argumento de charset, no hay conversión, y nada de la entrada le dice a Python lo que los bytes deberían significar. El significado es algo que debes aportar desde el contexto, y ese contexto casi siempre es una de tres cosas: una cabecera que lo dice, un contrato de API que lo dice, o un número mágico escondido en los propios bytes.
import base64
raw = base64.b64decode("w6l0w6k=")
print(raw)
# b'\xc3\xa9t\xc3\xa9'
print(raw.decode("utf-8"))
# été
La misma idea con la etiqueta equivocada es un fallo estruendoso, y eso es una suerte. Los bytes que no son UTF-8 válido se niegan a convertirse en cadena, y la excepción te dice exactamente qué byte ofendió:
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
Tres reglas prácticas impiden que esta sección se convierta en una película de terror. Una: cuando los datos decodificados son JSON, no necesitas decodificar a mano en absoluto, porque json.loads acepta bytes directamente desde Python 3.6 y detecta UTF-8, UTF-16 y UTF-32 por su cuenta. Dos: el binario no es texto, así que un charset "detectado" para un PNG es una adivinanza con suerte, no un hecho; comprueba los bytes en lugar de la etiqueta. Tres: si el remitente te dijo el charset, cree al remitente, porque una cabecera de content-type o un documento de API mandan más que cualquier detector, cada vez sin excepción.
Dónde Aparece el Base64 Decodificado en el Código de Python
Con el tiempo empiezas a reconocer las formas. Aquí va la guía de campo de los sitios donde el Base64 decodificado aparece en una aplicación de Python, y la receta de una línea para cada uno. Las secciones siguientes dan el trato completo a los más comunes:
| Dónde Lo Encuentras | Lo Que Es | Cómo Leerlo |
|---|---|---|
| Un JWT | partes de cabecera, payload y firma (RFC 7519) | separar por el punto, urlsafe_b64decode con la corrección de relleno |
Una cabecera Authorization |
credenciales HTTP Basic, user:pass (RFC 7617) |
quitar el prefijo Basic, decodificar, separar en el primer signo de dos puntos |
Un URI data: |
medios en línea dentro de HTML o CSS (RFC 2397) | cortar en la primera coma, decodificar el resto |
| Un adjunto de correo | un cuerpo con Content-Transfer-Encoding: base64 (RFC 2045) |
get_payload(decode=True) sobre la parte del mensaje |
| Un valor de cabecera de correo | una palabra codificada =?charset?b?...?= (RFC 2047) |
deja que el paquete email lo decodifique por ti |
| Un archivo PEM | una clave o un certificado con armadura (RFC 7468) | quitar las líneas de armadura, decodificar el cuerpo a DER |
| Un campo de una API JSON | binario contrabandeado disfrazado de cadena | decodificar y tratar el resultado como bytes, no como texto |
| Una columna TEXT o una variable de entorno | binario o JSON guardado en un sitio solo de texto | decodificar y luego parsear o escribir, con el charset acordado |
Leer un JSON Web Token
Un JWT son tres pedazos base64url unidos por puntos: una cabecera, un payload y una firma. Los dos primeros son JSON plano, así que asomarse a ellos es una línea cada uno, usando la corrección de relleno de la sección de arriba:
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'}
Una nota de alcance, porque importa: inspeccionar un token así es una herramienta de depuración, no un mecanismo de autenticación. Que el payload sea legible no significa que sea genuino; un atacante puede forjar los dos primeros segmentos sin llegar a conocer tu secreto. Para una verificación de verdad, entrega el token a PyJWT (pip install pyjwt), que comprueba la firma y se niega a decodificar sin una lista explícita de algoritmos:
import jwt
# Una clave de menos de 32 bytes provoca la InsecureKeyLengthWarning de PyJWT (PyJWT 2.11+), una amonestación razonable para una clave de demostración.
decoded = jwt.decode(token, "super-secret-key", algorithms=["HS256"])
print(decoded)
# {'sub': '1234567890', 'name': 'John Doe'}
Con una clave equivocada recibes una excepción en lugar de un diccionario, que es exactamente el comportamiento que quieres en código de producción. Y si el token llegó con una marca de tiempo caducada, PyJWT lanza por eso también, así que nunca tienes que acordarte tú de los nombres de las claims.
Abrir un Data URI
Los data URIs incrustan medios directamente dentro de HTML o CSS para que el navegador no dispare una segunda petición: data:, el tipo de medio, la palabra base64, una coma, y los bytes codificados. El corte es en la primera coma, y ya, y todo lo que sigue es un payload plano del alfabeto estándar:
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'
La firma PNG de ocho bytes al principio del resultado es una comprobación barata y simpática de que decodificaste lo correcto. Dos trampas merecen mención. Si el URI vino de una página raspada o de un mensaje de chat, quita primero las entidades HTML y los espacios en blanco sueltos, porque el decodificador tolerante perdonará mucha basura y te entregará una imagen corrupta en lugar de un error. Y si estás decodificando entrada no fiable en masa, pasa validate=True: un data URI que falla la validación estricta es un data URI que nunca fue bien formado, y no quieres escribirlo a disco por intuición.
Abrir la cabecera Authorization
La autenticación Basic (RFC 7617) es el esquema más antiguo de HTTP, y todavía sostiene un número sorprendente de integraciones de APIs, webhooks y pipelines de CI. El cliente envía sus credenciales como user:pass, codificado en base64, detrás de la palabra 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
Fíjate en el partition, porque es el detalle que te salvará más adelante: la contraseña puede contener signos de dos puntos, el identificador de usuario no, y solo el primer signo de dos puntos es el separador. Una nota sincera, porque el propio RFC es claro al respecto: base64 no es cifrado. RFC 4648 dice que la codificación base "oculta visualmente información que de otro modo se reconocería fácilmente, como contraseñas, pero no proporciona ninguna confidencialidad computacional". Una cabecera Basic puede ser decodificada por cualquiera que vea el tráfico, así que trátala como una comodidad para conexiones protegidas con TLS, no como una frontera de seguridad. Cuando eres tú quien envía la cabecera, requests la construye por ti con auth=("jane", "pa:ss"), que vale la pena usar siempre que la biblioteca ya esté en tu stack.
El Correo, el Cliente Original
Base64 se estandarizó en 1993 para exactamente un trabajo: hacer que el binario sobreviviera al correo. RFC 2045, el estándar MIME, definió la codificación de cuerpo Content-Transfer-Encoding: base64, y sigue siendo la forma por defecto en que los adjuntos viajan por internet. El paquete email de Python hace el trabajo entero por ti: parsea las cabeceras, decodifica las palabras codificadas =?utf-8?b?...?= que RFC 2047 esconde en campos de cabecera, y decodifica en base64 los cuerpos cuando se lo pides:
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'
La llamada get_payload(decode=True) lee la cabecera Content-Transfer-Encoding y decodifica en base64 el cuerpo por ti, desarrollando las líneas de 76 caracteres por el camino. El argumento policy=policy.default selecciona la interfaz moderna de Python 3.6 en adelante, cuando la nueva API de correo basada en políticas dejó de ser provisional, lo que te da valores de cabecera decodificados de fábrica; el parser heredado sigue funcionando, pero terminas decodificando las palabras codificadas a mano. Solo bajas a decodebytes cuando estás parseando un fragmento suelto que no es un mensaje completo, como un bloque que alguien pegó en un ticket. Para mensajes multipart, itera con iter_attachments() y da a cada parte el mismo trato de una línea.
Armadura PEM y el Paquete cryptography
Un archivo PEM es una línea de cabecera, algo de Base64 con saltos de línea, y una línea de pie, y nada más. La armadura es decorativa; el Base64 es toda la historia, porque se decodifica a la estructura DER cruda de debajo. El paquete cryptography (pip install cryptography) puede cargar el resultado directamente, y por eso es la herramienta estándar para cualquier cosa que involucre certificados y claves:
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
En la mayoría del código de producción nunca haces la armadura-y-decodificado a mano: load_pem_x509_certificate acepta los bytes con armadura y gestiona el paso de Base64 por debajo. El camino manual se gana la vida cuando los bytes DER ya están en tus manos (una columna de base de datos, un archivo de configuración, un búfer de bytes de un protocolo), o cuando el bloque llegó envuelto en una cadena y quieres ver lo que hay dentro antes de fiarte de él. Las claves funcionan igual, con load_der_private_key esperando al otro lado del mismo decodificado.
Archivos, Números Mágicos y el Hábito .b64
Decodificar es solo la mitad del trabajo; los bytes suelen querer un archivo. El patrón es leer, decodificar, comprobar, escribir, y la comprobación importa porque un payload roto produciría, de lo contrario, un archivo erróneo en silencio que descubrirías semanas después:
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)
Para conversiones rápidas de una sola vez, la función heredada de archivo a archivo hace el viaje entero en una sola llamada, líneas dobladas y todo:
import base64
with open("photo.b64", "rb") as src, open("photo.png", "wb") as dst:
base64.decode(src, dst)
¿Qué acabas de decodificar, al fin y al cabo? Los primeros bytes de casi todos los formatos comunes son una firma fija, y como Base64 es determinista, la firma codificada también es fija. Ver uno de estos prefijos es como reconocer una matrícula a distancia:
| El Base64 Comienza Con | Probablemente Es |
|---|---|
iVBORw0KGgo |
una imagen PNG |
/9j/ |
una imagen JPEG |
R0lGODlh |
una imagen GIF |
JVBERi0 |
un documento PDF |
UEsDBA== |
un archivo ZIP |
UklGRg== |
un contenedor RIFF (WAV, WEBP, AVI) |
LS0tLS1CRUdJTg== |
un bloque con armadura ASCII ("-----BEGIN ...") |
Y haz la matemática de tamaño mientras el archivo se escribe, porque es el número que sorprende a la gente cuando el disco se llena: codificar hincha los datos unos tercios, así que un archivo de 300 KB viaja como unos 400 KB de texto Base64, y el archivo que decodificas de vuelta es el más pequeño, el original. Tu disco, y tu memoria si lees el archivo entero de una vez, deberían presupuestar la diferencia.
Bases de Datos, Archivos de Configuración y Variables de Entorno
Base64 es un favorito para contrabandear binario (o JSON) por almacenamientos que solo aceptan texto: una columna TEXT, un valor en un archivo .ini, una variable de entorno en un pipeline de despliegue. La receta de decodificado es la misma que para archivos, menos el disco:
import base64
import json
stored = "eyJyb2xlIjogImFkbWluIiwicHJvamVjdCI6Im15c2l0ZSJ9"
payload = json.loads(base64.b64decode(stored))
print(payload)
# {'role': 'admin', 'project': 'mysite'}
Dos notas para esta esquina de la casa. Cuando el valor almacenado es JSON, salta el paso intermedio de .decode("utf-8") y deja que json.loads tome los bytes directamente, ya que lo hace desde Python 3.6. Y una advertencia sincera, porque aquí vive el malentendido más caro de todo el artículo: Base64 en una variable de entorno o en un archivo de configuración es un escudo contra el humano que echa un vistazo al archivo, no contra el que lo lee. Si el valor es de verdad sensible, cifralo primero (el paquete cryptography trae Fernet exactamente para esto) y solo entonces codifica en Base64 el texto cifrado si tu almacenamiento exige texto.
Cuando el Payload Llega en Trozos
La biblioteca estándar no tiene ningún decodificador Base64 incremental: no existe un par de actualizar-y-terminar, así que los datos en flujo necesitan un poco de contabilidad propia. La aritmética es simple y estricta a la vez. Cuatro caracteres codificados hacen tres bytes, así que solo puedes decodificar grupos completos de cuatro caracteres, y debes llevar el resto al siguiente trozo:
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)
Aliméntalo con un búfer de socket, un archivo leído en trozos de 64 KB, o un generador de líneas con los saltos de línea retirados, y la salida es idéntica a decodificarlo todo de una vez. Si tu entrada está garantizada limpia y sin doblar, conserva la estrictez decodificando cada grupo completo con validate=True, y recuerda que el resto final puede necesitar la corrección de relleno, por eso el auxiliar la añade antes del último decodificado. Es la misma lógica de costura que usan los codificadores del otro lado, solo que con cuatro caracteres en lugar de tres bytes.
Desde la Línea de Comandos
El módulo base64 hace doble vida como una diminuta herramienta de línea de comandos, que viene bien cuando el payload está sentado en tu terminal en lugar de en tu código. Codificar es el comportamiento por defecto; -d (o su gemelo, -u) decodifica:
echo -n "hello world" | python3 -m base64
aGVsbG8gd29ybGQ=
echo -n "aGVsbG8gd29ybGQ=" | python3 -m base64 -d
hello world
Lee de stdin cuando no le das ningún archivo, o del archivo que nombres, y por debajo es la interfaz heredada de archivo a archivo, así que la salida llega doblada a 76 caracteres con un salto de línea al final de cada línea. Para pegar un payload en una sesión con la estrictez a tope, la versión de una línea del decodificador es un buen hábito:
import base64
import sys
print(base64.b64decode(sys.stdin.read(), validate=True))
Nueve Maneras de Quemarte
Cada bug de decodificado Base64 en Python es uno de estos. Guarda la lista en algún sitio donde la encuentres en pleno pánico, porque ha atrapado más tardes que cualquier otro documento suelto que leas este año. Los tres primeros vienen con código, porque son más fáciles de recordar una vez que has visto la ruina:
El relleno que falta. El fallo más común de todos, normalmente porque una parte de JWT o un valor de API llegó sin sus rellenos:
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'
La cadena truncada. Cuando el error dice que el número de caracteres de datos "cannot be 1 more than a multiple of 4", el payload fue cortado en tránsito, o un copiar-pegar soltó un carácter al final. Ninguna cantidad de relleno arregla una cadena cuya longitud es uno módulo cuatro; los datos simplemente no están ahí, y la respuesta honesta es pedir el payload de nuevo.
La basura en silencio. El modo tolerante decodifica lo que sobrevive, y las palabras ordinarias en inglés están llenas de letras del alfabeto Base64, así que una palabra perdida delante del payload se convierte en bytes de verdad pegados a tus datos:
import base64
print(base64.b64decode("junkZm9vYmFy"))
# b'\x8e\xe9\xe4foobar' - tres bytes de pura ficción, y luego la verdad
Las otras seis no necesitan código alguno:
- Decodificaste una cadena base64url con el decodificador estándar. Las rayas y los guiones bajos no están en el alfabeto estándar, así que desaparecieron en silencio y el payload salió destrozado, o vacío. Usa
urlsafe_b64decodecon la corrección de relleno. - Olvidaste que el resultado son bytes. Pegarlos a una cadena lanza un
TypeError, y meterlos en una respuesta JSON serializa la representaciónb'...'. Llama a.decode(encoding)en el límite, a conciencia, con la codificación que de verdad quieres. - Pasaste una cadena no ASCII. El decodificador acepta cadenas, pero solo las ASCII; lo demás es un
ValueError. Si tu payload salió de un archivo de texto leído con la codificación equivocada, arregla la lectura, no el decodificado. - Decodificaste dos veces. Los datos ya estaban decodificados aguas arriba, o era Base64 de Base64, y la segunda pasada convirtió tu contraseña en seis bytes que ningún humano volverá a leer jamás.
- Usaste el modo estricto con datos doblados. Un solo salto de línea basta para que
validate=Truelance, así que los bloques MIME y los cuerpos PEM pertenecen a las herramientas tolerantes, no a la estricta. - Confiaste en un relleno en medio. En modo tolerante un
=en cualquier parte de la cadena se descarta en silencio, así que un payload corrupto con un relleno mal puesto puede decodificarse a la respuesta "correcta". Solo el modo estricto se da cuenta, y se da cuenta negándose.
Si tu trabajo es ser el guardián, aquí va un pequeño auxiliar que pone los dos estados de ánimo a trabajar juntos: estricto primero, corrección de relleno segunda, y un fallo estruendoso cuando ninguno sirve:
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'
Fíjate en que el auxiliar sigue confiando en el alfabeto que le dicen que confíe. Si tu entrada podría ser base64url, aliméntalo con urlsafe_b64decode en su lugar. La validación es un contrato, y el contrato dice en qué dialecto están los datos.
Tres Décadas de un Módulo Tranquilo
El módulo lleva un cuarto de siglo en la biblioteca estándar, y la mayor parte del tiempo se quedó quieto. Cuando se movió, los movimientos fueron pequeños pero reales, y explican unas cuantas historias de "funciona en mi máquina" que flotan por viejos foros:
- 1995 - Jack Jansen reescribió
base64.pypara delegar el trabajo real al módulobinasciia nivel C. El comentario sigue en el archivo, y la delegación sigue siendo cierta hoy. - 2003, publicado con Python 2.4 - Barry Warsaw añadió el soporte completo de RFC 3548: las familias
b16,b32yb64, más las variantesstandard_*yurlsafe_*que usas hoy. - Python 3.1 -
encodestringydecodestringquedaron deprecados en favor deencodebytesydecodebytes, los nombres que se quedaron. - Python 3.3 - las funciones de decodificado empezaron a aceptar cadenas ASCII, acabando con la era en que cada decodificado empezaba con un literal de bytes.
- Python 3.4 - cualquier objeto tipo bytes (incluidos los memoryview) es aceptado en todas partes, y los primos Base85,
a85yb85, se unieron al módulo. - Python 3.9 - los de largo tiempo deprecados
encodestringydecodestringfueron por fin eliminados. Los viejos tutoriales que los llaman necesitan un cambio de nombre de una palabra. - Python 3.10 -
b32hexencodeyb32hexdecodellegaron con el alfabeto hexadecimal extendido, el que mantiene los datos codificados ordenables lexicográficamente. - Python 3.11 -
binascii.a2b_base64ganóstrict_mode, en lo quevalidate=Truese apoya por debajo. - Python 3.13 -
z85encodeyz85decodetrajeron el dialecto Z85 de ZeroMQ a la biblioteca estándar, y el antiguo módulouufue eliminado bajo PEP 594 con una nota puntual para usarbase64en su lugar. - Python 3.14 -
b16decodese hizo hasta seis veces más rápido: su validación ahora corre sobrebytes.translateen lugar de una expresión regular, y el módulo ya no importareen absoluto. Su tiempo de importación también aterrizó en la lista de módulos mejorados.
Nada de esto cambia lo que las funciones hacen, y ese es el lujo tranquilo de un módulo tan viejo: código que decodificaba Base64 en 2005 todavía lo decodifica en 2026, en la misma línea, con el mismo resultado.
Delicias de los Márgenes
El trabajo serio está hecho, así que aquí van las pequeñas delicias que el módulo esconde en sus márgenes:
- La propia documentación del módulo lleva más de una década con la misma demostración: entra
b'data to be encoded', saleb'ZGF0YSB0byBiZSBlbmNvZGVk'. Si has leído la página de base64 de cualquier versión de Python en los últimos veinte años, ya has conocido a este par. - La palabra
junkes una cadena Base64 perfectamente válida. Sus cuatro letras están en el alfabeto, por eso una palabra perdida al inicio de un payload se convierte en tres bytes de ficción en lugar de un error, y por eso el modo tolerante se gana su apodo. urlsafe_b64decodees bilingüe por accidente. Traduce primero su alfabeto y luego decodifica en modo tolerante, así que también leerá una cadena del alfabeto estándar con+y/. Una función, dos dialectos, cero quejas.- Los mensajes de error son un mini-lexicón estable que no se ha movido desde la implementación en C:
Incorrect padding,Only base64 data is allowed,Excess padding not allowed,Leading padding not allowed. Apréndelos y podrás hacer triaje de un payload roto sin ejecutar una sola línea de código. - La cadena vacía es la única entrada que no provoca reacción alguna: entra
b'', saleb'', en los dos estados de ánimo. Nada entra, nada sale, sin alarma. - La docstring del módulo sigue citando RFC 3548, la edición de 2003 de la especificación. RFC 4648 es el estándar vigente desde 2006, y el módulo lo sigue fielmente sin molestarse en actualizar la frase.
- Python 2 no tenía ningún muro de tipos en el lado del decodificado: entraba un
strplano, salía unstrplano. La gran reforma de bytes de 2007 en el desarrollo de Python 3 cambió eso, y los viejos tutoriales de Python 2 son donde la mayoría de los hilos de "por qué se me rompe el decodificado" siguen señalando.
Así que aquí va toda la filosofía en cuatro reglas. Pasa validate=True para cualquier cosa que no hayas codificado tú, y trata la excepción como una respuesta de verdad, no como una sugerencia. Saborea qué dialecto tienes en la mano, estándar, base64url o doblado MIME, porque el decodificador no te lo dirá; solo adivinará soltando lo que no encaja. Trata el resultado como bytes hasta que demuestres que es texto, y entonces pregunta quién poseía el charset. Y recuerda que la característica más amigable de esta función, la disposición a decodificar cosas que no son del todo Base64, es la misma que la hace peligrosa, así que decide, en cada llamada, cuánta confianza se ha ganado la entrada.
Si en algún momento necesitas ir en la otra dirección, envolver bytes frescos de nuevo en ese cinto amigable de letras para un token, un adjunto o una imagen en línea, toda la historia de b64encode está cubierta en detalle en el artículo relacionado de codificación Base64 al final de esta página. Las dos direcciones son imágenes en espejo, pero cada una tiene su propio juego de sorpresas, y ahora te sabes esta de memoria. Feliz decodificado.
Última actualización: 2026-09-08
Artículo relacionado: Codificación Base64 en Python: una guía completa