Decodificación Base64 en JavaScript/Node.js: una guía completa
Tu aplicación recibe una cadena Base64. Puede ser la cabecera Authorization de una petición entrante, un campo dentro de un payload JSON, una imagen escondida en una data URL, o un certificado pegado en un archivo de configuración. Todas son la misma cosa: bytes crudos con un disfraz de ASCII. Este artículo trata de quitarse ese disfraz en JavaScript y Node.js, y de hacerlo sin perder un solo byte por el camino.
Una palabra rápida sobre el propio formato: Base64 es una codificación de texto que mapea cada tres bytes de entrada a cuatro caracteres imprimibles. La página principal de este sitio explica el alfabeto, las matemáticas y el relleno con todo detalle, así que aquí se queda en una sola frase. Hay una consecuencia que conviene guardar en el bolsillo: los datos codificados son unos 33 por ciento más grandes que los bytes que transportan, lo que significa que decodificar es una operación que encoge, y nada de este artículo añade o quita un gramo de secreto. Estás desempaquetando, no rompiendo un sello.
La buena noticia: no instalas nada. Los navegadores traen atob() desde hace dos décadas, Node.js tiene la clase Buffer con un modo base64 integrado, y los runtimes modernos ya traen Uint8Array.fromBase64(), una incorporación estricta y configurable de la especificación ES2026. El oficio está en elegir la herramienta adecuada para el trabajo, y en saber exactamente qué perdona cada herramienta, porque en un servidor decodificas datos de desconocidos, y la indulgencia es donde las cosas se tuercen.
Elegir un decodificador
Tres APIs cubren la gran mayoría del trabajo de decodificación. Difieren en temperamento, y esa diferencia es toda la historia:
| Decodificador | Disponible en | Temperamento |
|---|---|---|
Buffer.from(string, 'base64') |
Node.js (toda versión que importe) | Tolerante: omite los caracteres desconocidos, se detiene en el primer = y nunca lanza |
atob(string) |
Todos los navegadores, Node.js 16 y superiores | Estricto: lanza InvalidCharacterError ante mala entrada, omite los espacios ASCII y perdona el relleno que falta |
Uint8Array.fromBase64(string) |
Chrome 140+, Firefox 133+, Safari 18.2+, Node.js 25+ | Configurable: tú eliges el alfabeto y qué tan estricto debe ser el último trozo |
Los tres abren el mismo payload clásico de la misma manera:
// El caballo de batalla de Node.js
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// El par legado (todos los navegadores, Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world", como una cadena binaria
// El método moderno ES2026 (Chrome 140+, Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"
Un aviso antes de apoyarte en atob(): devuelve una cadena, pero una cadena binaria, una cadena en la que cada carácter lleva un byte crudo como punto de código de 0 a 255. Imprimir una está bien. Almacenarla en JSON, una base de datos o una cookie envía esos valores de byte crudos de paseo, así que conviértela en bytes de verdad o en texto real inmediatamente después de decodificar.
El decodificador indulgente y lo que se traga
El Buffer de Node es un lector indulgente, y eso es una espada de doble filo. Es maravilloso para datos que viajaron por caminos difíciles: correo MIME con sus saltos de línea, cadenas copiadas a mano, salida de registros con espacios sueltos. Es peligroso para datos que no produjiste tú, porque nunca se queja. Esto es lo que pasa de verdad:
| Entrada | Qué hace Buffer.from(input, 'base64') |
|---|---|
'!!!' |
Devuelve un Buffer vacío. Toda la basura se omite, nada se decodifica, sin error. |
'aGVsbG8== garbage' |
Devuelve "hello". El primer = termina la decodificación; el resto se ignora. |
'aG!VsbG8' |
Devuelve "hello". El signo de exclamación se omite, no es un error. |
'aGVs=bG8' |
Devuelve "hel". Un = a mitad de cadena detiene el espectáculo antes de tiempo. |
'aGVsbG8====' |
Devuelve "hello". El relleno extra al final se ignora. |
'=aGVsbG8' |
Devuelve un Buffer vacío. El relleno antes de los datos no significa nada. |
La solución para entrada no confiable es un validador, y la gramática de Base64 es lo suficientemente pequeña para caber en una sola expresión regular:
const STRICT = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
function decodeStrict (base64) {
if (!STRICT.test(base64)) {
throw new TypeError('Not a valid base64 string');
}
return Buffer.from(base64, 'base64');
}
console.log(decodeStrict('aGVsbG8gd29ybGQ=').toString('utf8')); // "hello world"
try {
decodeStrict('aGVs!bG8');
} catch (error) {
console.log(error.message); // "Not a valid base64 string"
}
La expresión regular comprueba la forma: grupos de cuatro con el relleno correcto. Una regla que no puede comprobar es la regla de codificación canónica del RFC 4648, que dice que los bits de relleno sin usar del último grupo deben ser cero. El modo estricto de Uint8Array.fromBase64() sí la comprueba, así que en Node.js 25 o cualquier navegador moderno puedes saltarte la expresión regular por completo y dejar que la plataforma haga la auditoría:
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello", el modo loose perdona el relleno que falta
try {
Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError", los bits de relleno no son cero
}
La opción lastChunkHandling tiene tres ajustes que merecen la pena conocer. "loose" (el predeterminado) omite los espacios, acepta el relleno que falta e ignora los bits de relleno sobrantes. "strict" exige un último grupo completo y relleno, con todos los bits de relleno puestos a cero. Y "stop-before-partial" decodifica solo grupos completos de cuatro caracteres y deja el fragmento final para que lo lleves tú, que es la pieza que hace agradable la decodificación por streaming, como verás más adelante en este artículo.
De bytes a texto: la decisión del charset
Decodificar Base64 te entrega bytes. Los bytes se convierten en texto solo cuando eliges un charset, y esa elección es tuya, normalmente basada en lo que el emisor prometió. El predeterminado de Node es el que quieres la mayor parte de las veces:
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // los dos bytes C3 A9
console.log(bytes.toString('utf8')); // "é", los dos bytes se unen en un carácter
console.log(bytes.toString('latin1')); // "é", los mismos bytes leídos un carácter a la vez
Hay un pliegue con UTF-8: cuando una secuencia de bytes no es UTF-8 válido, Node no lanza. Sustituye el carácter de reemplazo de Unicode (U+FFFD, el rombo con signo de interrogación) y sigue adelante, lo que significa que un payload corrupto puede navegar a través de tu canalización hasta tu base de datos. El decodificador de texto de verdad de la plataforma, TextDecoder (un global en Node.js y en todos los navegadores), tiene una opción fatal que convierte la corrupción en un TypeError que puedes atrapar:
const stray = new Uint8Array([0xe9]); // un byte solitario, no es UTF-8 válido
console.log(new TextDecoder().decode(stray)); // el carácter de reemplazo, sin error
try {
new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
console.log(error.name); // "TypeError"
}
Los sistemas legacy nunca mueren, y TextDecoder aún sabe leerlos. Acepta la tabla completa de etiquetas del estándar Encoding de WHATWG, así que un payload Base64 de una app de Windows de los años 90, de un mainframe japonés o de un viejo espejo FTP aún puede decodificarse con etiquetas como 'windows-1250', 'shift_jis', 'euc-kr' o 'gb18030', todas insensibles a mayúsculas y minúsculas. Hay una etiqueta que merece una advertencia, porque ha costado tiempo real de depuración: la especificación hace un alias de 'iso-8859-1', 'latin1' e incluso 'us-ascii' al decodificador de Windows-1252. El byte 0x80, un carácter de control en el Latin-1 de verdad, sale como signo de euro:
console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€", no es el Latin-1 que pediste
// Para una lectura Latin-1 de verdad, byte a byte, usa el lado Buffer:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // el carácter de control crudo 0x80
Si de verdad necesitas ese mapeo crudo, la codificación 'latin1' de Buffer (cuyo alias legacy 'binary' es, según las palabras de la documentación de Node, un nombre muy engañoso) mapea el byte N al punto de código N sin el desvío por Windows. Para todo lo moderno, UTF-8 más fatal: true es el dúo seguro.
Hacer saltar un JWT
El payload Base64 más común que decodifica un servicio de JavaScript es un JSON Web Token, la cadena xxxxx.yyyyy.zzzzz que viaja en la cabecera Authorization de medio mundo. Según el RFC 7515, un JWS compacto son tres partes separadas por puntos, y las dos primeras son objetos JSON codificados con base64url sin relleno. Leerlos en Node.js no requiere ceremonia, porque el modo base64url es una codificación de primera:
const { Buffer } = require('node:buffer');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWRhIn0.JMjpmDdNzQZpTuUO1H33GJsj7nWhBu-qxkPD0GL2uaA';
const [head, body, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))); // { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))); // { sub: '123', name: 'Ada' }
Dicho y dicho muchas veces, pero vale la pena repetirlo: decodificar no es verificar. La cabecera y el payload van disfrazados, no cifrados, y cualquiera que tenga el token puede leer ambos. La parte que debes comprobar es la tercera, la firma. Para un token clásico HMAC-SHA256, toda la comprobación son unas líneas del módulo crypto integrado, y lo único sutil es comparar con timingSafeEqual para que un atacante no pueda medir tu comparación byte a byte:
const crypto = require('node:crypto');
const expected = crypto.createHmac('sha256', 'topsecret').update(head + '.' + body).digest();
const actual = Buffer.from(signature, 'base64url');
console.log(crypto.timingSafeEqual(expected, actual)); // true
console.log(crypto.timingSafeEqual(crypto.createHmac('sha256', 'wrong-secret').update(head + '.' + body).digest(), actual)); // false
En un servicio real normalmente no haces esto a mano. El paquete jose (cero dependencias, funciona en Node.js, navegadores y runtimes de borde) y el longevo paquete jsonwebtoken (Node.js) envuelven la danza, manejan las familias de algoritmos RSA y ECDSA, y aplican las claims exp, aud e iss. Sea cual sea la librería que elijas, la tubería Base64 de debajo son las mismas dos llamadas que acabas de ver.
HTTP: cabeceras, cadenas de consulta y cookies
Tres esquinas del cable están llenas de Base64. La más vieja es la autenticación HTTP Basic, definida en el RFC 7617: el cliente envía Authorization: Basic más el Base64 de user-id:password. En el servidor es un corte y una decodificación, con el pequeño detalle de protocolo de que solo el primer dos puntos separa el nombre de usuario de la contraseña, de modo que una contraseña puede contener legalmente más dos puntos, pero un nombre de usuario no:
const { Buffer } = require('node:buffer');
const header = 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==';
const credentials = Buffer.from(header.slice(6), 'base64').toString('utf8');
const [user, ...rest] = credentials.split(':');
console.log(user, rest.join(':')); // "Aladdin" "open sesame"
Y recuerda qué es en realidad la autenticación Basic: ofuscación, no seguridad. Las credenciales cruzan el cable con un disfraz, y por eso el esquema solo es aceptable sobre HTTPS. La segunda esquina es la cadena de consulta, y esconde la mina más traicionera del terreno Base64:
const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=", el signo más se convirtió en un espacio
Tu Base64 no se corrompió a sí mismo. La capa de URL lo hizo con educación, en nombre de las reglas de codificación de formularios, que tratan + como un espacio. Por eso mismo los tokens que viven en cadenas de consulta usan el alfabeto seguro para URLs, cubierto en la sección de abajo. La tercera esquina es la cookie: las cookies son solo ASCII, así que cualquier valor no ASCII guardado en una es casi seguro Base64, y el viejo patrón de meter un blob JSON en Base64 dentro de una cookie sigue vivo en un número sorprendente de sistemas de producción. La decodificación es la misma que ya conoces; solo valida la forma antes, porque una cookie es de esos sitios donde un usuario, o una extensión del navegador, puede pasarte basura.
Archivos, imágenes y data URLs
El sistema de archivos de Node habla Base64 directamente, así que un archivo entero puede cruzar una frontera JSON en una sola línea:
const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // el archivo, unos 33 por ciento más pesado
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);
El otro payload con forma de archivo es la data URL, la cadena data:image/png;base64,... que los front ends aman para las imágenes inline. La receta en cualquier runtime es la misma: cortar en la primera coma, analizar los metadatos de antes y decodificar el resto. Aquí está un PNG de un solo píxel volviendo a la vida:
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const comma = dataUrl.indexOf(',');
const meta = dataUrl.slice(5, comma);
const bytes = Buffer.from(dataUrl.slice(comma + 1), 'base64');
console.log(meta); // "image/png;base64"
console.log(bytes.subarray(0, 8).toString('hex')); // "89504e470d0a1a0a", la firma PNG
Comprobar la firma es un hábito barato. Los primeros ocho bytes de un PNG son siempre 89 50 4E 47 0D 0A 1A 0A, y un JPEG empieza con FF D8 FF. Si una "imagen base64" de un cliente no empieza con los bytes mágicos que prometió, ya lo sabes antes de hacer nada costoso con ella.
Base64 seguro para URLs: el alfabeto de los tokens
El Base64 clásico usa + y / como sus dos caracteres especiales (RFC 4648, sección 4), y los dos dan problemas en las URLs: + se convierte en un espacio durante la decodificación de formularios, y / es un separador de ruta. La variante segura para URLs y nombres de archivo de la sección 5, a la que todos llaman base64url, los cambia por - y _, y puede quitar el relleno = final por completo cuando la longitud se conoce por contexto. Esa es exactamente la combinación que necesitan los JWTs, los tokens de OAuth y los deep links, así que base64url es el alfabeto que más vas a encontrar en el mundo salvaje.
El Buffer de Node convierte todo en un no-evento. Tanto el modo de decodificación 'base64' como el 'base64url' aceptan los cuatro caracteres especiales y los mapean a los mismos valores, así que una parte de JWT, un token de OAuth y un blob de Base64 clásico se decodifican todos sin ninguna ceremonia de intercambio de caracteres:
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVs-bG8', 'base64').toString('hex')); // "68656cf9b1bc"
console.log(Buffer.from('aGVs+bG8', 'base64url').toString('hex')); // "68656cf9b1bc", exactamente los mismos seis bytes
La API de ES2026 es más exigente a propósito, y te da la misma flexibilidad con un mando explícito. La opción alphabet elige entre "base64" (el predeterminado, + y /) y "base64url" (- y _), y alimentar un carácter del alfabeto equivocado es un SyntaxError, no una decodificación silenciosa entre alfabetos:
console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
Uint8Array.fromBase64('aGVs-bG8'); // el alfabeto predeterminado es el clásico
} catch (error) {
console.log(error.name); // "SyntaxError", el guion no es un carácter clásico
}
En un navegador que aún no tiene los nuevos métodos, el desvío es un pequeño intercambio antes de pasarle la cadena a atob(), que solo conoce el alfabeto clásico. También tienes que restaurar el relleno si el emisor lo quitó, que es la norma en payloads de estilo token:
function decodeBase64Url (value) {
const classic = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = classic + '='.repeat((4 - (classic.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
console.log(new TextDecoder().decode(decodeBase64Url('aGVsbG8gd29ybGQ'))); // "hello world"
Base64 en el mundo salvaje: dónde se esconden los payloads
Base64 es el servicio postal de los bytes en el mundo JavaScript. Un tour por dónde aparece, con la receta de decodificación para cada parada:
- Campos de APIs JSON, el transportista más común por mucho: avatares, miniaturas, documentos generados y subidas llegan como cadenas Base64 dentro de JSON ordinario, porque JSON no tiene palabra para "estos son bytes". Decodifica el campo antes de hacer nada más con él.
- Variables de entorno y archivos de configuración: varios gestores de secretos, sistemas de CI y hasta la propia CLI de npm te entregan blobs Base64 (versiones antiguas de npm guardaban la credencial del registry en
.npmrccomo el Base64 deuser:password; el npm moderno escribe un bearer token crudo en_authToken). Decodifica una vez al arrancar, y mantén el texto plano en memoria solo el tiempo que lo necesites. - Kubernetes y herramientas de clúster: los secretos de k8s son famosos por estar codificados en Base64 en la API y en
etcd, y la documentación oficial no deja de repetir que es codificación, no cifrado. Tu código de decodificación debería tratar el resultado como un secreto, no como una prueba de seguridad. - Bases de datos: lo binario que se guarde en una columna JSON (Postgres
jsonb, documentos de MongoDB, Redis) es con frecuencia una cadena Base64. Decodifícalo en la ruta de lectura en un Buffer o un Uint8Array, y deja que la base de datos se quede solo de texto. - Correo: el Base64 de MIME con su salto de línea cada 76 caracteres es como cruzan SMTP los adjuntos y las cabeceras binarias, un protocolo que originalmente era solo de 7 bits. El decodificador de Node salta los saltos de línea por ti, así que el cuerpo entero se decodifica en una llamada sin limpieza.
- Pipelines de CI y CD: los sistemas de build y los inyectores de secretos pasan tokens como valores de entorno en Base64; decodifica en el guion del pipeline, y nunca escribas el valor decodificado en un log.
- Datos de directorios y SAML: los archivos LDIF guardan atributos binarios (piensa en: certificados) como Base64, y las respuestas SAML a menudo se comprimen con deflate y luego se codifican en Base64 antes de cruzar una frontera HTTP.
- Hilos de worker y runtimes de borde: las cadenas Base64 cruzan la frontera de
worker_threadscomo cadenas structured-cloneables de toda la vida, así que una decodificación pesada puede vivir en un worker mientras el event loop del hilo principal se queda libre.
Dos de esas paradas merecen una mirada más de cerca, porque aparecen tanto en entrevistas como en producción:
const { Buffer } = require('node:buffer');
// Variable de entorno: el secreto llega codificado en Base64
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// Campo de API JSON: desempaqueta antes de hacer nada más
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", otra vez la firma PNG
// Correo MIME: los saltos de línea se omiten, no hay que limpiar nada
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"
El anti-patrón a detectar en este tour es el mismo en todas partes: Base64 en un sitio donde ya se permitían bytes crudos. Un frame de WebSocket, un stream de archivo, una columna bytea de Postgres, todos llevan bytes de forma nativa, así que un viaje de ida y vuelta por Base64 ahí es sobrecarga pura, el impuesto del 33 por ciento de tamaño sin beneficio que mostrar por él. Cuando existe una ruta binaria nativa, tómala.
Decodificar a trozos: streams y datos grandes
Base64 agrupa tres bytes en cuatro caracteres, así que un stream de trozos puede partir un grupo por la mitad. El enfoque ingenuo, decodificar cada trozo y rezar, corrompe la salida en fronteras al azar. La API de ES2026 está diseñada exactamente para esto: setFromBase64() escribe en un array preasignado e informa de cuántos caracteres de entrada consumió, y el modo "stop-before-partial" lo hace detenerse en el último grupo completo, dejando el fragmento para el siguiente trozo. El patrón refleja la API de stream de TextDecoder:
const { Buffer } = require('node:buffer');
const chunks = ['aGVsbG8', 'gd29ybGQ='];
let leftover = '';
const parts = [];
for (const chunk of chunks) {
const pending = leftover + chunk;
const space = new Uint8Array(Math.ceil(pending.length * 3 / 4));
const { read, written } = space.setFromBase64(pending, { lastChunkHandling: 'stop-before-partial' });
parts.push(Buffer.from(space.buffer, space.byteOffset, written));
leftover = pending.slice(read);
}
parts.push(Buffer.from(Uint8Array.fromBase64(leftover)));
console.log(Buffer.concat(parts).toString('utf8')); // "hello world"
En runtimes sin los nuevos métodos (y la línea LTS de Node no los tuvo durante un buen rato), el mismo bucle funciona con un pequeño decodificador userland que lleva la cuenta de los grupos parciales, o simplemente acumulas los trozos entrantes hasta que puedes partir en fronteras de grupo. La idea importante es el arrastre: nunca decodifiques un fragmento por su cuenta.
Los payloads grandes ponen ante ti dos límites más. Primero, la cadena en sí: el buffer.constants.MAX_STRING_LENGTH de Node es de 536870888 caracteres, unos 512 MiB de texto, que se decodifican a unos 400 MB de bytes. Un "archivo base64" más grande que eso necesita un enfoque por streaming, no un readFileSync de una sola vez. Segundo, la memoria: la cadena codificada vive en el heap de JavaScript como UTF-16, dos bytes por carácter, y el Buffer decodificado es una segunda copia de los datos. Con payloads grandes sostienes ambos brevemente, así que mantén la forma codificada viva el menor tiempo que el código permita, y prefiere streams para lo que pese como archivo.
Desde la terminal
Node es también un decodificador Base64 de línea de comandos perfectamente decente, y viene bien cuando depuras una petición o inspeccionas un valor de configuración:
# Decodifica una cadena Base64 clásica pasada como argumento
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# La variante URL-safe, relleno opcional
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# Decodifica desde stdin, para eso están las tuberías
echo -n "aGVsbG8gd29ybGQ=" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(Buffer.from(d.trim(),"base64").toString("utf8")))'
Los tres imprimen hello world. Si la máquina tiene también el clásico comando base64 de coreutils, hace el mismo trabajo con base64 -d, pero las versiones de Node conocen base64url, que la herramienta tradicional no.
Trampas con acento JavaScript
Cada una de estas ha sido la tarde perdida de alguien en JavaScript o Node.js:
- El decodificador silencioso:
Buffer.from('!!!', 'base64')devuelve un Buffer vacío, no un error. Una entrada a medio corromper se decodifica a datos a medio corromper sin avisar. Valida la entrada no confiable con la expresión regular estricta (o el modo estricto defromBase64), y trata un Buffer vacío procedente de una cadena no vacía como bandera roja. - El argumento de codificación faltante:
Buffer.from('aGVsbG8=')sin segundo argumento no decodifica nada. Construye un Buffer a partir de los bytes UTF-8 de esas letras, así que tus datos "decodificados" son las propias letras, reempacadas como bytes. El argumento'base64'es todo el truco. - El disfraz de cadena binaria: la salida de
atob()no es texto hasta que tú dices que lo es. Meterla en una respuesta JSON, una cookie o una línea de log "funciona", y también preserva cada byte nulo, lo que sorprende a los gestores de logs y a los serializadores por igual. Conviértela en un Uint8Array concharCodeAt(), o en texto UTF-8, de inmediato. - El signo más en la cadena de consulta: un
+en un valor de consulta decodificado como formulario es un espacio para cuandoURLSearchParamste lo entrega. Prefiere base64url para lo que vive en una URL, y nunca pegues un token de Base64 clásico en una cadena de consulta sin escapar. - El carácter de reemplazo: el UTF-8 inválido se convierte en un rombo-con-interrogación silencioso en el modo UTF-8 de Buffer, no en un error, así que un payload corrupto puede pasar por tu canalización y acabar en una base de datos. Activa
fatal: trueconTextDecoderdonde la corrupción debería ser un fallo ruidoso. - El desvío por Windows: pedirle a
TextDecoder'iso-8859-1'o'latin1'te da el decodificador de Windows-1252, donde el byte 0x80 se convierte en signo de euro. Para un Latin-1 de verdad byte a byte, lee el Buffer contoString('latin1')en su lugar. Y recuerda que'binary'es solo un alias engañoso para el mismo mapeo Latin-1. - Los techos de tamaño:
buffer.constants.MAX_LENGTHes de 9007199254740991 bytes (2 elevado a 53, menos uno) en sistemas de 64 bits, pero la cadena que lleva el Base64 no puede crecer más allá deMAX_STRING_LENGTHde 536870888 caracteres. Una sola cadena puede por tanto llevar apenas unos 400 MB de datos decodificados; más allá de eso, por streaming. - La factura de memoria: una cadena Base64 cuesta dos bytes de heap por carácter (UTF-16), y el Buffer decodificado es una segunda copia completa. Un archivo de 100 MB pasa brevemente a ser unos 133 MB de cadena más 100 MB de Buffer en tu proceso. Encoge la ventana en la que la forma codificada sigue referenciada.
- El choque entre estricto en remoto e indulgente en local: tu decodificador de Node perdona lo que un decodificador estricto en otra parte rechaza (un guion de Python, un servicio en Go, una app móvil). Si un lado de tu sistema es estricto y el otro indulgente, el bug solo aparece con ciertas longitudes de payload, que es el peor tipo de bug. Acuerda la estrictez a nivel de protocolo, no en tu cabeza.
Cómo fueron creciendo los decodificadores de JavaScript
El lado de los navegadores tiene una historia larga, aburrida y fiable. atob() y btoa() se especificaron en el borrador de HTML5 a principios de 2011 (los navegadores los tenían antes que la especificación), y han estado en todos los navegadores importantes desde entonces, sin cambios de comportamiento durante más de una década. Anteceden a los typed arrays en el estándar del lenguaje (ES2015), por eso hablan en "cadenas binarias" en vez de en bytes.
Node.js desarrolló su decodificador en una línea de tiempo distinta. La clase Buffer se hizo global en la versión 0.1.103, en el verano de 2010, casi cinco años antes de Node 1.0, y arrastraba el modo 'base64' desde el principio. Para la mayor parte de la vida de Node, ese era el único decodificador del pueblo. Entonces llegó la ola de estándares web: Node 16 en 2021 añadió atob() y btoa() como globales para que el código escrito para el navegador funcionara en el servidor sin polyfill, y marcó ambos como Legacy desde el primer día. Node 25, lanzado el 15 de octubre de 2025, actualizó V8 a 14.1 y trajo los métodos de ES2026, Uint8Array.fromBase64(), setFromBase64() y sus hermanos hex, al runtime. Por el camino, el viejo constructor new Buffer() fue deprecado (Node 10 empezó con las advertencias en 2018) a favor de Buffer.from(), alloc() y allocUnsafe(), en parte porque una asignación sin inicializar podía filtrar la memoria que hubiera allí antes.
En los navegadores la misma ola llegó algo antes: Firefox 133 y Safari 18.2 lanzaron los nuevos métodos en 2024, y Chrome 140 (estable el 2 de septiembre de 2025) completó el set, momento en el que la característica fue declarada Baseline Newly available en el programa Baseline de los fabricantes de navegadores. Bun, el runtime JavaScript todo en uno, los consiguió en la versión 1.1.22 en agosto de 2024. Y si no puedes exigir un runtime reciente, core-js y el paquete es-arraybuffer-base64 del proyecto es-shims traen polyfills para todo ello, que es también la ruta que la mayoría de los frameworks toman internamente.
El formato que sirven tiene una genealogía aún más vieja. El alfabeto se estandarizó por primera vez para Privacy-Enhanced Mail en 1987 (RFC 989), la revisión de 1993 (RFC 1421) mantuvo el mismo alfabeto, y MIME lo adoptó en 1996 (RFC 2045), unos tres años después de esa revisión, con su salto de línea cada 76 caracteres; el RFC 3548 de 2003 consolidó base16, base32 y base64 en un único documento, y el RFC 4648 de 2006 lo reeditó, manteniendo el alfabeto seguro para URLs que el RFC 3548 había añadido - el mismo que, una década después, acabaría en cada JWT. La variante URL-safe es un bonito dato de sobremesa: se propuso en un post de 2001 en una lista de correo sobre identificadores peer-to-peer, antes de conocer jamás a un token.
Datos curiosos para tu próximo standup
- La clave de ejemplo del RFC de WebSocket,
dGhlIHNhbXBsZSBub25jZQ==, se decodifica en las palabras "the sample nonce". El comité de estándares escondió un guiño dentro de su propio ejemplo, y elatob()de Node descifra la broma en una llamada. Buffer.from('!!!', 'base64')devuelve un Buffer de longitud cero. Una asignación de verdad con nada dentro. Nada. Es lo más cerca que llega Node de una encogida de hombros.- Los decodificadores Base64 de Node son bilingües de una manera que la especificación nunca pidió:
+,-,/y_son todos bienvenidos en ambos modos,'base64'y'base64url', cada par mapeando al mismo valor. - La documentación de Node para
atob()contiene la frase "Use Buffer.from(data, 'base64') instead". Un runtime diciéndote que dejes de usar uno de sus propios globales, con un codemod oficial (npx codemod@latest @nodejs/buffer-atob-btoa) que hace la migración por ti. - Los Buffers pequeños se cortan de una losa compartida:
Buffer.poolSizees de 65536 bytes, y cada asignación pequeña reutiliza trozos de esa piscina. Por eso la creación de Buffer es rápida, y por qué "unsafe" en asignaciones es una frase cuyo significado deberías conocer. - El paquetito
base64-js, tres funciones y cero dependencias, arrastra más de 100 millones de descargas a la semana en npm, casi todas como dependencia oculta dentro de otros paquetes. Base64 es el código más contrabandeado del ecosistema. Uint8Array.fromBase64()tiene un modo llamado"stop-before-partial"que existe puramente para que puedas decodificar un stream sin partir jamás un grupo de cuatro caracteres. Un modo nombrado por la cosa que se niega a hacer es una pieza rara de poesía API.- El mundo de contraseñas de Unix usa sus propios alfabetos con sabor Base64, sin relleno, y, para confusión, no todos en el mismo orden. El alfabeto "hash64" clásico de
crypt(3)es./0-9A-Za-z, pero bcrypt baraja los mismos 64 caracteres y los pone en./A-Za-z0-9. Encontrarás la versión de bcrypt en los hashes$2b$que muchos proyectos JavaScript guardan para las contraseñas de usuario, y es la razón por la que "base64" en un contexto de seguridad puede significar varios alfabetos distintos, no solo dos.
Solo queda una dirección
Decodificar Base64 en JavaScript y Node.js es una pila de tres herramientas honestas: Buffer.from(string, 'base64'), el caballo de batalla indulgente que acepta ambos alfabetos y omite cada carácter extraviado, mejor custodiado por una expresión regular estricta; TextDecoder, para texto real en cualquier charset que la vieja web haya inventado, con el modo fatal cuando la corrupción debería doler; y el nuevo Uint8Array.fromBase64(), para código bytes-primero que quiere alfabetos estrictos, bits de relleno estrictos y streaming sin acrobacias. Fija el charset, valida lo que los desconocidos te envían, compara firmas con timingSafeEqual, y el formato deja de ser un misterio en ambos lados de la frontera navegador/servidor.
Y cuando termines de abrir paquetes, recuerda que alguien tuvo que sellarlos. El lado de la codificación tiene sus propias trampas: la muralla Unicode que frena btoa() a mitad de frase, el envolvido de líneas de MIME, las reglas de relleno de base64url, y el nuevo Uint8Array.toBase64() con su opción omitPadding. Esa historia, con ejemplos de código para cada paso, se cubre en profundidad en el artículo relacionado de codificación Base64 en nuestro sitio hermano. Léelo a continuación, porque las trampas son distintas y más graciosas en ese lado del alfabeto.
Última actualización: 2026-09-07
Artículo relacionado: Codificación Base64 en JavaScript/Node.js: una guía completa