¿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 Java: una guía completa

Esta es la situación: tienes bytes. Un archivo, una contraseña, un certificado, un saludo de 13 bytes, una subida de 200 megabytes. Y los necesitas dentro de algo que solo entiende texto: un campo JSON, una cabecera HTTP, una columna de base de datos, una URL, un archivo de configuración. Ese es todo el trabajo de Base64, y esta guía es el manual en Java para hacerlo bien. Orientación rápida, porque la página de inicio recorre el formato paso a paso: Base64 reescribe cada tres bytes de datos como cuatro caracteres de un alfabeto de 64 letras, con uno o dos rellenos = añadidos cuando el último bloque queda corto. El precio del viaje es el tamaño: cada tres bytes se convierten en cuatro caracteres, así que la salida codificada acaba unas 33 por cien más grande que la entrada, y un poco más si hay saltos de línea por medio.

La noticia principal, y es buena. Desde el 18 de marzo de 2014, cada JDK lleva incluida una caja de herramientas Base64 completa en la biblioteca estándar: java.util.Base64. Sin descargas, sin coordenada de Maven, sin biblioteca nativa. Un import, tres personalidades de codificador, y el mismo comportamiento desde Java 8 hasta el Java 26 de hoy. Todo lo de este artículo se apoya en esa única clase, y nunca lanza excepción sobre los datos en sí: el trabajo del codificador no puede fallar con entrada inválida, porque cada byte posible es codificable.

Un límite honesto antes de empezar: esta es la parte del codificador de la historia. Aprenderás la decisión de cadena a bytes que de verdad determina la corrección, las perillas de padding y envoltura, el base64url y su modo sin relleno para tokens, y los casos de uso donde los desarrolladores Java se encuentran con más frecuencia la salida codificada. La decodificación, donde vive la mayor parte del dolor real, tiene su propia guía y está enlazada al final de esta.

Un import, cero descargas

Instalar Base64 en Java es la respuesta de una línea que das frente a la pizarra: "Está en el JDK." La clase java.util.Base64 es parte del módulo java.base desde 1.8, y su javadoc sigue diciendo Since: 1.8 doce años después. Lo único que instalas es un JDK: con cualquier Java 8 o posterior de cualquier proveedor (Oracle, Eclipse Temurin, Amazon Corretto, Zulu) funciona, y en una máquina con base Debian es un único comando:

sudo apt install openjdk-17-jdk-headless

La API es una fábrica: nunca construyes un codificador; se lo pides a la clase. El lado del codificador tiene cuatro puertas, todas devuelven instancias de la clase anidada Base64.Encoder:

Método de fábrica Alfabeto Forma de salida
getEncoder() A-Z a-z 0-9 + / Con relleno, sin saltos de línea
getUrlEncoder() A-Z a-z 0-9 - _ Con relleno, sin saltos de línea
getMimeEncoder() A-Z a-z 0-9 + / Con relleno, líneas de 76 caracteres, CRLF
getMimeEncoder(int, byte[]) A-Z a-z 0-9 + / Con relleno, tu longitud de línea, tu separador

Tres propiedades valen la pena conocerlas de entrada. Las instancias son seguras para hilos, y la fábrica devuelve la misma instancia compartida en cada llamada, así que Base64.getEncoder() == Base64.getEncoder() es cierto; construye uno en un campo estático y compártelo en todas partes. Los codificadores nunca lanzan excepción sobre los datos: cada valor de byte tiene una codificación, así que no hay un estado de "entrada inválida" que manejar, y las únicas excepciones que encontrarás son de mala configuración (un mal separador de línea) o de un array de destino demasiado pequeño. Y cada codificador de esta lista añade padding por defecto; la perilla que lo apaga, withoutPadding(), aparece en la sección de base64url, porque es donde lo vas a necesitar.

Aún encontrarás bibliotecas más antiguas en las bases de código, así que aquí va un mapa rápido del terreno. Apache Commons Codec (actualmente 1.22.1) lleva su propia org.apache.commons.codec.binary.Base64 desde la 1.0, con una API de Builder que expone la política estricta-o-permisiva, la longitud de línea y el separador como perillas; es la herramienta adecuada solo si debes soportar JVM anteriores a Java 8. Guava incluye com.google.common.io.BaseEncoding, un veterano con capacidades similares, aún común en pilas de big data. Para lo que se ejecute en una JVM moderna, java.util.Base64 es el valor por defecto: cero dependencias, y los benchmarks de la comunidad siguen encontrándola la más rápida del grupo (más sobre eso en la sección de seguridad y velocidad).

Tu primera codificación

El noventa por ciento de la vida de codificación cabe en tres líneas. Aquí tienes toda la ceremonia, usando el ejemplo más pequeño que el artículo de Wikipedia sobre Base64 usa para explicar el alfabeto:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstEncode {
  public static void main(String[] args) {
    byte[] text = "Man".getBytes(StandardCharsets.UTF_8);
    String packed = Base64.getEncoder().encodeToString(text);
    System.out.println(packed); // TWFu
  }
}

La cadena TWFu es el ejemplo que el artículo de Wikipedia sobre Base64 usa para explicar el alfabeto, así que si tu codificador convierte "Man" en ella, la máquina es honesta. Pero mira la primera línea de ese ejemplo, porque es la línea donde la codificación sucede de verdad en Java. No existe ningún método encodeToString(String) a propósito. Una String de Java es una secuencia de unidades de código UTF-16, no bytes, y Base64 es un formato de bytes, así que la API te obliga a decidir la cuestión de los bytes tú mismo: "Man".getBytes(StandardCharsets.UTF_8). Esa única llamada, con un charset explícito, es donde "café" se mantiene correcto los próximos cien años, y es el hábito más importante de todo este artículo. La siguiente sección va dedicada a eso, porque la alternativa es el clásico bug de mojibake.

Dos notas sobre la segunda línea. encodeToString() devuelve una String construida a partir de los bytes codificados; el javadoc explica que construye el resultado usando el charset ISO-8859-1, lo que en la práctica no es un problema porque cada carácter de salida de Base64 es ASCII plano y se ve idéntico en Latin-1, UTF-8 y en la mayor parte del resto de la zoología de charsets. Y si prefieres tener tú el búfer de salida, encode(byte[]) devuelve un byte[] nuevo, y encode(byte[] src, byte[] dst) escribe en un destino que tú aportas, devolviendo el recuento (y lanzando IllegalArgumentException: Output byte array is too small for encoding all input bytes si el destino es corto, sin escribir ni un solo byte).

La decisión del charset

Vamos a hacer concreto el paso de cadena a bytes con el caso clásico. La palabra "café" es una palabra, pero en bytes depende enteramente del charset que eligas:

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetEncode {
  public static void main(String[] args) {
    byte[] utf8 = "café".getBytes(StandardCharsets.UTF_8);
    byte[] latin1 = "café".getBytes(Charset.forName("ISO-8859-1"));
    System.out.println(utf8.length + " vs " + latin1.length);
    // 5 vs 4: el acento son dos bytes en UTF-8, uno en Latin-1
    System.out.println(Base64.getEncoder().encodeToString(utf8));
    // Y2Fmw6k=
    System.out.println(Base64.getEncoder().encodeToString(latin1));
    // Y2Fm6Q==
  }
}

Dos cadenas Base64 distintas para una misma palabra, y ambas son "correctas" siempre que se le diga al lector qué charset usar. La lección entera cabe en una línea: el codificador es fiel a los bytes que le das, y tú eres responsable de los bytes. En la práctica eso significa: acuerda UTF-8 con tu contraparte, pasa StandardCharsets.UTF_8 explícitamente, y escribe el charset en la especificación, el esquema o el mensaje de commit, porque nadie del lado receptor puede adivinarlo del Base64 solo. El gemelo en el lado decodificador de este bug es el tema de la guía hermana.

Una nota de versión, porque cambia el modo de fallo del código perezoso. El new String(bytes) sin argumentos y el String.getBytes() sin charset usan el charset por defecto de la plataforma, que históricamente era Cp1252 en Windows y algo dependiente de la zona regional en Linux. Desde el JDK 18 (JEP 400, "UTF-8 by Default") el valor por defecto es UTF-8 en todas las plataformas, así que en una JVM moderna la forma perezosa resulta ser la correcta. Eso no la hace segura: tu código sobrevivirá al JDK para el que se escribió, y la persona que lo herede no debería tener que saber cuál es el valor por defecto. Escribe el charset.

Un detalle de diseño relacionado: no existe ninguna sobrecarga encode(String) en ninguna parte de la API, y eso es deliberado. Cada otro paso de la tubería (arrays, búferes, streams) toma bytes, y un método que aceptara String tendría que elegir un charset por ti, que es exactamente la decisión que el JDK se niega a tomar. El único método de tipo String que existe, encodeToString, está en el lado de la salida, donde la cuestión del charset no existe: la salida de Base64 es ASCII puro. La forma entera de la API es un pequeño argumento a favor de "decide tus bytes a propósito".

Padding, envoltura y la perilla MIME

Los codificadores de Java toman dos decisiones de formato por ti por defecto, y ambas valen la pena entenderlas porque ambas son perillas que puedes girar. La primera es el padding: cada codificador añade los caracteres = que hacen de la salida un múltiplo de cuatro, como pide el RFC 4648: las implementaciones DEBEN incluir los caracteres de relleno apropiados al final de los datos codificados a menos que la especificación que se refiere diga lo contrario. La segunda es la envoltura de líneas: solo el codificador MIME envuelve, a 76 caracteres con retorno de carro y salto de línea, y no añade un separador de línea después de la última línea parcial, un detalle que el javadoc señala explícitamente y otras herramientas lo hacen mal:

Codificador Rellena la salida Envuelve líneas Separador de línea
getEncoder() no n/d
getUrlEncoder() no n/d
getMimeEncoder() sí, 76 caracteres CRLF
getMimeEncoder(64, "\n") sí, 64 caracteres LF

La perilla MIME es la parte más útil de la API para quienes heredan formatos de otros. El constructor estándar es getMimeEncoder() (76, CRLF, directo del RFC 2045); la versión de dos argumentos, getMimeEncoder(int lineLength, byte[] lineSeparator), te deja reproducir otras convenciones. Las dos rarezas que hay que conocer: la longitud de línea "se redondea hacia abajo al múltiplo de 4 más cercano", así que pedir 77 te da 76 en silencio, y un valor redondeado que no sea positivo no te da ninguna envoltura; y el separador no debe contener ningún carácter del alfabeto Base64, o el constructor lanza una IllegalArgumentException al momento, porque un separador que se pueda confundir con datos es un bug esperando a pasar. Aquí va la perilla en acción, en su versión estándar MIME y con aire PEM:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WrapDials {
  public static void main(String[] args) {
    byte[] data = "Hello, wrapped world! This line keeps going and going and going until it finally has to wrap.".getBytes(StandardCharsets.UTF_8);
    Base64.Encoder mime = Base64.getMimeEncoder();
    Base64.Encoder pem = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.ISO_8859_1));
    System.out.println(mime.encodeToString(data));
    // líneas de 76 caracteres, CRLF entre ellas
    System.out.println(pem.encodeToString(data));
    // líneas de 64 caracteres, LF plano entre ellas
  }
}

Dos notas prácticas. Si tu consumidor espera que una cadena envuelta termine con un salto de línea (algunas herramientas de correo lo hacen), añádelo tú después de codificar: el JDK se detiene deliberadamente después de la última línea parcial. Y si estás produciendo datos que vivirán en una URL o un token, la envoltura es la perilla equivocada por completo; esos consumidores quieren una línea larga y normalmente sin relleno, que es la siguiente sección.

base64url y la perilla sin relleno

El Base64 estándar termina su alfabeto con + y /, y esos son exactamente los dos caracteres que no se llevan bien con las URLs: un + en una query string ya es un espacio antes de que el servidor lo parsee, una / es un separador de rutas, y un = colgando pide codificación por porcentajes hasta convertirse en un monstruo de tres caracteres. La sección 5 del RFC 4648 dibuja la solución: el alfabeto seguro para URLs y nombres de archivo, donde + pasa a ser -, / pasa a ser _, y el relleno final = se suele omitir cuando la longitud se conoce implícitamente. El RFC es tajante con el nombre: esta codificación "no debe considerarse igual a la codificación base64", y el nombre que oirás es base64url. Los JSON Web Tokens, los parámetros de estado de OAuth, los IDs de sesión de API y los IDs de vídeo de once caracteres, todos viven en este dialecto.

Java te da el alfabeto con getUrlEncoder(), pero aquí va la perilla que atrapa a la gente: el codificador seguro para URLs sigue rellenando por defecto, y los estándares de tokens no quieren relleno. El RFC 7515 es explícito en que las partes JWS usan base64url "con todos los caracteres '=' finales omitidos ... y sin la inclusión de ningún salto de línea, espacio en blanco u otro carácter adicional". Así que la receta canónica de JWT en Java es una cadena de dos métodos:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TokenParts {
  public static void main(String[] args) {
    Base64.Encoder url = Base64.getUrlEncoder().withoutPadding();
    byte[] header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8);
    byte[] payload = "{\"sub\":\"1234567890\",\"name\":\"John Doe\"}".getBytes(StandardCharsets.UTF_8);
    System.out.println(url.encodeToString(header));
    // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    System.out.println(url.encodeToString(payload));
    // eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0
  }
}

La llamada withoutPadding() devuelve una nueva instancia de codificador que se comporta idénticamente excepto que omite los rellenos finales; la original queda intacta, y el javadoc lo dice con precisión. El lado del decodificador acepta tanto entrada con relleno como sin, así que un valor que produces sin relleno seguirá siendo legible por un decodificador estricto, que es por qué sin relleno es la opción segura para lo que cruce un límite de API. Ahora, una gran advertencia: las dos partes de arriba son las mitades sin firmar de un JWT. Un token real necesita una firma calculada sobre "header.payload", y eso es criptografía, no codificación. En producción, acuña y verifica tokens con una librería JOSE: JJWT (0.13.0) o nimbus-jose-jwt (10.9.1). El artefacto de API de JJWT, por ejemplo, está a una coordenada:

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.13.0</version>
</dependency>
<!-- añade jjwt-impl y jjwt-jackson en tiempo de ejecución, según la documentación del proyecto -->

Los IDs de YouTube son la otra cara de esta perilla: once caracteres de base64url sin relleno, un identificador que tiene que sobrevivir a ser pegado en cualquier parte donde se permita una URL. Si tu sistema genera identificadores que viajan en URLs, la cadena withoutPadding() de arriba es la forma a copiar.

Codificando archivos

El trabajo de archivo cotidiano es el espejo del favorito del decodificador: lee un archivo, codifícalo, escribe el texto. Cuatro líneas con java.nio.file:

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class EncodeFile {
  public static void main(String[] args) throws Exception {
    byte[] raw = Files.readAllBytes(Paths.get("report.pdf"));
    String packed = Base64.getEncoder().encodeToString(raw);
    Files.write(Paths.get("report.pdf.b64"), packed.getBytes(StandardCharsets.ISO_8859_1));
    System.out.println(raw.length + " -> " + packed.length());
  }
}

Ese último print es la factura del 33 por cien, hecha visible. Un archivo de 1 MB se convierte en aproximadamente 1,33 MB de texto (4/3 del original, más como mucho dos caracteres de relleno), y si lo envolviste al estilo MIME, los saltos de línea añaden un par de porcentajes más: la vieja matemática de la época del correo, aún cierta, es 4/3 veces 78/76, o sea unas 1,37 veces el original para un payload MIME envuelto. Dos consecuencias. Primera, dimensiona cualquier almacenamiento o campo de mensaje desde la longitud codificada, no desde la cruda: una columna VARCHAR(255) que aguanta feliz un valor crudo de 192 bytes rechazaría su codificación de 256 caracteres. Segunda, la dirección de codificación es la que empeora la memoria, así que para archivos grandes la versión de array es la herramienta equivocada y la sección de streaming es la correcta. Un pequeño placer para el público de archivos: porque los primeros caracteres de salida son una función pura de los primeros bytes de entrada, cada PNG codificado en Base64 empieza por iVBORw0K y cada GIF codificado por R0lGOD; puedes reconocer el tipo de archivo antes de que se decodifique un solo byte.

JSON, APIs y data URIs

Dos de los sitios más comunes donde la salida codificada vive en el cable.

Uno: binario dentro de JSON. Los endpoints de subida de archivos, las APIs de contenido, los almacenes de secretos y los webhooks incrustan binario como texto Base64 dentro de JSON, porque los bytes en crudo romperían el escapado de cadenas JSON. El lado del codificador es de una línea en el límite, y la única decisión es qué dialecto pide la especificación:

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class JsonField {
  public static void main(String[] args) throws Exception {
    byte[] image = Files.readAllBytes(Paths.get("logo.png"));
    // La especificación dice base64url, sin relleno:
    String field = Base64.getUrlEncoder().withoutPadding().encodeToString(image);
    // Entrega "field" a tu librería JSON como un valor de cadena normal.
    System.out.println(field.length());
  }
}

La trampa no es codificar; es leer la especificación. Algunas APIs quieren Base64 estándar con relleno, otras quieren base64url sin, y unas pocas son permisivas con las dos. Cuando la especificación calla, la solución más barata es mirar un valor de ejemplo del otro lado: un - o un _ en cualquier parte resuelve el alfabeto, y los = finales resuelven el relleno. Equivocarse de dialecto no suele hacer explotar al otro lado; suele corromper el archivo, que es el tipo de bug más lento de encontrar.

Dos: data URIs. La cadena data:image/png;base64,... que incrusta una imagen en HTML o CSS es el data URI del RFC 2397: data:, un tipo de medio opcional, un flag ;base64 opcional, una coma, y luego los datos. Construir uno es concatenación de cadenas, y la única decisión es si el flag está ahí (sin flag, el payload es texto codificado por porcentajes, lo que nadie quiere para binario):

import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DataUriBuild {
  public static void main(String[] args) throws Exception {
    byte[] icon = Files.readAllBytes(Paths.get("icon.png"));
    String b64 = Base64.getEncoder().encodeToString(icon);
    String uri = "data:image/png;base64," + b64;
    System.out.println(uri.substring(0, Math.min(40, uri.length())) + "...");
    // data:image/png;base64,iVBORw0KGgo...
  }
}

El propio consejo del RFC aplica con interés: los data URIs son para valores cortos. Incrustar un icono de 50 KB es un intercambio normal (una petición menos); incrustar una foto de 5 MB es un bug de rendimiento con disfraz de comodidad. Mantén el flag, mantén el tipo de medio honesto, y mantén los bytes pequeños.

Construyendo la cabecera Basic Auth

La cabecera de autenticación más antigua de la web sigue siendo el caso de uso más fácil de Base64 en Java, porque es exactamente una llamada de codificación. Según el RFC 7617, una petición Basic envía Authorization: Basic seguido de la codificación Base64 de username:password; el propio ejemplo del RFC, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, es "Aladdin:open sesame" llevando un disfraz. En el lado del cliente, construir la cabecera son dos líneas de Base64 más una llamada HTTP moderna:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuthClient {
  public static void main(String[] args) throws Exception {
    byte[] credentials = ("alice:secret123").getBytes(StandardCharsets.UTF_8);
    String header = "Basic " + Base64.getEncoder().encodeToString(credentials);
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("https://example.com/api/status"))
      .header("Authorization", header)
      .GET()
      .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.statusCode());
  }
}

Tres precauciones le corresponden a esta cabecera. Primera, el RFC es explícito en que Basic es codificación, no protección: las credenciales son legibles por cualquiera que pueda ver los paquetes, así que esta cabecera es tan fuerte como el HTTPS que hay debajo, y es una mala idea en cualquier cosa que no sea TLS. Segunda, el charset: el RFC espera credenciales US-ASCII (UTF-8 para lo demás, y el parámetro de autenticación charset es orientativo), así que elige StandardCharsets.UTF_8 y mantén la consistencia en los dos lados. Tercera, una nota de versiones: el cliente de java.net.http es de Java 11; en una JVM más antigua la misma cabecera va en un HttpURLConnection con una llamada a setRequestProperty, y la línea de Base64 es idéntica en ambos casos. En el lado del servidor de la misma cabecera, parsear y decodificar es el ejemplo de la guía hermana, con la división en el primer signo de dos puntos y la comparación de tiempo constante. Los dos lados son dos llamadas de la misma API, que es la elegancia silenciosa de esta.

Valores en configuraciones, variables de entorno y columnas

Base64 es un contenedor de texto, y por eso aparece en sitios donde no te lo esperarías: un DSN de base de datos con punto y coma en un archivo de entorno, una contraseña con comillas en un archivo de propiedades, un certificado de varias líneas en un map de configuración, un blob binario en una columna TEXT porque el esquema se diseñó antes de que nadie considerara los BLOBs. El lado de la codificación es una llamada, y el encuadre honesto es lo que es: un truco de seguridad de formato, no un truco de secreto:

import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigEncode {
  public static void main(String[] args) {
    String dsn = "pg:host=db;password=qu\"ote";
    byte[] raw = dsn.getBytes(StandardCharsets.UTF_8);
    String packed = Base64.getEncoder().encodeToString(raw);
    System.out.println(packed);
    // cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
    System.out.println("DB_DSN_B64=" + packed);
  }
}

Dos reglas mantienen esto honesto. Primera, nunca almacenes un secreto como Base64 y lo llames cifrado: Base64 no añade entropía ni quita información, en el momento en que un desarrollador lee el archivo puede decodificar el valor en una llamada, y la sección de seguridad del RFC apunta exactamente a este fallo, personas que revelan credenciales pegando intercambios de protocolo "codificados". Si el valor es secreto, cifralo antes, y solo entonces empaqueta el texto cifrado en Base64 si el canal exige texto. Segunda, presupuesta el tamaño: el valor almacenado es un tercio más grande que el original, y una columna o campo que cabía el valor crudo no cabrá el codificado. Y cuando el valor vuelva, decodifícalo en el límite y guárdalo como bytes (para binario) o cadena con charset explícito (para texto); esa dirección es terreno de la guía hermana.

Streaming para datos grandes

La codificación es la dirección que empeora la memoria, así que la historia de los archivos grandes aquí va de mantener el conjunto de trabajo pequeño. La versión de array del ejemplo de la sección de archivos va bien hasta el punto en que el archivo deja de caber cómodamente en memoria; más allá de eso, el adaptador de stream es la jugada. wrap(OutputStream) devuelve un stream de salida que codifica mientras escribes, así que un archivo de varios gigabytes nunca se mantiene como un único array de bytes:

import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamEncode {
  public static void main(String[] args) throws Exception {
    OutputStream packed = Base64.getEncoder().wrap(Files.newOutputStream(Paths.get("bigfile.b64")));
    InputStream raw = Files.newInputStream(Paths.get("bigfile.bin"));
    byte[] buf = new byte[8192];
    int n;
    while ((n = raw.read(buf)) != -1) {
      packed.write(buf, 0, n);
    }
    packed.close();
    raw.close();
  }
}

Hay un comportamiento en este stream que merece un foco, porque el propio javadoc apunta a él: el stream envuelto puede mantener internamente unos pocos bytes sobrantes, y la práctica recomendada es "cerrar de inmediato el stream de salida devuelto tras el uso, durante lo cual volcará todos los bytes sobrantes posibles al stream de salida subyacente". Si dejas de escribir y lees el archivo de salida antes de cerrar, la cola de tus datos sigue sentada en el codificador, y el archivo parece truncado. Por eso el ejemplo cierra packed antes de que nada más toque el archivo, y en producción pondrías los dos streams en un bloque try-with-resources. Hazte al hábito: en el stream de codificación, cerrar es parte de codificar.

Conociendo a la vieja guardia

Las bases de código heredadas están llenas de APIs de Base64 anteriores a java.util.Base64, y reconocerlas te libra de los misterios de "por qué envuelve esto mi salida". Las cuatro que de verdad vas a encontrar:

API Dónde la encontrarás Qué hacer
sun.misc.BASE64Encoder / BASE64Decoder Código pre-Java-8 Migra a java.util.Base64; eliminada en Java 9
javax.xml.bind.DatatypeConverter Código de la era XML, viejos web services Eliminado en Java 11 (JEP 320); migra
org.apache.commons.codec.binary.Base64 Código que debe correr en JVM pre-8 Mantén para soporte pre-8; en otro caso la clase del JDK es el valor por defecto
com.google.common.io.BaseEncoding Pilas cargadas de Guava y de big data Funciona bien; la clase del JDK no tiene dependencias

La pareja de sun.misc es la que trae drama. Era una API interna, sin soporte (el tipo que compila bien en el JDK del momento y desaparece sin un aviso de deprecación), y su salida tenía sus propios hábitos, como envolver el texto codificado en líneas, que es de donde viene un número sorprendente de bugs de "mi Base64 tiene saltos de línea en él". Cuando Java 9 salió en septiembre de 2017, la limpieza del sistema de módulos la eliminó, y la guía oficial de migración no usa palabras suaves: "Cabe destacar que sun.misc.BASE64Encoder y sun.misc.BASE64Decoder fueron eliminadas. En su lugar, usa la clase java.util.Base64 soportada, que se añadió en el JDK 8". Si ejecutas jdeps sobre código que aún referencia las clases viejas, la herramienta marca la dependencia como "JDK removed internal API", que es lo más cercano a un cono de tráfico al que llega el JDK. El DatatypeConverter de JAXB tuvo una vida más larga pero similar, deprecado con los módulos de Java EE en la era de Java 9 y eliminado de plano en Java 11 por el JEP 320, "Remove the Java EE and CORBA Modules". Ambas migraciones son mecánicas: las viejas llamadas printBase64Binary y BASE64Encoder().encode mapean uno a uno sobre getEncoder().encodeToString, salvo por las diferencias de envoltura, y una vez que el código está en java.util.Base64 corre en cada JDK del 8 al 26 sin más pensamiento.

Seguridad y velocidad

La sección de seguridad es corta, porque el trabajo del codificador no puede fallar con los datos, pero no está vacía. Base64 no es cifrado, y el estándar lo dice en palabras textuales: 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", y la misma sección señala que esto "ha causado incidentes de seguridad". Las consecuencias prácticas para el lado del codificador: no codes un secreto para hacerlo seguro (ahora es menos seguro, porque cabe en más canales); si el valor es secreto, cifra antes y codifica el texto cifrado; y ten presente el gemelo de la maleabilidad, donde un receptor puede intercambiar una grafía válida por otra (padding distinto, basura en los bits de sobra) sin cambiar los datos decodificados. Un codificador determinista ayuda aquí: java.util.Base64 produce exactamente una salida para exactamente una entrada, así que si tu propio sistema tanto escribe como lee un valor, la grafía es estable, y son los valores externos en el límite de confianza los que necesitan comprobación de forma canónica.

En velocidad, el lado del codificador tiene la misma historia que el lado del decodificador: en una JVM moderna la implementación integrada es lo bastante rápida para que Base64 casi nunca sea el cuello de botella, y es el punto de referencia del benchmark. El mismo benchmark de gRPC-java de 2025 mencionado en la guía hermana (issue 11857, JMH en JDK 17 y 21) situó el codificador del JDK en unas 2,5 a 3,8 veces el caudal del de Guava, con la mayor brecha en x86. Dos notas prácticas: para las rutas calientes, comparte una instancia de codificador (la fábrica ya devuelve la misma compartida) y prefiere encode(byte[], byte[]) a un array predimensionado para saltarte la asignación; para datos enormes, la sección de streaming es la historia de la memoria, y el coste de la envoltura es ruido junto al disco. El único impuesto de rendimiento real en Base64 es el tamaño en sí, y ninguna implementación, incluida esta, puede negociar bajarlo.

La lista de trampas

Cada trampa recogida en un solo sitio, todas específicas de Java:

  • El charset que falta. text.getBytes() sin charset explícito usa el valor por defecto de la plataforma: correcto por accidente en JDK 18+, equivocado en cualquier cosa más antigua, y equivocado en principio en todas partes. Pasa StandardCharsets.UTF_8 y escribe el charset en la especificación.
  • El JWT con relleno. getUrlEncoder() rellena por defecto, y los tokens no quieren relleno. La llamada withoutPadding() es parte de la receta, no un extra opcional; un token con = finales es un token que algunos validadores rechazarán y otros destrozarán.
  • La salida envuelta. El codificador MIME envuelve a 76 con CRLF y no añade un salto de línea final. Si el consumidor espera un salto final, añádelo; si el consumidor no espera ningún salto, no uses el codificador MIME.
  • La doble codificación. Codificar un valor que ya es Base64 produce una cadena perfectamente válida y perfectamente inútil. La causa clásica: un campo llega precodificado desde una API y tu código "amablemente" lo codifica otra vez. Comprueba antes de codificar.
  • Signos de más en URLs. La salida de Base64 estándar contiene +, que en una query string es un espacio antes de que el servidor lo vea. Si un valor de alfabeto estándar debe viajar en una URL, codifícalo por porcentajes, o genéralo en el alfabeto seguro para URLs desde el principio.
  • La factura del 33 por cien. Un valor que cabe en la columna cruda no cabrá en la codificada. Dimensiona almacenamiento, campos de mensaje y cabeceras desde 4 * ceil(n / 3), y recuerda que la salida MIME envuelta es un par de porcentajes por encima.
  • El stream sin cerrar. El stream de salida envuelto mantiene los bytes sobrantes hasta el cierre. Leer el archivo antes del cierre te da una codificación truncada. Try-with-resources, cada vez.
  • Secretos en plena vista. Base64 es cinta adhesiva, no un candado. Credenciales codificadas en un archivo de configuración, un log o una variable de entorno son credenciales legibles. Cifra antes, o no cifres.
  • El muro de Android. En Android, java.util.Base64 solo existe desde el nivel de API 26; por debajo, la clase del framework es android.util.Base64 con sus propias constantes de flag (NO_PADDING, URL_SAFE y NO_WRAP). Fijar una sin comprobar falla en exactamente los dispositivos que nunca probaste.
  • La rareza de la longitud de línea. getMimeEncoder(77, ...) envuelve a 76 en silencio, porque la longitud se redondea hacia abajo a un múltiplo de cuatro, y pedir 3 o menos desactiva la envoltura por completo. Si tu formato exige una longitud de línea impar, la perilla MIME no es la herramienta.

De sun.misc a la biblioteca estándar

La historia de Java es corta, con un antes y un después claros. Antes de 2014, si necesitabas Base64 dentro del JDK te salía la pareja interna sun.misc.BASE64Encoder y sun.misc.BASE64Decoder, sin soporte desde el primer día, con sus propios hábitos de envoltura a 76 caracteres, o recurrías a javax.xml.bind.DatatypeConverter en código XML, o añadías Apache Commons Codec o Guava al build, que es como muchas bases de código empresariales acabaron con tres implementaciones de Base64 y sin idea de cuál era cuál. El 18 de marzo de 2014, Java 8 publicó java.util.Base64: una clase, tres alfabetos, las reglas del RFC 4648 y el RFC 2045 implementadas a conciencia, el patrón de fábrica, las perillas de padding y envoltura, y adaptadores de stream en las dos direcciones. Era el Base64 que el lenguaje debería haber tenido desde el principio, y el javadoc ha dicho Since: 1.8 desde entonces.

La limpieza llegó en dos oleadas. Java 9 (21 de septiembre de 2017) eliminó la pareja de sun.misc como parte de la limpieza del sistema de módulos, con la guía de migración señalando a cada desarrollador hacia la clase del JDK 8, y Java 11 eliminó el módulo JAXB y su DatatypeConverter con él (JEP 320). Java 18 (22 de marzo de 2022) aterrizó el JEP 400, "UTF-8 by Default", que no tocó Base64 en absoluto pero cambió el modo de fallo de las llamadas perezosas a getBytes() que la alimentan: el charset por defecto de la plataforma se volvió UTF-8 en cada sistema operativo, así que los viejos patrones de mojibake simplemente dejaron de reproducirse en JVM nuevas. Desde 1.8 la API pública no ha cambiado ni un solo método. Lo que ha movido es el motor de debajo: correcciones de bugs y trabajo de rendimiento, que es por qué los benchmarks de la comunidad siguen encontrando a la versión de la biblioteca estándar superando a las bibliotecas de legado que reemplazó. Hoy, en cualquier JDK del 8 al 26, la respuesta a "¿cómo hago Base64 de esto en Java?" es un import y una llamada de fábrica, y lo ha sido por más de una década.

Unas pocas delicias nerd

Porque un manual debería terminar con una sonrisa, aquí van algunos hechos específicos de Java que son simplemente divertidos:

  • El javadoc dice Since: 1.8, y ha sido cierto durante doce años. Ni un método añadido, ni uno quitado, ni un comportamiento cambiado: una de las superficies de API que más tiempo ha pasado congelada en el lenguaje, y la usas sin pensarlo.
  • encodeToString construye su cadena resultado con el charset ISO-8859-1, según el javadoc. Es un detalle completamente innecesario en la práctica, porque la salida de Base64 es ASCII puro y se ve igual en Latin-1, UTF-8 y en la mayor parte del resto de la zoología de charsets, pero el javadoc te lo cuenta igualmente, que es el JDK siendo el JDK.
  • El codificador MIME no añade un separador de línea después de la última línea parcial. Otras herramientas, incluidas algunas bibliotecas de correo muy famosas, terminan la salida envuelta con un CRLF final. Si tu diff contra una implementación de referencia es de exactamente dos caracteres al final, has encontrado esta rareza.
  • Pídele a getMimeEncoder líneas de 77 caracteres y te da 76: la longitud de línea se redondea hacia abajo al múltiplo de cuatro más cercano, en silencio, porque una envoltura que dividiera un grupo de cuatro caracteres produciría basura. La API se niega a construir una línea rota antes que pedirte permiso.
  • Base64.getEncoder() == Base64.getEncoder() es cierto. Los métodos de fábrica devuelven la misma instancia compartida en cada llamada, así que la API de "consigue una nueva" es un disfraz para un singleton, y la promesa de seguridad para hilos es solo una descripción de lo que la JVM ya está haciendo.
  • En Android, la API gemela android.util.Base64 expone las mismas decisiones como flags: NO_PADDING, URL_SAFE, NO_WRAP. Dos APIs, una tabla de decisiones, que es un testimonio silencioso de lo asentado que está el diseño de Base64 por ahora.
  • La sección 5 del RFC 4648 es donde nace el nombre "base64url": la especificación dice que la codificación segura para URLs "puede ser referida como base64url" y advierte que "no debe considerarse igual a la codificación base64". Su origen se anota al pie a un post de 2001 en una lista de correo de P2P-hackers, así que el nombre de cada URL que pegas tiene un linaje de lista de correo.
  • Codifica la palabra base64 y obtienes YmFzZTY0, sin relleno, porque seis es múltiplo de tres. Un formato describiéndose a sí mismo es el equivalente técnico de un espejo que habla en Morse, y este es el reflejo propio del espejo.
  • Ejecuta jdeps -jdkinternals sobre código pre-Java-8 y mírale marcar sun.misc.BASE64Encoder como "JDK removed internal API". El ejemplo de la herramienta en la guía oficial de migración es una clase de Base64, que es el JDK señalando tus imports y diciendo "esto lo hemos hablado".
  • El factor 1,37. Cada payload MIME envuelto cuesta unas 1,37 veces su tamaño original (4/3 por el alfabeto, 78/76 por el ritmo CRLF), una fracción tan estable que la vieja matemática del correo aún la cita: el peaje que la infraestructura de correo de los 90 cobraba en cada adjunto es exactamente la factura que getMimeEncoder() cobra hoy.

Tomando el otro camino

Eso es la parte del codificador de la historia, y es la más tranquila de las dos: el trabajo nunca falla con los datos, las trampas van sobre tus decisiones (charset, padding, envoltura, dialecto) y no sobre las sorpresas de los demás, y la API entera cabe en un import. La otra dirección es donde Base64 deja de ser cómodo y empieza a ser adversarial, porque decodificar es donde te encuentras con las elecciones de padding de los demás, sus saltos de línea, sus charsets y su armadura, con una IllegalArgumentException de pie entre tú y la verdad. La decodificación Base64 en Java, enlazada desde esta página, cubre el decodificador con la misma profundidad: las tres personalidades de decodificador, los mensajes de error exactos, las reglas de padding, el base64url y los JWT, el MIME y el PEM, y las trampas específicas de Java reunidas en un solo sitio. Lee las dos como pareja y el tema entero es tuyo.

Última actualización: 2026-09-08

Artículo relacionado: Decodificación Base64 en Java: una guía completa