Decodificación Base64 en Kotlin: una guía completa
Estás mirando fijamente un valor que se niega a ser leído: SGVsbG8sIFdvcmxkIQ==. Una ristra de letras y dígitos, de cuando en cuando un + o un / mezclados en medio, y normalmente uno o dos caracteres = colgando al final. Esto es Base64, y esta guía va de convertirlo de vuelta en lo que era - una frase, una imagen, un certificado, un montón de bytes binarios - a la manera Kotlin. Un recordatorio de una línea antes de meternos en materia: Base64 empaqueta cada tres bytes en cuatro caracteres tomados de un alfabeto de 64 símbolos, y una pequeña cola de padding = marca donde dejó de haber datos reales. El recorrido completo por el formato vive en la página de inicio, así que solo le dedicamos una frase aquí, y una más: como cuatro caracteres cargan lo que cargaban tres bytes, la forma de texto es aproximadamente un tercio más larga que los datos originales.
La buena noticia sobre Kotlin: no necesitas ningún paquete. La biblioteca estándar lleva años incluyendo su propia implementación de Base64; es totalmente estable desde Kotlin 2.2, y funciona en cada plataforma donde funciona Kotlin, desde el JVM de tu portátil hasta un teléfono Android, pasando por Node.js y una función edge de WASI. Todo lo que sigue funciona con el Kotlin que viene con tu proyecto.
Primero las buenas noticias: lo que de verdad necesitas
No hay ningún artefacto base64 que añadir a Gradle, ningún paquete estilo NuGet, ningún módulo de npm. La clase que buscas es kotlin.io.encoding.Base64, parte de la propia biblioteca estándar de Kotlin. Si sabes escribir println, puedes decodificar Base64. Tres APIs pueden hacer trabajo de Base64 en un proyecto Kotlin, y elegir la correcta es la primera decisión de verdad:
| API | Dónde se ejecuta | Cuándo usarla |
|---|---|---|
kotlin.io.encoding.Base64 |
Cada plataforma Kotlin: JVM, Android, JS, Native, Wasm | Elección por defecto. Estable desde Kotlin 2.2, multiplataforma, API moderna |
java.util.Base64 |
Solo JVM (Java 8+; en Android, API 26+) | Bases de código solo para JVM que ya viven en terreno de interop con Java |
android.util.Base64 |
Solo Android (API 8+) | Código Android legacy, o cuando necesitas específicamente sus constantes de flag |
Dos notas de versión que valen la pena conocer. Primero, la clase de la biblioteca estándar apareció por primera vez en Kotlin 1.8.20 (abril de 2023) tras una barrera de @ExperimentalEncodingApi; Kotlin 2.0.20 trajo la perilla de withPadding y la regla estricta de padding, y Kotlin 2.2.0 (junio de 2025) estabilizó la API y añadió la instancia PEM. Así que en Kotlin 2.2 o más reciente - incluida la línea estable actual, 2.4.x - puedes usar todo lo de esta guía sin una sola anotación. Segundo, si tu proyecto fija una versión de Kotlin entre 1.8 y 2.1, la misma clase existe pero está marcada como experimental, y el compilador no te dejará usarla sin una anotación @OptIn en la función.
Una trampa de instalación que le ha costado más de una tarde a más de uno: el paquete kotlin de los repositorios de Debian y Ubuntu es la versión 1.3.31, que es anterior a toda la API Base64 de la biblioteca estándar, así que no puede compilar ni un solo ejemplo de este artículo. Consigue el compilador desde las releases de Kotlin en GitHub o desde SDKMAN, y en los proyectos Gradle fija el plugin explícitamente:
plugins {
kotlin("jvm") version "2.4.10"
}
Tu primera decodificación: dos líneas y un resultado en bytes
Toda la ceremonia cabe en dos sentencias, y la clásica cadena TWFu es un buen punto de partida:
import kotlin.io.encoding.Base64
fun main() {
val packed = "TWFu"
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // Man
}
Léelo despacio, porque hay tres decisiones de diseño escondidas en él. Primero, Base64.decode(...) sin ningún .Default no es un error tipográfico: Default es el objeto companion de la clase, así que llamar a la función sobre la propia clase es una abreviatura de llamarla sobre Base64.Default. También verás Base64.Default.decode(...) en tutoriales más antiguos y significa exactamente lo mismo. Segundo, y esto pesa más de lo que parece: decode te entrega un ByteArray, nunca un String. El payload puede ser un JPEG, un certificado X.509 o una frase, y la API se niega a adivinar cuál, así que el salto de bytes a texto es un paso separado y deliberado. Tercero, ese paso es donde vive la decisión del charset, y es donde nacen la mayoría de los bugs de "mi Base64 volvió como basura". Llegamos allí en un momento; primero, una ida y vuelta para demostrar que la decodificación es fiel:
import kotlin.io.encoding.Base64
fun main() {
val original = "Hello, World!".encodeToByteArray()
val packed = Base64.encode(original)
val back = Base64.decode(packed)
println(packed) // SGVsbG8sIFdvcmxkIQ==
println(back.contentEquals(original)) // true
}
Cuatro esquemas, cuatro personalidades
La clase nunca se instancia; eliges una de las cuatro instancias listas para usar, y cada una decodifica con un temperamento distinto:
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello?".encodeToByteArray()
println(Base64.Default.encode(data)) // SGVsbG8/
println(Base64.UrlSafe.encode(data)) // SGVsbG8_
println(Base64.Mime.encode(data)) // SGVsbG8/
println(Base64.Pem.encode(data)) // SGVsbG8/
}
| Instancia | Alfabeto | Cómo decodifica |
|---|---|---|
Base64.Default |
A-Z a-z 0-9 + / |
Estricto: cualquier carácter fuera del alfabeto lanza una excepción; el padding es obligatorio |
Base64.UrlSafe |
A-Z a-z 0-9 - _ |
Estricto, pero contra el alfabeto URL; un + o un / en la entrada lanza una excepción |
Base64.Mime |
A-Z a-z 0-9 + / |
Permisivo: ignora los separadores de línea y otros caracteres no alfabéticos, pero nada puede ir después del padding =; el padding es obligatorio |
Base64.Pem |
A-Z a-z 0-9 + / |
Permisivo, mismas reglas que Mime; esta es la versión PEM/PKI del mismo alfabeto |
La división permisivo/estricto es lo más útil que puedes interiorizar. Default y UrlSafe tratan cualquier carácter foráneo como la escena de un crimen y lanzan la excepción al instante. Mime y Pem se encogen de hombros ante los saltos de línea, los espacios y la puntuación suelta - porque eso es exactamente lo que contienen los archivos de email y de certificados reales - y sin embargo no son ilimitados: en el momento en que un carácter de datos aparece después del padding, hasta ellos lanzan la excepción. Verás los mensajes de error exactos en la guía de fallos más adelante en este artículo.
Una consecuencia más de las personalidades: un esquema no puede leer la salida de otro esquema. Si le pasas un token base64url a Base64.Default, el carácter - no está en su alfabeto, así que recibes IllegalArgumentException: Invalid symbol '-'(55) at index .... Cuando dudes de dónde viene una cadena, elige el esquema que coincida con quien la produjo, no el que coincida con tu estado de ánimo.
Base64 URL-safe y JWTs
Dos caracteres del alfabeto estándar causan problemas en cuanto los datos tienen que viajar por una URL. Un + en una query string se reinterpreta rutinariamente como un espacio antes de que nada lo lea, y / es un separador de rutas, así que no puede aparecer en un segmento de URL en absoluto. RFC 4648, sección 5, resuelve esto intercambiando los dos últimos símbolos del alfabeto: + pasa a ser - y / pasa a ser _. El nombre que más vas a oír es base64url, y en Kotlin es Base64.UrlSafe.
El mayor consumidor de base64url es el JSON Web Token. Un JWT en su forma compacta son tres partes base64url unidas por puntos: header.payload.signature. RFC 7515 especifica estas partes como base64url sin padding, que es una segunda diferencia con el alfabeto plano, no solo en los caracteres. Aquí tienes un token siendo abierto para inspeccionarlo:
import kotlin.io.encoding.Base64
fun main() {
val token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
val (header, payload, signature) = token.split(".")
val lenient = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(lenient.decode(header).decodeToString())
// {"alg":"HS256"}
println(lenient.decode(payload).decodeToString())
// {"sub":"1234567890","name":"John Doe"}
println(signature.length) // 43
}
Dos cosas a las que prestar atención. Las partes del token no llevan padding, pero Base64.UrlSafe de fábrica exige padding, así que la línea de withPadding(PRESENT_OPTIONAL) hace trabajo de verdad: acepta por igual la entrada con y sin padding. Y el split(".") con destructuring es Kotlin normal y corriente haciendo lo que el formato le pide. Un aviso serio: abrir un JWT es para mirar un token, no para fiarse de él. El header y el payload son datos planos tras la decodificación; solo una firma verificada dice que el token es auténtico, y para eso quieres una biblioteca de JWT de verdad, no un troceo de cadenas hecho a mano.
Modos de padding y la perilla de estricticidad
El padding no es un hecho fijo de Base64 en Kotlin; es una configuración. Cada instancia lleva un PaddingOption, las cuatro instancias predefinidas arrancan en PRESENT, y withPadding te entrega una instancia nueva con otra configuración dejando la original intacta. Aquí va la perilla, opción por opción:
| Opción | Entrada sin padding | Entrada con padding correcto |
|---|---|---|
PRESENT (por defecto en todas partes) |
Lanza | Decodifica |
ABSENT |
Decodifica | Lanza |
PRESENT_OPTIONAL |
Decodifica | Decodifica |
ABSENT_OPTIONAL |
Decodifica | Decodifica |
import kotlin.io.encoding.Base64
fun main() {
val data = "Hello".encodeToByteArray()
println(Base64.Default.withPadding(Base64.PaddingOption.ABSENT).encode(data))
// SGVsbG8
println(Base64.Default.encode(data))
// SGVsbG8=
val eitherWay = Base64.Default.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL)
println(eitherWay.decode("SGVsbG8").decodeToString()) // Hello
println(eitherWay.decode("SGVsbG8=").decodeToString()) // Hello
}
En el lado de la decodificación, PRESENT_OPTIONAL es tu red de seguridad: es la opción que dice "no sé si el remitente puso padding, y pienso seguir funcionando igual". Los mensajes de error de las demás combinaciones son inusualmente útiles, así que los reconocerás al instante cuando un decodificador estricto se encuentre con la entrada equivocada: el padding que falta bajo PRESENT produce The padding option is set to PRESENT, but the input is not properly padded, y el padding bajo ABSENT produce The padding option is set to ABSENT, but the input has a pad character at index 7. Hay un comportamiento que merece un aviso porque sorprende a la gente: un padding doble como SGVsbG8== no es "de más, pero sin problema". El primer = termina los datos, y el segundo es un carácter en un sitio donde se esperaba dato, así que hasta los decodificadores más permisivos lo rechazan.
También hay una historia de versiones escondida aquí. Si heredaste código escrito contra la API experimental de 1.8.x, recuerda que el decode antiguo aceptaba entrada con o sin padding. En Kotlin 2.0.20, Default pasó a la regla estricta de PRESENT, así que una entrada sin padding que funcionaba antes ahora lanza una excepción al actualizar más allá de esa versión puntual. La corrección es una línea: withPadding(Base64.PaddingOption.PRESENT_OPTIONAL), o normaliza tus entradas antes de decodificar.
De bytes a texto: charsets y Unicode
Cuando ya tienes tu ByteArray en la mano, la pregunta pasa a ser qué significa. Si el payload es texto, la respuesta por defecto es decodeToString(), que interpreta los bytes como UTF-8 y funciona en todas las plataformas. Para los casos habituales de APIs modernas, emails y datos web, con eso te va a sobrar toda la vida, y los emoji incluidos:
import kotlin.io.encoding.Base64
fun main() {
val original = "héllo 😀"
val packed = Base64.encode(original.encodeToByteArray())
println(packed) // aMOpbGxvIPCfmIA=
println(Base64.decode(packed).decodeToString()) // héllo 😀
}
Pero en el momento en que el remitente usó algo que no era UTF-8, la decisión del charset es tuya. Las conversiones de texto integradas de Kotlin son solo UTF-8 a propósito: decodeToString() no tiene parámetro de charset, y tampoco existe ninguna función de cadena a bytes que lo tenga. En el JVM caes a la API de charsets de la plataforma, que es honesta y explícita:
import kotlin.io.encoding.Base64
import java.nio.charset.Charset
fun main() {
val latinOne = "héllo".toByteArray(Charsets.ISO_8859_1)
val packed = Base64.encode(latinOne)
println(packed) // aOlsbG8=
val asUtf8 = Base64.decode(packed).decodeToString()
val asLatin = String(Base64.decode(packed), Charsets.ISO_8859_1)
println(asUtf8) // h?llo (el byte de é no es UTF-8 válido)
println(asLatin) // héllo
val byName = String(Base64.decode(packed), Charset.forName("ISO-8859-1"))
println(byName) // héllo
}
Ese ? de la línea del medio no es un problema de fuente; es U+FFFD, el carácter de sustitución de Unicode, ocupando el sitio de un byte que no forma UTF-8 válido. Si ves una ristra de estos tras decodificar, tu payload está bien: lo que no está bien es tu suposición sobre el charset. Fíjate también en la asimetría que pilla a más de uno: en el lado de la codificación existe la extensión del JVM toByteArray(charset), pero en el lado de la decodificación el constructor que corresponde es String(bytes, charset). Ninguno de los dos acepta un nombre de charset; para eso necesitas Charset.forName("..."), que lanza UnsupportedCharsetException para un nombre inventado, así que un error tipográfico en un valor de configuración falla rápido en vez de elegir en silencio otra codificación.
Mientras estamos en la tierra de los bytes, una trampa propia de Kotlin: un Char es un valor de 16 bits, y toByte() sobre él conserva en silencio solo los ocho bits bajos. Si armas bytes a mano a partir de caracteres, "中".first().code.toByte() te da 45, un número que no tiene nada que ver con el carácter. El camino correcto es siempre encodeToByteArray(), que hace el trabajo real de codificación - el mismo carácter son tres bytes en UTF-8, y su forma Base64 es 5Lit. Deja que la biblioteca estándar codifique; nunca empaquetes caracteres en bytes a mano.
Archivos, subcadenas y entradas grandes
Los datos Base64 no son siempre una cadena ordenada y recogida en memoria. A veces es un archivo, a veces un trozo de una respuesta más grande, y a veces son demasiado grandes para tenerlos todos de una vez. Kotlin te da las tres puertas.
Los archivos son el caso aburrido en el mejor sentido: lees los bytes, decodificas, listo. Funcionan las dos APIs estándar de archivos, la que ya use tu proyecto:
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
fun main() {
val fromFile = File("payload.b64").readBytes()
println(Base64.decode(fromFile.decodeToString()).size) // número de bytes decodificados
val fromPath = Path("payload.b64").readBytes()
println(Base64.decode(fromPath.decodeToString()).size) // mismo número
}
Las subcadenas son donde las sobrecargas de CharSequence se ganan el sueldo. decode acepta cualquier secuencia de caracteres con índice de inicio y de fin, así que puedes pasarle un trozo de un cuerpo de respuesta largo sin hacer antes una copia del trozo:
import kotlin.io.encoding.Base64
fun main() {
val body = "prefix junk SGVsbG8= trailing junk"
val bytes = Base64.decode(body, 12, 20)
println(bytes.decodeToString()) // Hello
}
Si ya conoces el tamaño de la salida y quieres reutilizar un buffer, decodeIntoByteArray escribe en el array de destino que tú elijas y te dice cuántos bytes escribió. Si le das un buffer demasiado pequeño, lanza IndexOutOfBoundsException con la capacidad necesaria en el mensaje, así que el error te sirve a la vez de pista de tamaño.
Para streams genuinamente grandes en el JVM, hay una tercera puerta: los decodificadores en streaming. Siguen marcados como experimentales - por eso la anotación de opt-in - y solo existen para el JVM, pero decodifican sobre la marcha en vez de tenerlo todo en memoria:
import java.io.ByteArrayInputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.io.encoding.decodingWith
@OptIn(ExperimentalEncodingApi::class)
fun main() {
val stream = ByteArrayInputStream("SGVsbG8gV29ybGQh".toByteArray())
stream.decodingWith(Base64.Default).use {
println(it.readBytes().decodeToString()) // Hello World!
}
}
Dos detalles prácticos. Las funciones de extensión viven a nivel superior del paquete, así que las importas por nombre (una importación con asterisco también funciona, pero los nombres son más amables). Y el decodificador trata el padding como un alto inamovible: si el stream subyacente sigue adelante después de la sección Base64, la lectura del stream decodificado termina en el = y los bytes restantes siguen disponibles en el stream original. Esto lo hace perfecto para formatos que le enganchan Base64 delante de otra cosa.
En la trinchera: APIs HTTP y cuerpos JSON
JSON no puede cargar bytes en bruto - es un protocolo de texto - así que las APIs que necesitan mover binarios (imágenes, certificados, blobs arbitrarios) casi siempre los envuelven en Base64 dentro de un campo de texto. El patrón es: parsea el JSON, toma el campo, decodifica. Con la biblioteca oficial de serialización, la parte del JSON está a solo dos anotaciones:
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
@Serializable
data class ImageResponse(val name: String, val data: String)
fun main() {
val body = """{"name":"icon.png","data":"iVBORw0KGgo="}"""
val response = Json.decodeFromString<ImageResponse>(body)
val bytes = Base64.decode(response.data)
println("${response.name}: ${bytes.size} bytes") // icon.png: 8 bytes
}
Este ejemplo necesita el plugin y la biblioteca de serialización, que se añaden una sola vez al build:
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10"
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}
Sin la biblioteca, la misma idea funciona sobre la cadena cruda, lo cual viene bien para scripts rápidos: extrae el campo con substringBetween y decodifícalo. Las trampas son las típicas de API: el campo puede ser en realidad un data URL completo (con el prefijo data:image/png;base64,, que tratamos más adelante en este artículo), el payload puede estar envuelto en MIME con saltos de línea, y el payload codificado puede ser un tercio mayor que el binario original, así que vigila tu presupuesto de memoria en respuestas grandes.
En la trinchera: email y entrada envuelta por MIME
El email es un mundo de texto de 7 bits, y la respuesta de RFC 2045 a los adjuntos binarios es Base64 con un giro: la salida codificada debe ir envuelta para que ninguna línea pase de 76 caracteres. Si alguna vez has recibido un adjunto como texto, por eso se parece a una columna de Base64 con sangría. Para esta entrada en concreto, Base64.Mime es el decodificador adecuado, porque ignora los separadores de línea y otros caracteres no alfabéticos mientras avanza:
import kotlin.io.encoding.Base64
fun main() {
val wrapped = "SGVs\nbG8=\r\n"
println(Base64.Mime.decode(wrapped).decodeToString()) // Hello
val withJunk = "Y@{mFz!Z!TY}0"
println(Base64.Mime.decode(withJunk).decodeToString()) // base64
}
La permisividad es real pero acotada. Envuelve la entrada, espolvorea uno o dos espacios, sin problema. Pero si añades un carácter de datos después del = final, hasta Mime lanza: Symbol 'e'(145) at index 7 is prohibited after the pad character. Y recuerda que Mime sigue exigiendo que el padding esté presente y sea correcto; un decodificador MIME que además se tragara el padding que falta estaría pidiendo problemas. La receta práctica para payloads entrantes de email liosos: decodifica primero con Mime, y si lanza, lee el mensaje - te dice exactamente qué símbolo, en qué índice, rompió las reglas.
En la trinchera: imágenes y data URLs
Un data URL es la forma que tiene la web de incrustar un archivo directamente en un documento: un media type, una marca base64, y el payload, todo en una sola cadena. A los navegadores, al CSS y a las interfaces incrustadas les encantan para activos pequeños - iconos, avatares, imágenes de relleno - porque no hay que hacer una segunda petición. El formato se ve así:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==
Decodificar uno en Kotlin es una operación de cadena seguida de una decodificación Base64. El prefijo no guarda ningún secreto; todo lo que hay después de la coma es el payload:
import kotlin.io.encoding.Base64
fun main() {
val dataUrl = "data:image/png;base64,iVBORw0KGgo="
val mediaType = dataUrl.substringBefore(";")
val packed = dataUrl.substringAfter("base64,")
val bytes = Base64.decode(packed)
println(mediaType) // data:image/png
println(bytes.size) // 8
println(bytes.contentToString()) // [-119, 80, 78, 71, ...]
}
Ese primer byte, -119 (que es 0x89), seguido de las letras PNG, es el número mágico que identifica a un archivo PNG. Comprobar los primeros cuatro u ocho bytes después de decodificar es una forma barata de confirmar que un data URL contiene realmente lo que su prefijo dice. Dos advertencias honestas: Base64 añade aproximadamente un tercio al tamaño, así que un data URL es un cambio de tamaño que haces frente a un viaje de red, y para cualquier cosa grande suele ser mejor servir el archivo desde una URL de verdad y dejar que la cache haga su trabajo.
En la trinchera: configuración, variables de entorno y bases de datos
Base64 aparece en archivos de configuración y variables de entorno cada vez que un valor binario debe viajar por un canal que solo entiende texto: un icono pequeño incrustado en un archivo de propiedades, un token guardado en una variable de entorno de un contenedor, un blob de bytes aparcado en una columna de texto porque el esquema es anterior a un tipo binario de verdad. El lado de la decodificación son los mismos dos pasos en todas partes: lee el texto y decodifícalo:
import kotlin.io.encoding.Base64
fun main() {
val line = "icon: UE5HREFUQQ=="
val packed = line.substringAfter("icon: ").trim()
val bytes = Base64.decode(packed)
println(bytes.decodeToString()) // PNGDATA
val fromEnv: String? = System.getenv("MY_ICON_B64")
if (fromEnv != null) {
println(Base64.decode(fromEnv).size)
}
}
La trampa de todo este vecindario cabe en una línea: Base64 no es cifrado. Es un truco de transporte, no un candado. Nadie debería leer un valor Base64 y pensar que los datos de dentro están ocultos; está a una llamada de función de ser visible, y es visible en cada línea de log que escribes. Si un valor es sensible, mántelo sensible de punta a punta - un gestor de secretos, una columna cifrada, lo que tu stack proporcione - y usa Base64 solo para hacer que los bytes viajen por el texto, no para protegerlos.
En la trinchera: la línea de comandos
El caso de uso más antiguo de todos: convertir un blob Base64 en la línea de comandos en un archivo. Una herramienta completa son ocho líneas de Kotlin, porque la biblioteca estándar hace el trabajo pesado. Compílala una vez con el compilador de Kotlin y es tuya para siempre:
import java.io.File
import kotlin.io.encoding.Base64
fun main(args: Array<String>) {
val packed = if (args.isNotEmpty()) args[0] else readlnOrNull().orEmpty()
val bytes = Base64.decode(packed.trim())
File("decoded.bin").writeBytes(bytes)
println("Wrote ${bytes.size} bytes to decoded.bin")
}
Ejecútala con un argumento para un valor puntual, o conecta un archivo por tubería para trabajo por lotes: el programa lee el primer argumento si existe y, si no, cae a la entrada estándar. El trim() está haciendo su trabajo silencioso aquí, porque los argumentos del shell y los valores pegados llegan con gusto con espacios sueltos que un decodificador estricto rechazaría. Y si tus payloads son base64url, intercambia Base64.decode por Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode y la herramienta también estará lista para los tokens.
Guía de campo para los fallos de decodificación
Cada decodificador de esta guía falla con uno de dos tipos de excepción, y cada mensaje es lo bastante específico para decirte exactamente qué salió mal. Aquí tienes el mapa completo, con los mensajes exactos que produce la biblioteca estándar:
| Situación | Excepción | Mensaje (tal como se produce) |
|---|---|---|
| Carácter fuera del alfabeto (espacio, salto de línea, símbolo del esquema equivocado) | IllegalArgumentException |
Invalid symbol ' '(40) at index 5 |
| Carácter de datos después del padding | IllegalArgumentException |
Symbol 'e'(145) at index 7 is prohibited after the pad character |
Falta padding mientras la opción es PRESENT |
IllegalArgumentException |
The padding option is set to PRESENT, but the input is not properly padded |
Hay padding mientras la opción es ABSENT |
IllegalArgumentException |
The padding option is set to ABSENT, but the input has a pad character at index 7 |
| Índice fuera de los límites de la fuente | IndexOutOfBoundsException |
startIndex: 0, endIndex: 100, size: 8 |
startIndex mayor que endIndex |
IllegalArgumentException |
startIndex: 3 > endIndex: 2 |
Buffer de destino demasiado pequeño para decodeIntoByteArray |
IndexOutOfBoundsException |
The destination array does not have enough capacity, destination offset: 0, destination size: 2, capacity needed: 8 |
Fíjate en el patrón de las dos primeras filas: el mensaje nombra al símbolo culpable, su código numérico entre paréntesis y su índice. Eso es un regalo para depurar. Cuando una decodificación lanza en producción, registra las primeras docenas de caracteres de la entrada y el índice del mensaje, y casi siempre encuentras al culpable en segundos, ya sea un salto de línea pegado, un payload truncado o una cadena base64url que se coló en un decodificador estándar.
Trampas que muerden a los desarrolladores de Kotlin en particular
- Asumir que el payload es texto.
decodedevuelve unByteArraya propósito. Llamar adecodeToString()sobre un JPEG porque "seguro que es texto" te da una pared de caracteres de sustitución. Decide qué son los bytes antes de convertirlos. - Espacios del copy-paste. El decodificador por defecto es estricto, y un valor sacado de un mensaje de chat o de un log llega casi siempre con un salto de línea al final o un espacio al principio. Recorta antes de decodificar, o decodifica a través de
Mime, o acepta laIllegalArgumentExceptiony trátala. - Actualizar desde la era experimental. El código escrito para la API experimental de 1.8.x llevaba anotaciones
@OptIn(ExperimentalEncodingApi::class)y contaba con que el padding fuera opcional. Desde 2.0.20, la misma entrada puede lanzar. La corrección esPRESENT_OPTIONAL, o limpiar las entradas antes de que lleguen al decodificador. - Ajustar el esquema al productor equivocado. Una parte de JWT decodificada con
Base64.Defaultfalla en sus caracteres-y_; un payload de alfabeto estándar decodificado conUrlSafefalla en+y/. La excepción nombra el símbolo exacto, pero la corrección es saber de dónde viene la cadena. - La brecha de charset.
decodeToString()es solo UTF-8, sin sobrecarga para otras codificaciones. Si el remitente usó Latin-1 o Windows-1252, planificaString(bytes, charset)en el JVM, y espera caracteres de sustitución U+FFFD como síntoma cuando se te olvide. - El compilador del paquete de la distribución.
apt install kotlinen Debian y Ubuntu sirve la 1.3.31, de antes de que existiera esta API. Si tus ejemplos de pronto se niegan a compilar con "unresolved reference", comprueba qué compilador está de verdad en el PATH.
Prácticas recomendadas para decodificar
- Decodifica a bytes primero, interpreta después. Mantén
Base64.decodey la conversión a texto como pasos separados. Hace explícito el charset, mantiene los payloads binarios binarios, y vuelve las pruebas triviales: compara arrays de bytes, no cadenas. - Elige la instancia que coincida con el productor. JWT y datos destinados a URL significan
UrlSafe; email y archivos PEM significanMimeoPem; todo lo demás arranca enDefault. Los decodificadores permisivos son para entradas liosas de fama conocida, no una red de seguridad general. - Normaliza la entrada no confiable una vez, y barato. Un
trim()y, cuando el formato se sabe limpio, una limpieza de espacios, antes de una decodificación estricta atrapa más fallos del mundo real que cualquier cantidad de try-catch. Un pequeño helper con respaldoPRESENT_OPTIONALes un buen patrón para valores de origen desconocido. - Presupuesta el tamaño antes de reservar memoria. La salida decodificada es como mucho tres cuartos de la longitud de la entrada (cuatro símbolos cargan tres bytes), así que un rápido chequeo de longitud te dice el tamaño de destino antes de decodificar, que es exactamente lo que quieres antes de llenar un buffer pre-asignado o aceptar una cadena de varios megabytes.
- Fíate del mensaje de error. La biblioteca estándar informa del símbolo, su código y su índice. Registra el entorno de ese índice para entradas no confiables y deja de adivinar.
- No decodifiques para esconder cosas, ni decodifiques para demostrar cosas. Base64 es una codificación de transporte. No añade secreto ni integridad; si necesitas una de las dos, ese es el trabajo de la criptografía, no del decodificador.
Cómo llegó Base64 a Kotlin
Base64 es unos cuantos decenios mayor que Kotlin - la especificación MIME que dio la regla de línea de 76 caracteres data de 1993, y el propio alfabeto de RFCs de mediados de los 90 - pero la historia propia de Kotlin es corta y reciente. El paquete kotlin.io.encoding llegó en Kotlin 1.8.20, en abril de 2023, trayendo Base64 con tres instancias - Default, UrlSafe y Mime - tras la anotación @ExperimentalEncodingApi, junto con las extensiones de streaming solo para JVM que siguen siendo experimentales hoy. Durante dos años, usarlo significaba una línea de opt-in en cada función y la pequeña probabilidad de que la API se moviera.
Kotlin 2.2.0, lanzado en junio de 2025, cambió el contrato. La API entera se volvió estable en una sola release, y la instancia Pem se unió a la familia (la variante de RFC 1421, de línea de 64 caracteres, que se usa alrededor de PKI). La estricticidad que hace que el código de padding opcional de la era 1.8 necesite atención después de una actualización llegó un paso antes: en 2.0.20, cuando withPadding con sus cuatro valores de PaddingOption sustituyó al viejo comportamiento fijo y el decodificador empezó a exigir padding. La release 2.2 también estabilizó a la clase hermana HexFormat en kotlin.text, la API de formato hexadecimal que era experimental desde Kotlin 1.9, así que las codificaciones de texto a nivel de byte tienen ya un hogar asentado en la biblioteca estándar. Y una nota de mantenimiento: desde Kotlin 2.4.0, la biblioteca estándar del JVM se publica con una ventana de soporte de 18 meses por línea de release, que es una razón más por la que un proyecto en la línea actual 2.4.x puede tratar esta API como un punto fijo y no como uno en movimiento.
Datos curiosos
- El objeto companion hace un trabajo. Como
Defaultes el companion deBase64, el nombre de la clase hace también de instancia por defecto:Base64.decode(x)yBase64.Default.decode(x)son la misma llamada. Es la razón por la que el ejemplo de dos líneas al principio de este artículo se queda en dos líneas. - La decodificación de cadenas recibe un truco de velocidad en el JVM. El bucle común de decodificación trabaja sobre bytes, pero las cadenas de Kotlin son secuencias de caracteres. La implementación del JVM evita la conversión reinterpretando los caracteres de una
Stringcomo valores de un solo byte ISO-8859-1 antes de que corra el bucle compartido - un truco que los comentarios del código fuente aseguran que es hasta diez veces más rápido que el camino común, y es por eso quedecode(String)se siente instantáneo incluso con payloads largos. - El nombre del paquete es una pista. Encontrarás esta API en
kotlin.io.encoding, no enkotlin.text, porque el punto entero es que los datos son bytes - la entrada y la salida tienen forma de E/S, y el texto es solo lo que le pasa al resultado después. - Los mensajes de error incluyen el código del carácter.
Symbol 'e'(145)informa del valor del símbolo culpable en octal, no solo de su glifo. Práctico cuando el culpable es un espacio:' '(40)te dice que era un espacio mucho antes de que te lo imagines. - PEM llegó tarde.
Base64.Pemno formaba parte de la API original de 1.8.20; apareció con la estabilización de 2.2. Si una entrada de blog de 2023 o 2024 lista solo tres instancias, no es errónea - está simplemente dos releases desactualizada. - Está escrito por plataforma, no delegado. La biblioteca estándar implementa el codec por separado para cada objetivo con funciones expect/actual. En el JVM hay hasta una optimización comentada que entregaría el trabajo a
java.util.Base64, desactivada tras un issue abierto del compilador, y es por eso que el comportamiento de la implementación de Kotlin es el comportamiento de referencia en cada plataforma.
Para rematar
Decodificar Base64 en Kotlin se reduce a una lista corta de decisiones deliberadas: la instancia que coincide con el sitio de donde vino el dato, el modo de padding que coincide con cómo fue enviado, el buffer o stream que coincide con lo grande que es, y el conjunto de caracteres que coincide con lo que significa. La biblioteca estándar te entrega los cuatro como funciones normales sin dependencias, y sus mensajes de error son lo bastante específicos para que un fallo sea un diagnóstico, no un misterio. La dirección opuesta - elegir el esquema y el padding correctos y la envoltura de líneas adecuada cuando eres tú quien produce el Base64 - tiene sus propias decisiones y sus propias trampas, y el artículo relacionado en el sitio hermano cubre la codificación Base64 en Kotlin en profundidad.
Última actualización: 2026-09-08
Artículo relacionado: Codificación Base64 en Kotlin: una guía completa