¿Tiene que ocuparse del formato Base64? Entonces esta página es perfecta para Ud. Utilice nuestra práctica herramienta en línea para codificar o decodificar sus datos.

Codificación Base64 en JavaScript/Node.js: una guía completa

Tienes datos que necesitan convertirse en texto. Un archivo que debe viajar dentro de un campo JSON, una imagen que quiere vivir en un archivo CSS, un secreto que va a quedarse en una variable de entorno, un token que va a cruzar una cadena de consulta. La respuesta en JavaScript y Node.js es casi siempre la misma: Base64. Este artículo es el manual de embalaje, desde el primer byte que sostienes hasta el momento en que tu cadena codificada sale de la máquina.

La página principal de este sitio explica el formato con todo detalle, el alfabeto, las matemáticas, el relleno, así que aquí va en una sola frase: cada tres bytes se convierten en cuatro caracteres imprimibles, por eso tu salida será unos 33 por ciento más grande que la entrada. Guárdalo en el bolsillo, porque es la razón de ser de cada sección de este artículo, y es el número en el que se calcula tu factura de almacenamiento.

Lo reconfortante: no instalas nada. Cada navegador moderno trae btoa() y el nuevo Uint8Array.toBase64(), y cada versión de Node.js que importa lleva la clase Buffer con un modo 'base64' y, desde la versión 15.7.0, un modo 'base64url' de primera. El arte está en saber qué forma de entrada tienes en las manos, qué alfabeto exige el destino, y qué reglas de salto de línea siguen imponiendo los formatos viejos.

Conoce tu entrada antes de codificarla

Toda pregunta de codificación empieza por la misma: ¿qué tienes exactamente en las manos? Una cadena de JavaScript es texto UTF-16, un Buffer es un array de bytes, y la llamada correcta depende de cuál de los tres tienes:

Lo que sostienes Llama a esto Notas
Una cadena solo ASCII (caracteres menores de 256) btoa(string) La vía más rápida en navegadores y Node.js 16+, pero se detiene en el primer carácter que no cabe en un byte
Cualquier cadena Unicode TextEncoder a bytes, y luego una llamada base64 El puente UTF-8; la única vía segura para letras acentuadas y emoji
Un Buffer o un Uint8Array buffer.toString('base64') o bytes.toBase64() El caballo de batalla de Node.js, y el método de ES2026 en navegadores modernos y Node.js 25+

Tres ejemplos, uno por cada fila de la tabla:

// Texto solo ASCII: el atajo legado (navegadores y Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Cualquier texto en Node.js: Buffer lee UTF-8 por defecto
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// Bytes que ya tienes
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (runtimes ES2026)

Fíjate en el segundo ejemplo: el mismo texto produce una cadena Base64 distinta según el charset en el que lo codifiques. Eso no es un bug - es todo el juego. La capa Base64 codifica bytes, y una cadena se convierte en bytes solo cuando eliges un charset, así que "codifica este texto" significa siempre a escondidas "codifica los bytes UTF-8 de este texto" (o los bytes Latin-1, si tú lo dices).

La muralla Unicode y los puentes sobre ella

btoa() es la API más vieja de la sala, y su contrato es de los años 90: cada carácter de la cadena de entrada debe caber en un solo byte, puntos de código entre 0 y 255. Cualquier cosa por encima de eso, un emoji, una letra cirílica acentuada, un carácter chino, lanza:

try {
  btoa('héllo ⛳');
} catch (error) {
  console.log(error.name); // "InvalidCharacterError"
  console.log(error.message); // "Invalid character" en Node; la redacción de rango Latin1 en navegadores
}

La solución es dejar de pensar en caracteres y empezar a pensar en bytes. TextEncoder (un global en todos los navegadores y en Node.js) convierte la cadena en su secuencia de bytes UTF-8; levantas esos bytes a una cadena Latin-1, y btoa() recibe exactamente lo que prometió manejar:

function encodeUnicode (text) {
  const bytes = new TextEncoder().encode(text);
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true, los mismos bytes

En los codebases también te encontrarás el idiomatismo más viejo, y por dentro funciona igual: btoa(unescape(encodeURIComponent(text))). La llamada a encodeURIComponent produce bytes UTF-8 percent-codificados, y unescape convierte los escapes percent de vuelta en caracteres crudos. Tanto escape como unescape son funciones legacy, así que el código nuevo debería preferir el puente TextEncoder, pero si heredas la forma vieja, ahora sabes exactamente qué está haciendo en vez de encoger los hombros.

En Node.js la muralla es casi un no-problema, porque Buffer.from(text) asume UTF-8 y hace la conversión a bytes por ti en la misma llamada. El puente importa sobre todo en el navegador, donde btoa() es la opción legacy y el paso UTF-8 te toca hacerlo explícitamente.

Bytes adentro, letras afuera: Buffers, relleno y sabores

Ya teniendo los bytes, el lado de la codificación en Node.js es un solo método: toString('base64'). Maneja las matemáticas de los grupos, el relleno, todo, y siempre produce salida canónica en el sentido del RFC 4648, es decir, los bits de relleno sin usar del último grupo son cero:

const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 bytes
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60 caracteres, el impuesto del 33 por ciento en acción

El relleno final está haciendo trabajo de verdad, no es decoración. Un último grupo con un byte sobrante se convierte en dos caracteres Base64 más dos =, y un grupo con dos bytes sobrantes en tres caracteres más un =. Si tu salida puede llevar ese relleno depende del destino, y esa es la diferencia entre los dos sabores de Base64 que usarás a diario:

const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2026, relleno incluido)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", Node descarta el relleno en modo base64url

Recuerda la proporción cuando calcules tamaños: tres bytes entran, cuatro caracteres salen, así que 1 MB de datos se convierten en unos 1.33 MB de texto, y si envuelves el texto en líneas para correo o PEM, los saltos de línea añaden unos porcentajes más encima.

Hacer cruzar el cable a los bytes

El problema más común del cable en JavaScript es que JSON no tiene bytes. Lo que tiene son cadenas, y la cadena que puede viajar con seguridad por cualquier parser JSON, cualquier proxy HTTP y cualquier sistema de registros es la de Base64. El patrón es el mismo en ambos extremos de la conexión: codificar en la frontera, decodificar en la frontera, y sostener los bytes en el medio:

const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
  name: 'photo.jpg',
  contentType: 'image/jpeg',
  data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true, el archivo ahora viaja dentro de un JSON ordinario

Saber cuándo luchar contra este patrón. Si tu transporte ya soporta binario, úsalo: una subida multipart/form-data envía el archivo crudo sin impuesto de tamaño, un frame de WebSocket lleva bytes crudos, y una columna bytea de Postgres los guarda de forma nativa. Base64 en un sitio donde los bytes crudos estaban permitidos es sobrecarga pura, el impuesto del 33 por ciento sin nada que mostrar por él. Base64 se gana el sueldo cuando el canal es solo de texto: APIs JSON, cuerpos de correo, variables de entorno, cadenas de consulta de URL, y los muchos puentes (SDKs móviles, apps de escritorio, sistemas de chat) que solo dejan pasar texto.

Data URLs: imágenes que viven en texto

La data URL, la cadena data:image/png;base64,..., es Base64 con una etiqueta MIME, y es la razón por la que puedes meter una imagen entera dentro de un único atributo HTML. El RFC de 1998 que definió el esquema dice incluso que es "only useful for short values", porque el HTML temprano tenía un límite de 1024 caracteres para los valores de atributo. Los navegadores modernos se ríen de ese límite y renderizan encantados data URLs de megabytes, lo cual es a la vez un superpoder y una trampa.

En el navegador, la API de canvas hace todo el trabajo por ti, píxeles adentro, data URL afuera:

const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 24)); // "data:image/png;base64,iV"

Y en cualquier runtime, incluido Node.js, construirla es solo concatenación de cadenas con los metadatos en el sitio correcto: un prefijo data:, el tipo de medio, el marcador opcional ;base64, una coma, y el payload. Sin el marcador ;base64 se espera que el payload sea texto percent-codificado en su lugar, y esa es la razón de que exista el marcador:

const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true

Las contrapartidas honestas: una data URL es parte del documento, así que no se puede cachear como recurso propio, cuenta contra el tamaño del HTML o CSS donde vive, y el DOM tiene que parsearla y sostenerla. Para un icono de 20 kilobytes es un chollo. Para una imagen hero de 4 megabytes, sirve el archivo por HTTP, donde caché y compresión funcionan ambas, y deja la data URL para las cosas pequeñas.

Archivos que viajan como cadenas

De archivo a Base64 es una danza de dos pasos que ambos runtimes comprimen en una sola llamada. En Node.js, el sistema de archivos acepta 'base64' como codificación de lectura, y el lado de escritura también:

const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // el archivo, unos 33 por ciento más pesado
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);

En el navegador, FileReader hace el mismo trabajo, con un giro: su modo de lectura de datos te entrega una data URL, así que cortas el prefijo para obtener el payload Base64 desnudo:

const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
  const reader = new FileReader();
  reader.onload = () => {
    const dataUrl = reader.result; // "data:application/pdf;base64,..."
    const payload = {
      name: fileInput.files[0].name,
      data: dataUrl.slice(dataUrl.indexOf(',') + 1)
    };
    console.log(payload.data.length); // el archivo, listo para una petición JSON
  };
  reader.readAsDataURL(fileInput.files[0]);
});

Si necesitas el Base64 crudo sin el prefijo de data URL en el navegador, file.arrayBuffer() seguido de Uint8Array.toBase64() (en runtimes que lo tienen) salta el prefijo por completo y es la vía más limpia para pipelines de subida.

base64url: el alfabeto que sobrevive a las URLs

El Base64 clásico lleva dos caracteres a los que las URLs son alérgicas. El + se convierte en un espacio cada vez que una cadena de consulta se decodifica como formulario, el / es un separador de ruta, y el relleno = parece una asignación. La variante segura para URLs y nombres de archivo de la sección 5 del RFC 4648, base64url, cambia los dos especiales por - y _, y descarta el relleno siempre que la longitud se puede conocer por contexto. Es el alfabeto de los JWTs, los tokens de OAuth y los deep links, y se merece un lugar dedicado en tu caja de herramientas mental.

El Buffer de Node habla este dialecto desde la versión 15.7.0, y el lado de la codificación es un argumento:

const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"

Dos cosas a notar. El + se convirtió en -, el / en _, y el relleno desapareció, porque el modo base64url lo omite por diseño. Y la IETF es explícita en que es una codificación distinta, no la misma con un disfraz, así que cuando una especificación dice "base64url", deberías producir base64url, no Base64 clásico con un buscar-y-reemplazar. El método de ES2026 convierte las mismas decisiones en opciones explícitas, y su flag omitPadding te devuelve el relleno cuando el contexto lo exige:

console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", dos bytes necesitan un carácter de relleno

En runtimes sin ninguna de las dos, la conversión es un intercambio de dos caracteres más un recorte de relleno, y es uno de los fragmentos más copiados y pegados del mundo JavaScript:

const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"

Usa base64url para lo que viva en una URL, una cadena de consulta, un nombre de archivo, o un estándar de tokens. Usa Base64 clásico para cuerpos MIME, data URLs y lo que jamás vaya a conocer a un percent-decoder. Confundirlos es el bug de interoperabilidad más común de todo este formato.

Sellando credenciales: Basic Auth, JWTs y PKCE

Tres esquinas de la autenticación web están construidas sobre Base64, y las tres son baratas de construir a mano una vez, lo cual es una buena cosa, porque saber qué pasa bajo la librería es lo que te mantiene calmado cuando la librería te sorprende.

Primera, la autenticación HTTP Basic (RFC 7617): el cliente envía la palabra de esquema Basic más el Base64 de user-id:password. Una línea, y una advertencia seria colgada de ella:

const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="

El Base64 aquí es ofuscación, no seguridad. Cualquiera que pueda leer la cabecera puede leer la contraseña, así que este esquema solo es aceptable sobre HTTPS, y aun así es un patrón legacy: prefiere tokens. Segunda, el JWT: las dos primeras partes separadas por puntos son base64url de JSON plano, y la tercera es la firma. Construir un token HMAC-SHA256 a mano son unas cuantas líneas del módulo crypto integrado:

const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3 partes, base64url sin relleno de principio a fin

Fíjate en los detalles que hacen o deshacen un token: sin relleno en ninguna parte (el RFC 7515 lo omite), la firma se calcula sobre la cadena literal header + '.' + payload, no sobre los objetos parseados, y todo el conjunto es tan secreto como la clave. En producción usarás una librería, jose (cero dependencias, navegador y Node.js) o jsonwebtoken (Node.js), pero ellas están ejecutando estas mismas llamadas por dentro. Tercera, PKCE (RFC 7636), la extensión que deja a clientes públicos como SPAs y apps móviles iniciar sesión con seguridad: el cliente genera un code_verifier de alta entropía, publica BASE64URL(SHA256(verifier)) como reto, y demuestra la posesión del verifier en el intercambio del token. La aleatoriedad importa, así que el verifier sale del módulo crypto, nunca de Math.random():

const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43, ambos dentro del rango permitido de 43-128

El correo antiguo necesita envolverse: MIME y armadura PEM

Dos de los formatos Base64 más viejos del mundo aún imponen longitudes de línea, y ambos tienen unos 30 años. MIME, el estándar de correo del RFC 2045, envuelve su Base64 a 76 caracteres por línea y exige que las líneas terminen con CRLF, una reliquia de los días de SMTP 8-bit limpio cuando las líneas muy largas partían servidores de correo de verdad. El RFC 7468, que pone por escrito las reglas PEM para certificados y claves, es todavía más estricto: los generadores deben envolver a exactamente 64 caracteres por línea, la última línea más corta, enmarcado por líneas de armadura -----BEGIN y -----END que nombran el contenido.

El envolvido en sí es una línea, y la armadura es una plantilla:

const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200 caracteres
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----

Dos notas prácticas. Cuando produzcas MIME o PEM, envuélvelo, porque los consumidores estrictos (pasarelas de correo, herramientas de la era OpenSSL, almacenes de claves Java) rechazarán un blob Base64 de 4000 caracteres en una sola línea. Cuando lo consumas, normalmente no necesitas hacerlo, porque el decodificador de Node omite los espacios, así que Buffer.from maneja los saltos de línea por ti - pero las líneas de armadura en sí no son Base64, así que quita las líneas -----BEGIN/-----END (o compara solo el cuerpo) antes de decodificar: Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64'). Esa asimetría es un regalo, pero no significa que puedas saltarte el paso de quitar la armadura cuando el Base64 va a un sitio que no omite nada, como un parser DER.

Dónde viven los datos codificados: entorno, configuración y bases de datos

Base64 es también un formato de almacenamiento, lo cual es a la vez conveniente y peligrosamente fácil de confundir con seguridad. Las variables de entorno son el hogar clásico: varios gestores de secretos y sistemas de CI te entregan valores codificados en Base64, y la decodificación es una línea:

const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"

Di la frase importante en voz alta: codificar no es cifrar. Un "secreto" Base64 en una variable de entorno, un archivo .env o un secreto de Kubernetes (k8s guarda sus secretos como Base64 en la API y en etcd, y la documentación lo repite constantemente) es legible por cualquiera que pueda leer el entorno del proceso, el archivo, o el clúster. Usa Base64 allí porque el transporte (shell, YAML, JSON) es solo de texto, nunca porque creas que esconde algo.

En las bases de datos, Base64 es el puente estándar para el binario dentro de los almacenes de documentos JSON, porque una columna jsonb o un documento de MongoDB no tienen su propio tipo de bytes:

const document = {
  name: 'logo',
  mime: 'image/png',
  data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}

Guarda el tipo de medio junto al payload, como hace el ejemplo, y te darás las gracias dentro de un año cuando alguien pregunte qué son los bytes. Si tu base de datos tiene un tipo binario nativo (Postgres bytea es el ejemplo de referencia), prefíeralo: los bytes no cuestan nada extra, y te ahorras el impuesto del 33 por ciento para siempre.

Codificar streams sin dividir grupos

Base64 trabaja en grupos de tres bytes, así que un codificador que recibe trozos arbitrarios debe llevar su resto: uno o dos bytes que aún no pueden formar un grupo tienen que esperar al siguiente trozo antes de poder codificarse. Haz las cuentas por trozo y emite solo grupos completos, y la salida es idéntica byte a byte a codificar el stream entero de una vez:

const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
  let pending = Buffer.alloc(0);
  return new Transform({
    transform (chunk, _encoding, done) {
      pending = Buffer.concat([pending, chunk]);
      const whole = Math.floor(pending.length / 3) * 3;
      this.push(pending.subarray(0, whole).toString('base64'));
      pending = pending.subarray(whole);
      done();
    },
    flush (done) {
      if (pending.length > 0) {
        this.push(pending.toString('base64'));
      }
      done();
    }
  });
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
  console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h"; idéntico a un solo toString('base64') grande
});
encoder.end(Buffer.from('hello world, this is a stream!'));

El callback flush es el detalle que todos olvidan: los últimos uno o dos bytes, los que nunca encontraron pareja en un trozo regular, reciben su relleno y se empujan al final. La misma lógica de arrastre es la que vas a reflejar en el lado de la decodificación, solo que allí la API de ES2026 te la da gratis: setFromBase64() con "stop-before-partial" se detiene exactamente en fronteras de grupo y te dice cuántos caracteres consumió.

Archivos grandes y la factura de memoria

Base64 es generoso con el espacio, así que los archivos grandes necesitan estrategia. Un archivo de 1 GB se convierte en unos 1.33 GB de texto Base64, y una cadena de JavaScript guarda UTF-16, dos bytes de heap por carácter, así que ese texto solo pide unos 2.7 GB de memoria antes de que llegue tu Buffer. El techo es explícito en Node: buffer.constants.MAX_STRING_LENGTH es de 536870888 caracteres, apenas por debajo de 512 MiB de texto, que se decodifican a unos 400 MB de bytes. Más allá de eso, una sola cadena no es opción, y el streaming es el único juego en la ciudad:

const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
  const joined = Buffer.concat([carried, chunk]);
  const whole = Math.floor(joined.length / 3) * 3;
  process.stdout.write(joined.subarray(0, whole).toString('base64'));
  carried = joined.subarray(whole);
});
source.on('end', () => {
  if (carried.length > 0) {
    process.stdout.write(carried.toString('base64'));
  }
  process.stdout.write('\n');
});

El patrón es el codificador de stream de la sección anterior, aplanado: leer en trozos de 64 KiB, llevar el resto de 1 a 2 bytes, emitir grupos completos, descargar la cola. La huella de memoria se queda en torno a un trozo más un resto, sea lo que pese el archivo. Y si el extremo receptor puede aceptar binario, pregúntate por qué pagas el impuesto en absoluto.

Una sola línea para la terminal

Node es también un codificador Base64 de línea de comandos, y viene bien cuando empaquetas un valor de configuración, depuras una API, o mueves un archivo pequeño entre máquinas a través de un mensaje de chat:

# Codifica un archivo a Base64 clásico en stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# La variante URL-safe, relleno descartado
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# Lee desde stdin, para eso están las tuberías
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'

Ninguno de los tres añade su propio salto de línea final, lo que mantiene la salida limpia para copiar y pegar y para la sustitución $(...) en scripts de shell. Si quieres un archivo bonito con líneas envueltas, pasa el resultado a tu editor favorito, o añade un \n al final de la una-línea.

Trampas que han costado horas a los desarrolladores

Cada una de estas ha costado una tarde de verdad en un codebase de verdad:

  • La muralla Unicode: btoa('héllo ⛳') lanza InvalidCharacterError porque la bandera de golf no cabe en un byte. La solución es el puente UTF-8: TextEncoder a bytes primero, y luego codificar. En Node.js te saltas el problema por completo con Buffer.from(text), que asume UTF-8.
  • El idiomático legacy: el código viejo lleno de btoa(unescape(encodeURIComponent(x))) funciona, pero escape y unescape son funciones legacy deprecadas. Cuando refactorices ese código, sustitúyelo por el puente TextEncoder y el comportamiento se queda idéntico.
  • El argumento de codificación faltante, al revés: la trampa del lado de la decodificación es Buffer.from(str) sin el modo 'base64'; la gemela del lado de la codificación es asumir que Buffer.from(someString) hace algo especial con Base64. No lo hace. Sin una codificación explícita construye un Buffer a partir de los bytes UTF-8 de la cadena, y tu salida "codificada" es el Base64 de los bytes de las letras de la cadena, lo cual casi nunca es lo que se quería. Sé explícito en ambas direcciones.
  • El choque de rellenos: los JWTs y la mayoría de los estándares de tokens quieren base64url sin relleno, MIME quiere Base64 clásico con relleno, y los dos son fáciles de cruzar. Un = relleno dentro de una parte de JWT rompe a los verificadores estrictos; un relleno faltante donde la longitud es desconocida rompe a los decodificadores perezosos. Ajústate al estándar, no a tu costumbre.
  • La cola no canónica: el RFC 4648 exige que los bits de relleno sin usar del último grupo sean cero. Los codificadores integrados producen todos salida canónica, pero un codificador hecho a mano que desplaza bits a mano puede dejar basura en esos bits, y un decodificador estricto rechazará tu payload sin razón aparente. Si escribes tu propio codificador, prueba contra los vectores de prueba del RFC 4648, no solo contra tus propios datos.
  • El envolvido olvidado: MIME quiere líneas de 76 caracteres y PEM quiere 64, y los consumidores estrictos (pasarelas de correo, herramientas de claves Java) rechazan un blob de una sola línea. Lo contrario es más raro pero real: algunos parsers son por líneas, y un CRLF faltante al final de un archivo PEM ha roto más builds que ningún bug del propio Base64.
  • La ilusión de seguridad: Base64 en una variable de entorno, un archivo .env o un secreto de Kubernetes no es cifrado. Se decodifica con una línea de código, en cualquier lenguaje, por cualquiera que pueda leer el archivo o el clúster. Trátalo como un disfraz de transporte, y deja que los controles de verdad (permisos, TLS, rotación de claves) se encarguen de proteger.
  • La hinchazón JSON: Base64 dentro de JSON cuesta un 33 por ciento más los escapes, y una subida de 5 MB se convierte en una cadena de 6.7 MB que tu parser JSON tiene que copiar a memoria. Para lo que pese como archivo por HTTP, multipart/form-data o un cuerpo binario crudo son el mejor transporte, y Base64 es para cuando el canal es solo de texto.
  • La factura de heap: una cadena codificada es UTF-16 en el heap de JavaScript, dos bytes por carácter, y el Buffer decodificado o de origen es una segunda copia de los datos. Un archivo de 100 MB significa brevemente unos 270 MB de cadena más 100 MB de Buffer. Mete los grandes por streaming, y mantén la forma codificada referenciada el menor tiempo que el código permita.
  • Los globales legacy en Node: la propia documentación de Node marca btoa() y atob() como Stability 3, Legacy, y te dice que uses Buffer en su lugar. En un navegador btoa() es una herramienta perfectamente válida para texto ASCII; en Node.js, ve a por el Buffer y deja los globales para el código con forma de polyfill que los necesita.

Cómo aprendió JavaScript a empaquetar bytes

El lado de los navegadores es una historia larga y silenciosa. btoa() se especificó en el borrador de HTML5 a principios de 2011, y ha estado en todos los navegadores importantes desde mediados de los 2000, sin cambios de comportamiento, con su contrato de un byte por carácter y su salida siempre rellena. Ese contrato antecede a los typed arrays - las cadenas binarias eran la única forma de llevar bytes antes de 2009 - por eso btoa() aún piensa en 'cadenas binarias'. La mitad moderna de la historia es muy reciente: la propuesta TC39 que añadió Base64 nativo a los typed arrays (junto con hex) se estandarizó como parte de ES2026, y llegó a Firefox 133 y Safari 18.2 en 2024, a Chrome 140 el 2 de septiembre de 2025, y luego fue declarada Baseline Newly available. Bun lanzó los mismos métodos en la versión 1.1.22 en agosto de 2024.

Node.js empaquetó bytes con un reloj distinto. 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 toString('base64') fue el codificador elegido durante más de una década, con los vicios de alfabeto de esa época (ya aceptaba los caracteres URL-safe al decodificar, un hábito bilingüe que la especificación nunca pidió). La versión 15.7.0 de enero de 2021 añadió el modo 'base64url' como nombre de codificación de primera, Node 16 ese mismo año añadió los globales btoa()/atob() del navegador (marcados Legacy de inmediato), y Node 22 en 2024 trajo más trabajo de rendimiento de V8 y base64. Entonces Node 25, lanzado el 15 de octubre de 2025, actualizó V8 a 14.1 y trajo los métodos de ES2026, toBase64() con su opción omitPadding y setFromBase64() para la otra dirección, al runtime. Para runtimes que no pueden seguir el ritmo, core-js trae polyfills (features/typed-array/to-base64 / from-base64), y el paquetito base64-js (tres funciones, cero dependencias) ha llevado el ecosistema en silencio durante años como dependencia transitiva.

El formato que sirven es más viejo que todo ello. El alfabeto se estandarizó por primera vez para Privacy-Enhanced Mail en 1987 (RFC 989), la revisión de 1993 (RFC 1421) lo mantuvo, y MIME lo adoptó meses después ese mismo año con su envolvido de 76 caracteres; el RFC 3548 consolidó la familia Base-N en 2003 y añadió la variante URL-safe, que el RFC 4648 reeditó en 2006. Una década después, los RFC 7515 y 7519 convirtieron el base64url sin relleno en la columna vertebral de cada JWT, y el RFC 7636 lo metió en el flujo PKCE de OAuth. Los codificadores de este artículo son el último kilómetro de un formato que tiene treinta años y sigue ganando pasajeros.

Para soltar en una fiesta

  • btoa('GIF89a') devuelve "R0lGODlh", la cabecera mágica entera de un GIF en ocho caracteres. Es el "hola" más pequeño que un archivo binario puede decir en Base64, y es el primer ejemplo Web-API del artículo de Wikipedia por algo.
  • toBase64() tiene una opción omitPadding que btoa() nunca pudo tener, porque el contrato de la Web API rellena sin condiciones. Dos décadas del mismo alfabeto, y la API nueva puede hacer una cosa que a la vieja jamás le dejaron.
  • Un alfabeto, dos longitudes oficiales de línea: MIME envuelve a 76, PEM a 64. Los mismos 64 caracteres, el mismo relleno, dos opiniones de 30 años sobre lo ancho que puede ser una línea de texto.
  • El número del 33 por ciento es exacto: cuatro caracteres por tres bytes es una proporción de 4/3, y el correo de la era RFC añadía aproximadamente otro 3.5 por ciento por los saltos de línea. Tu cadena de configuración "pequeña" está un 37 por ciento más gorda por nada.
  • El paquetito base64-js arrastra más de 100 millones de descargas a la semana en npm, casi todo escondido dentro de los árboles de dependencias de otros paquetes. Base64 es el código más contrabandeado del ecosistema JavaScript.
  • Los Buffers pequeños no se asignan uno a uno: Node los corta de una piscina compartida de 65536 bytes (Buffer.poolSize), que es la razón por la que la creación de Buffer es rápida, y por la que existen las variantes de asignación 'unsafe' para los casos en que los datos del inquilino anterior no importan.
  • El RFC que definió las data URLs en 1998 avisa de que son "only useful for short values", citando un límite de atributo HTML de 1024 caracteres. Los navegadores modernos incrustan imágenes de megabytes como data URLs en los mismos atributos, lo cual es o bien progreso o bien arrogancia, según tu imagen hero.
  • Los hashes de contraseñas de Unix usan sus propios alfabetos con sabor Base64, sin relleno, y, para confusión, el orden difiere por esquema: los hashes clásicos de crypt(3) usan ./0-9A-Za-z, mientras que las cadenas bcrypt $2b$ que los proyectos JavaScript guardan para las contraseñas de usuario barajan los mismos 64 caracteres y los ponen en ./A-Za-z0-9. Es un buen recordatorio de que "Base64" en un contexto de seguridad es una familia, no un único formato.
  • El decodificador de Node acepta -, _, + y / en ambos modos, 'base64' y 'base64url', cuatro caracteres, una tabla. El codificador, por supuesto, solo habla el dialecto que le pediste.

La mitad de un viaje de ida y vuelta

Codificar Base64 en JavaScript y Node.js se reduce a tres decisiones: qué bytes sostienes (una cadena necesita un charset, un Buffer no), qué alfabeto exige el destino (clásico para MIME y data URLs, base64url para tokens y URLs, relleno opcional según el contexto), y qué reglas de línea sigue imponiendo el formato (76 para correo, 64 para PEM, ninguna para JSON). Responde eso y los integrados hacen el resto: Buffer.toString() en Node, btoa() más el puente UTF-8 en el navegador, y Uint8Array.toBase64() en los runtimes modernos que por fin tuvieron uno.

Y cada paquete que sellas aquí, alguien lo abrirá un día. El lado de la decodificación tiene su propio set de trampas: el decodificador indulgente que se traga la basura sin hacer ruido, el disfraz de cadena binaria que te entrega atob(), las decisiones de charset que pasan en el lado del lector de la muralla, y la lógica de streaming que refleja el patrón de arrastre que acabas de aprender. Esa historia, con ejemplos de código para cada paso, se cubre en profundidad en el artículo relacionado de decodificación Base64 en nuestro sitio hermano. Léelo a continuación, porque las trampas de ese lado del alfabeto son más silenciosas, y el silencio es exactamente como ganan.

Última actualización: 2026-09-08

Artículo relacionado: Decodificación Base64 en JavaScript/Node.js: una guía completa