Base64 aparece en todas partes una vez que empiezas a buscar — tokens JWT, URIs de datos, archivos adjuntos de correo electrónico, cargas de API que transportan archivos binarios. La codificación en sí está definida en RFC 4648 y es conceptualmente sencilla: tomar bytes arbitrarios y representarlos usando solo 64 caracteres ASCII imprimables. Lo que confunde a la gente es la implementación en JavaScript — diferentes APIs en el navegador frente a Node.js, el problema Unicode que hace que btoa() lance un error, y la variante segura para URL de la que dependen los JWT. Esta guía cubre todo con código funcional.

btoa() y atob() en el navegador

El navegador ha tenido btoa() y atob() desde hace mucho tiempo. Los nombres son confusos (binary to ASCII y viceversa), pero el uso es sencillo para cadenas simples:

js
// Encode a plain ASCII string
const encoded = btoa('hello world');
console.log(encoded); // "aGVsbG8gd29ybGQ="

// Decode it back
const decoded = atob('aGVsbG8gd29ybGQ=');
console.log(decoded); // "hello world"

// A more realistic example — encoding a simple auth token
const credentials = 'apiuser:s3cr3tkey';
const basicAuth = 'Basic ' + btoa(credentials);
// "Basic YXBpdXNlcjpzM2NyM3RrZXk="
// This is exactly what HTTP Basic Authentication uses
La trampa Unicode: btoa() solo maneja cadenas donde cada carácter tiene un punto de código ≤ 255 (el rango Latin-1). Pásale una cadena que contenga cualquier emoji o carácter no Latino y lanzará InvalidCharacterError inmediatamente. Este es uno de los errores Base64 más comunes en el código del navegador.
js
// ❌ This throws — emoji is outside Latin-1
btoa('Hello 🌍');
// Uncaught DOMException: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.

// ❌ This also throws — any non-ASCII character will do it
btoa('café');
// Uncaught DOMException: ...

Manejo seguro de Unicode en el navegador

La solución es primero codificar la cadena a bytes UTF-8, luego codificar esos bytes en Base64. El enfoque clásico usa encodeURIComponent y un truco de decodificación porcentual. El enfoque moderno usa TextEncoder, que está disponible en todos los navegadores modernos y Node.js 11+:

js
// ✅ Unicode-safe encode using TextEncoder
function encodeBase64(str) {
  const bytes = new TextEncoder().encode(str);          // UTF-8 byte array
  const binString = Array.from(bytes, byte =>
    String.fromCodePoint(byte)
  ).join('');
  return btoa(binString);
}

// ✅ Unicode-safe decode using TextDecoder
function decodeBase64(base64Str) {
  const binString = atob(base64Str);
  const bytes = Uint8Array.from(binString, char =>
    char.codePointAt(0)
  );
  return new TextDecoder().decode(bytes);
}

// Now emojis and international text work fine
console.log(encodeBase64('Hello 🌍'));   // "SGVsbG8g8J+MjQ=="
console.log(decodeBase64('SGVsbG8g8J+MjQ==')); // "Hello 🌍"

console.log(encodeBase64('Héllo café')); // "SMOpbGxvIGNhZsOp"
console.log(decodeBase64('SMOpbGxvIGNhZsOp')); // "Héllo café"

Guarda estas dos funciones utilitarias en algún lugar de tu base de código y olvida que existe btoa() desnudo. El par TextEncoder/TextDecoder es la herramienta correcta para cualquier cosa más allá del ASCII puro. Puedes probarlo ahora mismo con la herramienta Codificador Base64.

Buffer.from() en Node.js

Node.js tiene su propia API para esto a través de la clase Buffer, que maneja la codificación/decodificación de forma más limpia. No hay problema Unicode aquí porque especificas explícitamente la codificación de entrada:

js
// Encode string → Base64
const encoded = Buffer.from('Hello 🌍', 'utf8').toString('base64');
console.log(encoded); // "SGVsbG8g8J+MjQ=="

// Decode Base64 → string
const decoded = Buffer.from('SGVsbG8g8J+MjQ==', 'base64').toString('utf8');
console.log(decoded); // "Hello 🌍"

// Practical example — encoding a JSON payload to embed in a config file
const config = {
  apiKey:    'sk-prod-abc123',
  projectId: 'proj_x9f2k',
  region:    'us-east-1'
};

const encodedConfig = Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
// eyJhcGlLZXkiOiJzay1wcm9kLWFiYzEyMyIsInByb2plY3RJZCI6InByb2pfeDlmMmsiLCJyZWdpb24iOiJ1cy1lYXN0LTEifQ==

// Decode and parse it back
const decodedConfig = JSON.parse(
  Buffer.from(encodedConfig, 'base64').toString('utf8')
);
console.log(decodedConfig.region); // "us-east-1"

Ten en cuenta que btoa() y atob() también están disponibles en Node.js 16+ como globales (para compatibilidad con el navegador), pero la API Buffer es más idiomática en Node.js y ha estado ahí desde Node.js v0.1. Para la codificación específica de JSON, la herramienta JSON a Base64 es útil para conversiones manuales rápidas.

Base64 seguro para URL — Lo que realmente usan los JWT

El Base64 estándar usa + y / en su alfabeto. Ambos caracteres son especiales en URLs — + significa un espacio en las cadenas de consulta, y / es un separador de ruta. Cuando necesitas Base64 en una URL o como segmento JWT, usas la variante segura para URL: reemplaza + por - y / por _, luego elimina el relleno =. Esto está estandarizado en RFC 4648 §5 y es lo que usa internamente cada biblioteca JWT:

js
// Convert standard Base64 to URL-safe Base64
function toBase64Url(base64Str) {
  return base64Str
    .replace(/+/g, '-')
    .replace(///g, '_')
    .replace(/=+$/, '');  // strip padding
}

// Convert URL-safe Base64 back to standard Base64
function fromBase64Url(base64UrlStr) {
  // Restore padding — length must be a multiple of 4
  const padded = base64UrlStr + '==='.slice((base64UrlStr.length + 3) % 4);
  return padded
    .replace(/-/g, '+')
    .replace(/_/g, '/');
}

// Encode a string to URL-safe Base64
function encodeBase64Url(str) {
  return toBase64Url(btoa(str));
}

// Decode URL-safe Base64 to a string
function decodeBase64Url(str) {
  return atob(fromBase64Url(str));
}

// Example: manually inspect a JWT payload
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTM0MDAwMDB9.signature';
const [header, payload] = jwt.split('.');

console.log(decodeBase64Url(header));
// {"alg":"HS256","typ":"JWT"}

console.log(decodeBase64Url(payload));
// {"userId":42,"role":"admin","iat":1713400000}

Por eso verás cadenas Base64 como eyJhbGciOiJIUzI1NiJ9 en los JWT — sin relleno, guiones en lugar de signos más. Cuando envíes datos codificados como parámetro de consulta URL, usa siempre la variante segura para URL para evitar URLs rotas. La herramienta Decodificador Base64 maneja automáticamente tanto Base64 estándar como Base64 seguro para URL.

Codificación de archivos con la API FileReader

Una tarea común en el navegador: el usuario elige una imagen o documento, y necesitas enviarlo a una API como Base64. La API FileReader tiene readAsDataURL() exactamente para esto — te da una URI de datos completa con el tipo MIME incluido:

js
// Wrap FileReader in a Promise for easier async usage
function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload  = () => {
      // result is "data:image/png;base64,iVBORw0KGgo..."
      // Strip the data URI prefix to get just the Base64 string
      const base64 = reader.result.split(',')[1];
      resolve(base64);
    };

    reader.onerror = () => reject(new Error('Failed to read file'));
    reader.readAsDataURL(file);
  });
}

// Hook it up to a file input
const fileInput = document.getElementById('avatarUpload');

fileInput.addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;

  try {
    const base64 = await fileToBase64(file);
    console.log(`File size: ${file.size} bytes`);
    console.log(`Base64 length: ${base64.length} chars`);

    // Send to your API
    await fetch('/api/users/42/avatar', {
      method:  'PUT',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify({ image: base64, mimeType: file.type })
    });
  } catch (err) {
    console.error('Upload failed:', err.message);
  }
});

Si necesitas la URI de datos completa (incluyendo el prefijo del tipo MIME) en lugar del Base64 sin procesar, omite el .split(',')[1] y usa reader.result directamente. Para la conversión masiva de archivos, la herramienta Imagen a Base64 maneja imágenes sin escribir ningún código.

Codificación de datos binarios y Uint8Arrays

A veces no empiezas desde una cadena o un archivo — tienes bytes sin procesar de una operación WebCrypto, una exportación de canvas, o un módulo WebAssembly. Aquí se explica cómo pasar de un Uint8Array a Base64 y viceversa en ambos entornos:

js
// --- Browser ---

// Uint8Array → Base64 (browser)
function uint8ToBase64(bytes) {
  const binString = Array.from(bytes, byte =>
    String.fromCodePoint(byte)
  ).join('');
  return btoa(binString);
}

// Base64 → Uint8Array (browser)
function base64ToUint8(base64Str) {
  const binString = atob(base64Str);
  return Uint8Array.from(binString, char => char.codePointAt(0));
}

// Example: export a canvas as raw PNG bytes → Base64
const canvas  = document.getElementById('myCanvas');
canvas.toBlob(blob => {
  blob.arrayBuffer().then(buffer => {
    const bytes   = new Uint8Array(buffer);
    const encoded = uint8ToBase64(bytes);
    console.log('PNG as Base64:', encoded.slice(0, 40) + '...');
  });
}, 'image/png');


// --- Node.js ---

// Uint8Array / Buffer → Base64 (Node.js)
function uint8ToBase64Node(bytes) {
  return Buffer.from(bytes).toString('base64');
}

// Base64 → Buffer (Node.js)
function base64ToBufferNode(base64Str) {
  return Buffer.from(base64Str, 'base64');
}

// Example: hash a password and encode the result
const crypto = require('crypto');
const hash   = crypto.createHash('sha256').update('mySecretPassword').digest();
// hash is a Buffer (which extends Uint8Array)
console.log(hash.toString('base64'));
// "XohImNooBHFR0OVvjcYpJ3NgxxxxxxxxxxxxxA=="

Incrustar imágenes como URIs de datos

Uno de los usos más prácticos de Base64 en el desarrollo web es incrustar imágenes directamente en HTML o CSS, eliminando una solicitud HTTP. Probablemente hayas visto URIs de datos en SVGs en línea o plantillas de correo electrónico. Aquí está el patrón:

html
<!-- Inline image in HTML — no separate network request -->
<img
  src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
  alt="1x1 transparent pixel"
  width="1"
  height="1"
/>
css
/* Inline background image in CSS — commonly used for small icons and loading spinners */
.spinner {
  width:  32px;
  height: 32px;
  background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJhMTAgMTAgMCAxIDAgMCAyMCAxMCAxMCAwIDAgMCAwLTIweiIvPjwvc3ZnPg==");
  background-repeat:   no-repeat;
  background-position: center;
  background-size:     contain;
}
js
// Generate a data URI from a fetched image (Node.js)
const fs     = require('fs');
const path   = require('path');

function imageFileToDataUri(filePath) {
  const ext      = path.extname(filePath).slice(1).toLowerCase();
  const mimeMap  = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
                     gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp' };
  const mimeType = mimeMap[ext] ?? 'application/octet-stream';
  const fileData = fs.readFileSync(filePath);
  const base64   = fileData.toString('base64');
  return `data:${mimeType};base64,${base64}`;
}

const dataUri = imageFileToDataUri('./logo.png');
// "data:image/png;base64,iVBORw0KGgo..."
// Drop this into an <img src> or CSS background-image
Advertencia de tamaño: La codificación Base64 infla el tamaño del archivo en aproximadamente un 33%. Una imagen de 100 KB se convierte en ~133 KB de texto Base64. Las URIs de datos son mejores para activos pequeños (iconos, SVGs, sprites pequeños) — no para fotos o imágenes grandes. Para esos, el multiplexado HTTP/2 hace que las solicitudes separadas sean más rápidas que el inlining.

Un módulo utilitario compacto para ambos entornos

En lugar de dispersar llamadas btoa() por tu base de código, vale la pena tener un único módulo utilitario que cubra Unicode, variantes seguras para URL, y funcione tanto en navegador como en Node.js. Aquí hay uno que hace todo eso:

js
// base64.js — drop into any project
const isNode = typeof process !== 'undefined' && process.versions?.node;

export function encode(str) {
  if (isNode) {
    return Buffer.from(str, 'utf8').toString('base64');
  }
  // Browser: encode to UTF-8 bytes first, then Base64
  const bytes = new TextEncoder().encode(str);
  const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
  return btoa(binString);
}

export function decode(base64Str) {
  if (isNode) {
    return Buffer.from(base64Str, 'base64').toString('utf8');
  }
  // Browser: Base64 → bytes → UTF-8 string
  const binString = atob(base64Str);
  const bytes = Uint8Array.from(binString, c => c.codePointAt(0));
  return new TextDecoder().decode(bytes);
}

export function encodeUrlSafe(str) {
  return encode(str)
    .replace(/+/g, '-')
    .replace(///g, '_')
    .replace(/=+$/, '');
}

export function decodeUrlSafe(str) {
  const padded = str + '==='.slice((str.length + 3) % 4);
  return decode(padded.replace(/-/g, '+').replace(/_/g, '/'));
}

export function encodeBytes(bytes) {
  if (isNode) return Buffer.from(bytes).toString('base64');
  const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
  return btoa(binString);
}

export function decodeToBytes(base64Str) {
  if (isNode) return Buffer.from(base64Str, 'base64');
  const binString = atob(base64Str);
  return Uint8Array.from(binString, c => c.codePointAt(0));
}
js
// Usage examples
import { encode, decode, encodeUrlSafe, decodeUrlSafe } from './base64.js';

encode('Hello 🌍');           // "SGVsbG8g8J+MjQ=="
decode('SGVsbG8g8J+MjQ==');   // "Hello 🌍"

encodeUrlSafe('[email protected]'); // "dXNlckBleGFtcGxlLmNvbQ" (no +, /, or =)
decodeUrlSafe('dXNlckBleGFtcGxlLmNvbQ'); // "[email protected]"

Problemas comunes a tener en cuenta

  • btoa() lanza error en caracteres no-Latin — cualquier carácter por encima del punto de código 255 causa InvalidCharacterError. Usa siempre el enfoque TextEncoder o Buffer.from(str, 'utf8') en Node.js.
  • El relleno importa para la decodificación — las cadenas Base64 deben tener una longitud que sea un múltiplo de 4. El relleno = faltante hace que atob() devuelva silenciosamente basura o lance un error, dependiendo del navegador. Restaura siempre el relleno antes de decodificar cadenas seguras para URL.
  • Codificación de Buffer vs cadena en Node.jsBuffer.from(str) tiene por defecto UTF-8, pero Buffer.from(str, 'binary') trata la cadena como bytes Latin-1. Usar la codificación incorrecta al decodificar produce salida ilegible que puede ser difícil de depurar.
  • Tipo MIME de la URI de datosdata:;base64,... (sin tipo MIME) funcionará en algunos navegadores pero no en otros. Incluye siempre el tipo MIME: data:image/png;base64,....
  • Saltos de línea en MIME Base64 — RFC 4648 permite a las implementaciones insertar saltos de línea cada 76 caracteres (como hacen los codificadores de correo electrónico). atob() y Buffer.from() manejan esto, pero si estás generando Base64 tú mismo, no añadas saltos de línea a menos que el sistema de destino los espere.

Conclusión

Base64 en JavaScript es uno de esos temas que parece trivial hasta que te pica. La versión corta: nunca uses btoa() desnudo para nada generado por el usuario — envuélvelo con TextEncoder para manejar Unicode correctamente. En Node.js, Buffer.from(str, 'utf8').toString('base64') es el modismo correcto. Cuando la cadena codificada termina en una URL o JWT, cambia a la variante segura para URL. Para experimentos rápidos o conversiones únicas, las herramientas Codificador Base64, Decodificador Base64, y JSON a Base64 ahorran tiempo. La página del glosario Base64 de MDN también tiene una buena referencia centrada en el navegador si necesitas una segunda opinión sobre cualquiera de esto.