Decodificación Base64 en Java: una guía completa
Te llega en un ticket de soporte, una respuesta de API, un secreto de Kubernetes o enterrado en mitad de una URL: una ristra larga de letras y dígitos con el ocasional +, /, - o _, y quizás uno o dos signos = colgando al final. Alguien te dice que es Base64 y que contiene algo que necesitas: una contraseña, un payload en JSON, un certificado, una foto. Esta guía es la receta en Java para recuperarlo. Orientación rápida, porque la página de inicio recorre el formato a fondo: Base64 reescribe cada tres bytes de datos como cuatro caracteres tomados de un alfabeto de 64 letras, y añade uno o dos rellenos = al final cuando el último bloque queda corto. Decodificar es la mitad que encoge de esa operación: entran cuatro caracteres, salen tres bytes, así que el resultado siempre necesita un cuarto menos de espacio que la entrada.
Aquí va 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, siete métodos de fábrica, tres alfabetos, 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.
Un límite honesto antes de empezar: esta es la parte del decodificador de la historia. Aprenderás a elegir el decodificador adecuado para el alfabeto que te encuentres, a leer los mensajes de error del JDK como un médico lee una analítica, a convertir bytes en texto sin mojibake, a retirar la armadura PEM, a procesar payloads de varios gigabytes con streams, y a detectar las trampas de seguridad que el formato va dejando plantadas sin hacer ruido. La otra dirección, empaquetar bytes en una cadena, tiene su propia guía, y está enlazada al final de esta.
Lo que ya tienes en la mano
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 en 2026. 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 decodificador; se lo pides a la clase. Los siete métodos de fábrica reparten tres personalidades en cada dirección, y el lado del decodificador se ve así:
| Método de fábrica | Alfabeto | Carácter | Úsalo cuando |
|---|---|---|---|
getDecoder() |
A-Z a-z 0-9 + / |
Estricto: rechaza cualquier carácter fuera del alfabeto | Datos que tú produces o controlas |
getUrlDecoder() |
A-Z a-z 0-9 - _ |
Estricto, alfabeto seguro para URLs | JWT, tokens, IDs, lo que nace en una URL |
getMimeDecoder() |
A-Z a-z 0-9 + / |
Permisivo: salta todos los caracteres que no son del alfabeto | Correo electrónico, entradas realmente envueltas en líneas, cuerpos PEM |
getEncoder(), getUrlEncoder(), getMimeEncoder() |
como arriba | Codificación, terreno de la guía hermana | Cada vez que produces Base64 en lugar de leerlo |
Tres propiedades de las instancias devueltas merecen la pena memorizarlas. Primera, son seguras para hilos: el javadoc dice que las instancias son "seguras para su uso por múltiples hilos concurrentes", y el código fuente muestra que los métodos de fábrica devuelven la misma instancia compartida en cada llamada, así que Base64.getDecoder() == Base64.getDecoder() es cierto. Construye un decodificador en un campo estático y compártelo en todo tu servicio; ni siquiera estás copiando nada. Segunda, no guardan estado entre llamadas, así que no hay nada que reiniciar y nada con lo que sincronizar. Tercera, pasar null donde se espera un array de bytes o una cadena no es una operación inofensiva: es una NullPointerException, tal y como promete el javadoc de la clase.
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 o quieres sus ayudantes de comprobación de forma. Guava incluye com.google.common.io.BaseEncoding, un veterano con capacidades similares, aún popular en pilas de big data. Para lo que se ejecute en una JVM moderna, java.util.Base64 es la elección 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 rendimiento).
Decodificando tu primera cadena
El noventa por ciento de la vida de decodificación cabe en un puñado de líneas. Aquí tienes toda la ceremonia, usando el ejemplo más pequeño que el propio RFC usa para explicar el alfabeto:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstDecode {
public static void main(String[] args) {
byte[] bytes = Base64.getDecoder().decode("TWFu");
String text = new String(bytes, StandardCharsets.UTF_8);
System.out.println(text); // Man
}
}
Cuatro frases sobre lo que acaba de pasar. Primera, el punto de entrada es una instancia, no la clase: decode() vive en el objeto Base64.Decoder que obtuviste de la fábrica. Segunda, y esta es la decisión de diseño más importante de toda la API, el resultado es un array de bytes, nunca una String. El payload puede ser una frase, un JPEG o un hash, y ninguno de ellos debería tratarse igual antes de saber qué tienes, así que el JDK se detiene en los bytes a propósito. Tercera, el salto de bytes a texto es un paso separado y deliberado, con un charset explícito, y ese paso es donde "café" se convierte en mojibake si te despistas; la sección de charset de más abajo va dedicada a eso. Cuarta, la cadena vacía es un valor de primera categoría: Base64.getDecoder().decode("") te da un array de longitud cero, sin excepción, sin drama.
Para el dato de prueba que llevas en la cabeza, recuerda que TWFu es el propio test de humo del estándar: si tu código de decodificación lo convierte en Man, la máquina es honesta. El viaje de ida y vuelta en la otra dirección es de dos líneas de la misma API, y recibe el tratamiento completo en la guía de codificación enlazada al final.
El plantel de decodificadores
Java no te da un decodificador; te da tres, y la diferencia entre ellos es una decisión de política sobre qué alfabeto aceptar y cuánto desorden tolerar. Los tres son instancias de la misma clase anidada Base64.Decoder. El javadoc de la clase explica la división con una frase por carácter. Para los decodificadores básico y seguro para URLs: el decodificador "rechaza datos que contengan caracteres fuera del alfabeto base64". Para el decodificador MIME: "todos los separadores de línea u otros caracteres no encontrados en la tabla del alfabeto base64 se ignoran en la operación de decodificación". Esa segunda frase es la historia entera del MIME en una línea, y tiene colmillos, porque "ignorar" significa todo lo que no sea un carácter del alfabeto, no solo los saltos de línea.
La regla de elección es corta. Por defecto, getDecoder(). Si el valor viene de una URL, un token o una API que prometió "seguro para URLs", cambia a getUrlDecoder(). Solo cuando esperas de verdad una entrada con forma de MIME (saltos de línea cada 76 caracteres, directo de un sistema de correo) recurres a getMimeDecoder(). Ante la duda, elige estricto: el trabajo de un decodificador estricto es hacer que las sorpresas fallen, y eso es exactamente lo que quieres en un límite de confianza. Un decodificador permisivo, en cambio, es una lupa para la corrupción: una cadena con caracteres sueltos se decodifica a algo verosímil y equivocado, sin ningún error.
Leiendo las quejas del decodificador
Los decodificadores estrictos fallan a gritos, y fallan con precisión. Cada entrada mala lanza una IllegalArgumentException cuyo mensaje te dice exactamente qué salió mal, así que la primera vez que una cadena de producción explota, esta tabla es lo que lees. Los mensajes de abajo son la redacción exacta del JDK actual:
| Entrada (para getDecoder salvo indicación) | Qué falla | Mensaje exacto |
|---|---|---|
"SGVs bG8s" |
un espacio se coló | Illegal base64 character 20 |
"SGVs\nbG8s" |
un salto de línea se coló | Illegal base64 character a |
"SGVs$bG8s" |
el signo de dólar no está en el alfabeto | Illegal base64 character 24 |
"SGVsbG8-" |
un guion seguro para URLs en el decodificador estándar | Illegal base64 character 2d |
"ab+c" para getUrlDecoder() |
un signo de más en el decodificador seguro para URLs | Illegal base64 character 2b |
"S" |
un símbolo solo no puede formar un byte | Input byte[] should at least have 2 bytes for base64 bytes |
"SG=VsbG8s" |
padding en mitad de los datos | Input byte array has wrong 4-byte ending unit |
"Zm8==" |
dos rellenos donde toca uno | Input byte array has incorrect ending byte at 4 |
"Z=" |
un carácter seguido de un relleno | Last unit does not have enough valid bits |
"SGVsbG8sIHdvcmxkIQ==xx" |
basura después de los rellenos | Input byte array has incorrect ending byte at 20 |
Ese número hexadecimal del mensaje es el valor de byte del carácter ofensor, impreso con Integer.toString(byte, 16): 20 es un espacio, a es un salto de línea, d es un retorno de carro, 24 es el signo de dólar, 2d es el guion seguro para URLs, 2b es el más, 2f la barra, 5f el guion bajo. Dos rarezas para guardar en el bolsillo. Primera, el mensaje puede dar negativo: le das al decodificador una cadena que contiene é y se queja con Illegal base64 character -17, porque el carácter se mapea primero al byte Latin-1 0xE9, que como byte Java con signo es menos 23, y menos 23 en hexadecimal es menos 17. Tu registrador de errores, brevemente, está haciendo aritmética con signo. Segunda, la posición: en la familia incorrect ending byte at N, N es el índice desde cero del primer byte que el decodificador no supo interpretar, y eso es un regalo cuando estás bisecando un payload corrupto.
Un cambio de disfraz que conviene conocer: cuando la decodificación pasa por el stream envuelto (la variante wrap(InputStream), cubierta más abajo), los mismos problemas aparecen como una IOException esta vez con prefijo 0x: Illegal base64 character 0x20 (JDK actuales; el decodificador de streams del JDK 8 imprime el valor de la búsqueda, -1, en lugar del byte). Mismo problema, excepción distinta, ortografía ligeramente distinta. Y el decodificador MIME permisivo, por supuesto, no se queja de nada de esto: simplemente salta. Ese es el precio del carácter permisivo.
Las reglas del padding
Cada cadena Base64 que anda por ahí hace una promesa silenciosa sobre el padding, y la promesa de Java es inusualmente amable. El javadoc del decodificador lo dice al pie de la letra: el carácter de relleno = "se acepta e interpreta como el fin de los datos de bytes codificados, pero no es obligatorio". Una unidad final de dos o tres caracteres se decodifica como si hubiera estado rellena, y cuando hay rellenos deben estar en la cantidad exacta. El comportamiento del JDK actual con los ejemplos clásicos:
| Entrada | Resultado |
|---|---|
"" |
array de bytes vacío, sin error |
"Zm8" |
"fo", el padding simplemente ausente |
"Zm8=" |
"fo", la grafía canónica |
"Zm8==" |
IllegalArgumentException: incorrect ending byte at 4 |
"Zm9v=" |
IllegalArgumentException: wrong 4-byte ending unit |
"Zg==" |
"f", un byte |
"Z=" |
IllegalArgumentException: last unit does not have enough valid bits |
"AA==" |
exactamente un byte, el byte NUL 0x00 |
"AAAA" |
tres bytes NUL |
Lee esa tabla dos veces. La cadena vacía se decodifica a nada, mientras que AA== se decodifica a un único byte NUL: en Base64, "nada" y "un cero" son criaturas distintas, y ambas son entradas perfectamente válidas. Y el padding, cuando está, debe ser exacto: Zm8= es correcto, Zm8== es incorrecto, Zm9v= es incorrecto, y un relleno en mitad de la cadena es incorrecto. Consecuencia práctica para tus propios protocolos: elige una grafía (con relleno o sin) e impónla en los dos extremos, porque un valor que puede llegar en dos grafías es un valor que puede romper una comprobación de igualdad ingenua en algún punto del camino.
base64url: el alfabeto construido para URLs
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 Java lo vea, 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". Lo encontrarás como base64url, y es donde viven 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.
El payload base64url más famoso de la web es el JWT, y asomarse por dentro de uno es un trabajo de tres líneas. Las partes del token son convencionalmente sin relleno, y el decodificador de URLs está contento con eso, porque el relleno se acepta pero no es obligatorio, recuerda:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtPeek {
public static void main(String[] args) {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
+ ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
String[] parts = token.split("\\.");
byte[] header = Base64.getUrlDecoder().decode(parts[0]);
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
System.out.println(new String(header, StandardCharsets.UTF_8));
// {"alg":"HS256","typ":"JWT"}
System.out.println(new String(payload, StandardCharsets.UTF_8));
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
}
}
Aquí viven dos advertencias honestas. Primera, decodificar un JWT es asomarse, no confiar: la tercera parte es una firma, y las dos partes que acabas de leer no son secretas ni autenticadas. Confiar en un payload antes de verificar su firma es el bug clásico de los JWT, y la solución es entregar la verificación a una librería JOSE como JJWT (0.13.0) o nimbus-jose-jwt (10.9.1) en lugar de inventar tu propia criptografía. Segunda, los errores identifican la dirección: le pasas una cadena de alfabeto estándar a getUrlDecoder() y obtienes Illegal base64 character 2b o 2f, y al revés te salen 2d o 5f. El desfase de alfabetos es el fallo de decodificación Base64 más común que existe en el mundo real, y el mensaje de error apunta a él en un abrir y cerrar de ojos. Si un token en una query string debería ser Base64 estándar, su + y su / probablemente fueron destrozados por el transporte antes de llegar a ti, y el error de decodificación te está contando un bug aguas arriba, no en tu decodificador.
De bytes a palabras
Cada llamada de decodificación de este artículo se detiene en los bytes a propósito, porque Base64 es un formato de bytes, y punto. La pregunta "¿qué texto era eso?" es tuya, y la respuesta por defecto moderna es UTF-8. Aunque hay un detalle de charset en el lado de decodificación de la API que sorprende, así que aquí va. La sobrecarga decode(String) no interpreta tu cadena como UTF-8. El javadoc lo dice al pie de la letra: una invocación "tiene exactamente el mismo efecto que invocar decode(src.getBytes(StandardCharsets.ISO_8859_1))". Eso no es un bug - es un truco: el alfabeto Base64 es ASCII puro, así que mapear la cadena a través de Latin-1 le entrega al decodificador exactamente los mismos bytes con cero coste de conversión, y cualquier carácter no ASCII en la entrada simplemente se convierte en un símbolo inválido que el decodificador estricto rechaza (de ahí vienen los números hexadecimales negativos en los mensajes de error).
El charset del payload es una decisión completamente separada, la del paso new String(bytes, charset). Aquí va el caso clásico: "café" en UTF-8 son los cinco bytes 63 61 66 C3 A9, que codificados dan Y2Fmw6k=:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetDecode {
public static void main(String[] args) {
byte[] packed = Base64.getDecoder().decode("Y2Fmw6k=");
System.out.println(new String(packed, StandardCharsets.UTF_8));
// café, el acento sobrevive
System.out.println(new String(packed, StandardCharsets.ISO_8859_1));
// caf seguido de mojibake, los bytes UTF-8 mal leídos como Latin-1
}
}
Esa segunda línea es el modo de fallo que hay que reconocer al instante: un payload UTF-8 leído a través de Latin-1, que produce una cadena exactamente un carácter más larga y un byte fuera. La cura es siempre acordar un charset con el productor y pasarlo explícitamente. Y pasarlo explícitamente en el código, no solo en tu cabeza: el constructor new String(bytes) sin argumentos usa el charset por defecto de la plataforma, que en un servidor Windows puede ser Cp1252 y en un Linux más antiguo puede ser lo que la máquina quiera. 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 sin argumentos resulta ser la correcta, pero tu código debería decirlo igual, porque la siguiente persona que lo lea no debería tener que saber cuál es el valor por defecto. Y cuando el payload no es texto en absoluto, el mismo código solo cambia de final: bytes en, bytes fuera, hasta el último paso.
Cuando el payload es un archivo
El trabajo de archivo más común es el inverso de alguna rutina de exportación: llega un archivo de texto .b64 y necesitas el archivo original de vuelta. Con decodificación estricta, esto ya tiene forma de producción:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DecodeFile {
public static void main(String[] args) throws Exception {
byte[] packed = Files.readAllBytes(Paths.get("payload.bin.b64"));
byte[] raw = Base64.getDecoder().decode(packed);
Files.write(Paths.get("payload.bin"), raw);
}
}
Nada en este camino se preocupa de si el payload es un archivo de texto, un archivo ZIP o un vídeo: byte[] son solo bytes. La cuenta de tamaños también juega a tu favor: la salida decodificada es tres cuartos de la longitud de la entrada codificada, así que decodificar nunca empeora la memoria, y un archivo codificado de cientos de megabytes es el menor de los dos. Un buen hábito es dejar que los bytes se anuncien solos antes de confiar en cualquier etiqueta. Los ocho primeros bytes de un PNG son siempre el número mágico 89 50 4E 47 0D 0A 1A 0A, lo que significa que cada PNG codificado en Base64 que te encuentres empieza con el mismo prefijo, iVBORw0K: si un payload "afirma" ser una imagen y no empieza así, algo ya va mal.
Si ya tienes el búfer de destino, la sobrecarga de dos arrays escribe directamente en él y devuelve exactamente cuántos bytes aterrizaron, sin ninguna asignación intermedia:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class DecodeInto {
public static void main(String[] args) {
byte[] src = "SGVsbG8sIHdvcmxkIQ==".getBytes(StandardCharsets.ISO_8859_1);
byte[] dst = new byte[16];
int written = Base64.getDecoder().decode(src, dst);
System.out.println(written); // 13
System.out.println(new String(dst, 0, written, StandardCharsets.UTF_8));
// Hello, world!
}
}
Un detalle cortante de esa sobrecarga, documentado en el javadoc: si el destino es demasiado pequeño, no se escribe ni un byte y obtienes IllegalArgumentException: Output byte array is too small for decoding all input bytes. Dimensiona el búfer con la cuenta simple, aproximadamente 3 * n / 4 menos el padding, y la excepción nunca muestra la cara. También hay una sobrecarga de ByteBuffer que devuelve un búfer nuevo con su límite fijado a la longitud decodificada, práctica cuando tu tubería vive en NIO.
Del cable: cabeceras, JSON y data URIs
Base64 se encuentra con Java más a menudo en el borde de la red. Tres formas merecen cada una un ejemplo trabajado.
Forma uno: la cabecera de autenticación HTTP Basic. La cabecera de autenticación más antigua de la web sigue montada en Base64. Según el RFC 7617, una petición Basic envía Authorization: Basic seguido de la codificación Base64 de username:password, y el RFC es explícito en que esto es codificación, no protección: cualquiera con una captura de paquetes puede leer ambas mitades con una pulsación. El propio ejemplo del RFC, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, se decodifica a Aladdin:open sesame. Parsear la cabecera en el lado del servidor son unas pocas líneas de trabajo:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuth {
public static String[] credentials(String header) {
if (header == null || !header.startsWith("Basic ")) {
return null;
}
byte[] packed = header.substring(6).getBytes(StandardCharsets.ISO_8859_1);
byte[] raw = Base64.getDecoder().decode(packed);
String userPass = new String(raw, StandardCharsets.UTF_8);
int colon = userPass.indexOf(':');
if (colon < 0) {
return null;
}
return new String[] {userPass.substring(0, colon), userPass.substring(colon + 1)};
}
}
Dos detalles mantienen esto a salvo. Dividir en el primer dos puntos importa, porque una contraseña puede contener legalmente dos puntos de su propia cosecha. Y la comparación de la contraseña decodificada con tu valor almacenado debería ser de tiempo constante: hashea ambos valores con SHA-256 y compara los resúmenes con MessageDigest.isEqual, nunca un equals simple, porque un atacante puede medirlo y convertirlo en una lista de usuarios. Sirve esto solo por HTTPS; en una conexión en claro la capa Base64 es pura cosmética.
Forma dos: binario dentro de JSON. Una gran parte de las APIs modernas incrustan binario como texto Base64 dentro de JSON: endpoints de subida de archivos, APIs de contenido, almacenes de secretos y webhooks, todos lo hacen, porque los bytes en crudo romperían las reglas de escapado de la cadena JSON. El patrón es siempre el mismo: el campo llega como una cadena normal, y lo decodificas en el límite, no dentro de tus objetos de dominio:
import java.util.Base64;
public class ApiField {
public static void main(String[] args) {
// El JSON parseado traía: "content" : "iVBORw0KGgoAAA..."
String field = "iVBORw0KGgo=";
byte[] image = Base64.getUrlDecoder().decode(field);
// Algunas APIs hablan Base64 estándar en cambio. Lee la especificación
// y elige getDecoder() o getUrlDecoder() en consecuencia.
System.out.println(image.length); // 8
}
}
La trampa aquí no es decodificar; es leer la especificación. Algunas APIs quieren Base64 estándar con padding, otras quieren base64url sin padding, 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 del valor resuelve el alfabeto, y los = finales resuelven el padding.
Forma tres: el data URI. Alguien pega una imagen en un formulario y el front end te entrega el data URI completo: data:image/png;base64,iVBORw0KGgo.... El RFC 2397 define la forma: data:, un tipo de medio opcional, un flag ;base64 opcional, una coma, y luego los datos. Cuando el flag está presente, el payload es Base64; cuando no está, el payload es texto plano codificado por porcentajes, más raro pero legal. Si el tipo de medio se omite, el valor por defecto es text/plain;charset=US-ASCII. Dividir uno es directo:
import java.util.Base64;
public class DataUri {
public static void main(String[] args) {
String uri = "data:image/png;base64,iVBORw0KGgo=";
int comma = uri.indexOf(',');
String meta = uri.substring(5, comma);
String payload = uri.substring(comma + 1);
boolean isBase64 = meta.endsWith(";base64");
String mime = isBase64 ? meta.substring(0, meta.length() - 7) : meta;
byte[] raw = Base64.getDecoder().decode(payload);
System.out.println(mime + " -> " + raw.length + " bytes");
// image/png -> 8 bytes
}
}
Dos trampas viven en este formato. El flag ;base64 ausente es la primera: un data URI legal sin el flag lleva un payload codificado por porcentajes, y pasarlo por Base64.getDecoder() lanza excepción. La segunda es el tipo de medio afirmado: es una pista del remitente, no un hecho, así que comprueba los bytes mágicos de lo que decodificaste antes de archivarlo bajo "png". Y recuerda el propio consejo del RFC de que los data URIs son para valores cortos; una imagen de varios megabytes dentro de una URL es un mal olor, no un patrón.
Correo electrónico, MIME y armadura PEM
Base64 nació para el correo, y el Base64 con forma de correo sigue llegando a los programas Java todo el tiempo. El estándar MIME (RFC 2045) convirtió a Base64 en una de las codificaciones de transferencia binaria y añadió dos reglas de la casa: las líneas codificadas no deben superar los 76 caracteres, y los decodificadores deben ignorar todos los caracteres fuera del alfabeto, saltos de línea incluidos. Los decodificadores estrictos rechazan el primer salto de línea que se encuentran; getMimeDecoder() se construyó exactamente para esta entrada:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class MimeDecode {
public static void main(String[] args) {
String wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
byte[] bytes = Base64.getMimeDecoder().decode(wrapped);
System.out.println(new String(bytes, StandardCharsets.UTF_8));
// Hello, standard
}
}
Funciona, y aun así deberías conocer el enganchón, porque el enganchón tiene colmillos. El decodificador permisivo no "ignora los saltos de línea"; ignora todo lo que no está en su alfabeto. Si una cadena Base64 estándar se corrompe con caracteres sueltos, la basura desaparece y el resto se decodifica a algo verosímil, así que recurre a getMimeDecoder() solo cuando de verdad esperes una entrada con forma de MIME.
El hermano de MIME en el mundo real es la armadura PEM, el asunto de -----BEGIN CERTIFICATE----- que envuelve certificados y claves. Aquí va la trampa: las líneas de la armadura están llenas de caracteres de alfabeto corriente. Las letras de "BEGIN CERTIFICATE" son simplemente letras Base64, así que si alimentas un bloque PEM entero, armadura incluida, se decodifica la armadura como si fuera datos. Retira la armadura tú mismo, y luego entrega el cuerpo desnudo a un decodificador:
import java.util.Base64;
public class PemDecode {
public static void main(String[] args) {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ "TUlJQm96Q0NBVWlnQXdJQkFnSUpBSXBhVDJUaVFvZU1BMEdDU3FHU0liM0RRRUE9\n"
+ "-----END CERTIFICATE-----\n";
String body = pem.replaceAll("(?m)^-----.*$", "").replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(body);
System.out.println(der.length); // el cuerpo DER, armadura excluida
}
}
PEM envuelve convencionalmente a 64 caracteres por línea (MIME a 76), y una vez que el espacio en blanco ha desaparecido, el decodificador estricto y el decodificador MIME coinciden en el resultado. Usa el estricto: una sorpresa al menos tiene la decencia de lanzar excepción. Para el caso sucio pero estándar, la receta clásica es retirar el espacio en blanco conocido y decodificar con la instancia estricta, y dejar que cualquier basura restante se gane una IllegalArgumentException en lugar de un certificado corrupto.
Valores de configuración, entorno y base de datos
Base64 es un contenedor de texto, y por eso aparece en sitios donde no te lo esperarías. En las bases de datos, un blob binario (un archivo, un icono, una estructura serializada) puede vivir en una columna TEXT como Base64, sobreviviendo a cada herramienta que asume texto; espera que el valor almacenado sea un tercio más grande que el original y dimensiona la columna en consecuencia. En los archivos de configuración y las variables de entorno, Base64 es el truco para contrabandear valores que romperían el formato: un DSN con punto y coma, una contraseña con comillas, un certificado de varias líneas. Decodificar al arrancar es todo el trabajo:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigDecode {
public static void main(String[] args) {
String value = System.getenv("DB_DSN_B64");
if (value == null) {
return;
}
byte[] raw = Base64.getDecoder().decode(value);
String dsn = new String(raw, StandardCharsets.UTF_8);
// dsn podría ser: pg:host=db;password=qu"ote
}
}
La misma precaución se aplica dos veces aquí. Primera, esto es seguridad de formato, no secreto: en el momento en que un desarrollador lee el archivo de configuración, puede decodificar el valor en una llamada, así que nunca almacenes un secreto como Base64 y lo llames cifrado; la sección de seguridad de más abajo entra en detalle. Segunda, valida al arrancar: un valor de entorno corrupto o pegado a medias es una IllegalArgumentException de la llamada estricta, y una comprobación de dos líneas convierte un error de ejecución enigmático en un mensaje de arranque accionable. Una nota específica de Java para el público de bases de datos: mantén el binario decodificado como byte[] (un parámetro byte[] en tu código JDBC), y no hagas nunca un viaje de ida y vuelta del binario a través de una String, porque los constructores de cadena son el lugar donde los payloads binarios van a morir.
Streaming para lo grande
Para payloads grandes que aun así caben en un búfer que tú gestionas, las APIs de array van bien. Para payloads que no deberían caber en memoria en absoluto, el adaptador de stream es la jugada: wrap(InputStream) devuelve un stream de entrada que decodifica mientras lees, así que un archivo codificado de varios gigabytes nunca tiene que sentarse en un 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 StreamDecode {
public static void main(String[] args) throws Exception {
InputStream packed = Base64.getDecoder().wrap(Files.newInputStream(Paths.get("bigfile.b64")));
OutputStream raw = Files.newOutputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = packed.read(buf)) != -1) {
raw.write(buf, 0, n);
}
raw.close();
packed.close();
}
}
Dos detalles que valen la pena conocer. Los métodos de lectura del stream envuelto lanzan IOException cuando se encuentran con bytes que no pueden decodificarse, así que un archivo corrupto falla con una excepción de stream en lugar de una IllegalArgumentException. Y cerrar el stream envuelto cierra el stream subyacente, así que el ejemplo cierra packed al final, después del bucle de copia, y en producción pondrías los dos en un bloque try-with-resources. (El búfer de 8192 es solo un búfer de lectura holgado; el stream envuelto decodifica internamente, así que el tamaño con el que lees es una elección de rendimiento, no un requisito de protocolo.)
Ahora una advertencia de legado, porque este es el único bug real de toda la historia y tiene número de bug. En cada JDK anterior al 16 (el informe de bug lo reproduce en el 8, el 10 y el 11), leer un decodificador envuelto con ciertos tamaños de búfer añade dos bytes cero sueltos al final de los datos decodificados: el JDK 8222187, cuya reproducción clásica empareja un búfer de lectura de siete bytes con una entrada simple de ocho bytes, y está corregido en el JDK 16. Si tienes que hacer streaming en un JDK 8 de legado, vuelve a comprobar la longitud decodificada después de la copia, porque el bug se dispara para combinaciones concretas de entrada y búfer, y hasta un búfer de 4096 bytes se ha reportado en el mundo real, o mejor, actualiza el JDK, que arreglaría unas cien cosas más igualmente.
Cinta adhesiva, no un candado
Ahora la sección que separa a los cuidadosos de los quemados. Base64 no es cifrado, y el propio estándar lo dice dos veces. Sección 12 del RFC 4648: 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 sigue señalando que esto "ha causado incidentes de seguridad" cuando alguien pega un intercambio de protocolo en un ticket y revela la contraseña por accidente. El consejo del RFC para los implementadores también merece un marco: "un decodificador no debe romperse con entradas inválidas, incluyendo, p. ej., caracteres NUL incrustados".
La trampa más sutil es la maleabilidad. Recuerda que cada símbolo lleva seis bits, y que una unidad final corta deja bits de sobra que deben ser cero en una codificación bien formada. Un codificador descuidado u hostil puede meter basura en esos bits de sobra, y el resultado sigue teniendo pinta totalmente válida: MQ== y MT== se decodifican ambos al único byte del dígito 1. Java toma el lado indulgente de esto: Base64.getDecoder().decode("MT==") no verifica los bits no significativos y te entrega encantado el mismo byte. ¿Por qué importaría? Porque dos cadenas distintas que se decodifican a los mismos datos rompen el supuesto de "grafía única" en el que se apoyan en silencio las comprobaciones de hash, la deduplicación y las comparaciones de firma, y un atacante que pueda manipular un valor codificado en tránsito puede intercambiar una grafía por la otra. El paper de 2022 "La maleabilidad de Base64 en la práctica" de Chatzigiannis y Chalkias (ACM ASIA CCS 2022) recorre exactamente estas inconsistencias a través de implementaciones del mundo real. Las propias palabras del RFC sobre los bits de sobra: "pueden ser abusados para filtrar información o utilizados para saltarse comparaciones de igualdad de cadenas o para provocar problemas de implementación". La regla práctica no es "nunca decodificar"; es "conoce tu límite": para datos entre tus propios sistemas la generosidad del JDK va bien, pero para datos que cruzan un límite de confianza, impone la forma canónica (longitud correcta, bits de sobra a cero, una sola grafía de padding) antes de confiar en nada de lo que decodificaste.
Notas de rendimiento
Aquí va la buena noticia en una frase: en una JVM moderna, el decodificador integrado es lo bastante rápido para que Base64 casi nunca sea tu cuello de botella, y es la referencia contra la que el resto del ecosistema se benchmarka. Un caso que lo ilustra: en 2025 el proyecto gRPC-java benchmarkó públicamente su manejo Base64 basado en Guava contra java.util.Base64 (issue 11857), y la implementación del JDK salió unas 2,5 a 3,8 veces más rápida en codificación y 1,3 a 2,1 veces más rápida en decodificación en JDK 17 y 21, con las mayores brechas en x86. Eso es una pista fuerte sobre a dónde ha ido el esfuerzo de implementación del JDK, y es la misma conclusión que seguirás encontrando en los benchmarks de Base64: la versión de la biblioteca estándar es ahora la rápida, no la de legado.
Dos notas prácticas. Primera, para archivos enormes lo que gestionas es el perfil de memoria, no la velocidad, y por eso existe la sección de streaming: wrap(InputStream) mantiene el conjunto de trabajo limitado a tu búfer de lectura. Segunda, si de verdad te metes en una ruta caliente que decodifica millones de valores pequeños, comparte una instancia de decodificador (la fábrica ya devuelve la misma instancia compartida, como se dijo arriba), salta la sobrecarga decode(String) cuando ya tienes bytes (copia la cadena a través de Latin-1 antes), y deja que la sobrecarga decode(byte[], byte[]) escriba en un array de destino predimensionado para saltarse el baile de asignaciones.
Trampas con acento Java
Las trampas reunidas en un solo sitio, todas específicas de Java:
- Decodificador equivocado para el alfabeto. Una cadena base64url en
getDecoder()(o al revés) es el clásico desastre deIllegal base64 character, normalmente con un2d,5f,2bo2fen el mensaje. Ajusta el decodificador al protocolo, cada vez. - Espacio en blanco final del mundo real. Los valores copiados de una terminal, una variable de entorno o un archivo de configuración a menudo llegan con un salto de línea, y el decodificador estricto lo convierte en
Illegal base64 character a. Aplícalestrip()a la entrada, o usa el decodificador MIME solo cuando los datos estén realmente envueltos. - La trampa de la armadura.
getMimeDecoder()no entiende las cabeceras PEM, y las letras de BEGIN y CERTIFICATE se decodifican como datos. Retira las líneas de la armadura tú mismo, siempre. - La permisividad MIME como atajo. Decodificar con el decodificador MIME solo para "ir a lo seguro" salta en silencio cualquier carácter no alfabeto suelto, así que un payload corrupto puede salir verosímil y equivocado. Úsalo solo para entrada MIME real.
- Charset dejado a la suerte. El
new String(bytes)sin argumentos usa el valor por defecto de la plataforma. Es UTF-8 en JDK 18+, pero tu código debería pasarStandardCharsets.UTF_8explícitamente, o disfruta del mojibake tras la próxima migración de servidores. - Convertir binario a cadena. Hacer
new String(decodedPng)y volver es destrucción de datos: cada secuencia de bytes que no es válida en tu charset se convierte en el carácter de sustitución, y el viaje de ida y vuelta es de sentido único. Bytes en, bytes fuera, hasta el último paso. - Confianza en los bits de sobra.
MT==se decodifica igual queMQ==, así que un payload con basura escondida en los bits no significativos pasa cada comprobación que el JDK ejecuta. Si el protocolo importa, impone la forma canónica. - Streams en JDK 8, 11 y 12. El decodificador envuelto en esas versiones puede añadir dos bytes cero sueltos para ciertos tamaños de búfer (JDK 8222187, corregido en el 16). En el 16 y posteriores esto no es un problema; en los anteriores, sí.
- null no es vacío. Pasar
nulladecode()es unaNullPointerException, no un array vacío. Si una variable puede ser null, resuélvela antes de la llamada. - Android es otra zoología. En Android,
java.util.Base64solo existe desde el nivel de API 26; por debajo, la clase del framework esandroid.util.Base64con sus propias constantes de flag. El código que hardcodea una u otra sin comprobar falla en exactamente los dispositivos que nunca probaste. - Olvidar que no es seguridad. Base64 esconde una contraseña de una ojeada y de nadie más. Si los datos son secretos, cifralos antes y solo entonces empaquetalos si el canal exige texto.
El camino largo hacia java.util.Base64
La historia del formato es más antigua que Java. En los años 80 la infraestructura de correo de internet solo podía llevar ASCII de 7 bits, y la gente que quería mover binarios inventó dialectos locales: uuencode para UNIX (su alfabeto recorre códigos ASCII consecutivos, así que codificar era una suma de 32 sin tabla de búsqueda) y BinHex para las máquinas de Apple (que curó su alfabeto para descartar caracteres visualmente confundibles como 7, O, g y o). En 1987 el protocolo Privacy-Enhanced Mail (RFC 989) estandarizó el esquema de 64 caracteres con líneas de 64 caracteres para llevar certificados, y el RFC 1421 en 1993 conservó el alfabeto y las reglas de padding. En 1996 el MIME (RFC 2045, actualizando el RFC 1521 de 1993) llevó el esquema ya llamado "base64" por su alfabeto de 64 caracteres, fijó la longitud de línea de 76 caracteres que aún envuelve tus adjuntos de correo, y escribió la regla del decodificador permisivo que getMimeDecoder() implementa hasta hoy. En 2003 el RFC 3548 intentó ordenar toda la familia y declaró que los decodificadores deberían rechazar caracteres fuera del alfabeto, y en 2006 el RFC 4648 se convirtió en el estándar que todos citan, con las tablas de alfabetos, la variante base64url en la sección 5, y la sección de seguridad que mantiene honesta la última sección de este artículo.
El capítulo propio de Java es un poco más dramático. Durante años, el único Base64 dentro del JDK era la pareja interna sun.misc.BASE64Encoder y sun.misc.BASE64Decoder, el tipo de API que compila hoy y desaparece sin un aviso de deprecación, y si necesitabas Base64 en un mundo XML también estaba javax.xml.bind.DatatypeConverter de JAXB. Todos los demás usaban Apache Commons Codec o Guava. Entonces el 18 de marzo de 2014: Java 8 publicó java.util.Base64, implementando el RFC 4648 y el RFC 2045 en una clase con el patrón de método de fábrica que has estado usando todo el tiempo. Tres años y medio después, Java 9 (21 de septiembre de 2017) eliminó la pareja de sun.misc de una vez, y la guía oficial de migración es contundente al respecto: "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". Ejecuta jdeps sobre código antiguo que aún las referencia y la herramienta marca la dependencia como "JDK removed internal API". Java 11 continuó eliminando el módulo JAXB y su DatatypeConverter con él (JEP 320). Desde 1.8 la API pública no ha cambiado ni un solo método, y el javadoc sigue llevando su etiqueta original Since: 1.8. Lo que ha movido es el motor de debajo: correcciones de bugs (el bug de stream del JDK 8222187, corregido en el JDK 16) y trabajo de rendimiento, que es por qué los benchmarks de la comunidad siguen llegando a la misma conclusión. Doce años, una API, y sigue siendo el Base64 más rápido que no te va a costar un céntimo.
Curiosidades, edición Java
Porque una guía completa debería terminar con una sonrisa, aquí van algunos hechos específicos de Java que son simplemente divertidos:
- El javadoc de Oracle para
decode(byte[] src, byte[] dst)promete que "algunos bytes pueden haber sido escritos en el array de bytes de salida antes de que se lance IllegalargumentException". No IllegalArgumentException, IllegalargumentException, con una a minúscula. El error tipográfico está en el código fuente real del JDK, y lleva allí desde 2014. Una documentación tan comprometida con un error tipográfico es más rara de lo que debería. - Decodifica una cadena que contiene
éy el mensaje de error esIllegal base64 character -17: un número hexadecimal negativo, porque el carácter se convierte en el byte Latin-10xE9, que como byte Java con signo es menos 23, y el JDK lo imprime en base 16. Tu registrador de errores, brevemente, está haciendo aritmética con signo. Base64.getDecoder() == Base64.getDecoder()es cierto. El código fuente devuelve una instancia estática 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.- Aliméntale al decodificador de URLs una cadena de cuatro guiones bajos,
"____", y devuelve tres bytes de0xFFpuros. El guion bajo es el valor 63 del alfabeto, cuatro de ellos hacen 24 bits, y 24 bits de unos son la tripleta de bytesFF FF FF. Nada ilegal en esto, que es la parte más graciosa. AA==se decodifica a un único byte NUL mientras que la cadena vacía se decodifica a nada. En Base64, "nada" y "un cero" son criaturas distintas, y ambas son entradas perfectamente válidas.- La pequeña cadena
TWFuque se decodifica aManse ha convertido en el test de humo favorito del ecosistema: aparece en el RFC, en Wikipedia, en manuales de referencia y en la mayoría de los tutoriales de Base64 del planeta, así que cada decodificador escrito desde entonces ha estado pagando el mismo pequeño tributo. - Cada PNG codificado en Base64 que hayas decodificado alguna vez empieza por
iVBORw0K. Ese es el número mágico del PNG disfrazado, y es uno de los prefijos de ocho caracteres más reconocibles de internet. - La sección segura para URLs del RFC 4648 es donde nace el nombre "base64url": la especificación dice que la codificación "puede ser referida como base64url" y advierte que "no debe considerarse igual a la codificación base64". El origen del alfabeto seguro para URLs se anota al pie a un post de 2001 en una lista de correo de P2P-hackers, así que el nombre que pegas en cada URL tiene un linaje de lista de correo.
- Los IDs de vídeo de YouTube son base64url sin relleno, la conocida cadena de once caracteres que puedes pegar en cualquier parte de una URL. El formato que fue diseñado para adjuntos de correo ahora mueve una plataforma de vídeo, y
getUrlDecoder()es la parte de tu JDK que lo hace funcionar. - Decodifica la cadena
YmFzZTY0y obtienes la palabrabase64de vuelta, 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.
La otra dirección
Eso es la parte del decodificador de la historia, y es donde vive la mayor parte del dolor, porque decodificar es donde te encuentras con los datos de los demás: sus elecciones de padding, sus saltos de línea, sus charsets, sus tokens, su armadura. La otra dirección, convertir bytes en una cadena Base64 con los codificadores de java.util.Base64, es un animal más tranquilo: nunca lanza excepción sobre entrada inválida (no hay entrada inválida que codificar), tiene una factura de tamaño que pagar en lugar de un mensaje de error que leer, y su propio conjunto de trampas (el paso del charset, las perillas MIME, la decisión de padding para tokens) tiene una guía propia. La codificación Base64 en Java, enlazada desde esta página, cubre el codificador con la misma profundidad, y las dos se leen cómodamente como pareja.
Última actualización: 2026-09-08
Artículo relacionado: Codificación Base64 en Java: una guía completa