Decodificación Base64 en Go: una guía completa
Hay una cadena larga escondida en la respuesta de una API, y finge ser un valor cuando en realidad es un archivo, un token, una imagen o un mensaje de un sistema tres años mayor que el tuyo. En algún rincón de tus líneas de log, filas de base de datos y payloads JSON, las cadenas base64 aparecen constantemente: el alfabeto estándar de 64 letras, a veces con un signo más y una barra, a veces con un guion y un guion bajo, y de vez en cuando dos signos de igualdad estacionados al final como una firma.
La página principal de este sitio ya explica el formato en sí: 64 caracteres imprimibles que transportan 6 bits cada uno, cuatro caracteres por cada tres bytes de entrada, y padding para rematar el trabajo. Así que este artículo va directo a la mitad del trabajo donde viven las decisiones interesantes: abrir esas cadenas en Go. La buena noticia es que Go es un lugar estupendo para hacerlo. Un paquete de la biblioteca estándar, cero dependencias, un decodificador estricto por defecto pero indulgente con los saltos de línea, y mensajes de error que señalan el byte exacto que falló.
Lo que viene con Go
Todo lo que necesitas ya está en la biblioteca estándar. El paquete se llama encoding/base64, su archivo de fuente todavía lleva un encabezado de copyright de 2009, del año en que nació el lenguaje, y no hay ninguna extensión que activar, ningún módulo que descargar, ninguna configuración que cambiar. Si go version imprime algo en tu máquina, ya eres dueño de toda la herramienta.
A fecha de escribir esto, la versión más nueva es Go 1.27.1, publicada el 1 de septiembre de 2026, con la línea Go 1.26 (actualmente 1.26.8) como la otra rama soportada. La API de base64 es idéntica en ambas, y gracias a la promesa de compatibilidad de Go 1, un programa que decodifica base64 hoy seguirá haciendo exactamente lo mismo en cada versión futura. Consigue el propio Go desde los tarballs oficiales en go.dev/dl (algo como go1.27.1.linux-amd64.tar.gz, descomprimido en /usr/local), desde el gestor de paquetes de tu distribución (sudo apt install golang-go en sistemas basados en Ubuntu), o a través del envoltorio golang.org/dl si te gustan varias versiones de Go lado a lado.
Una vez instalado Go, go doc encoding/base64 imprime la API completa en una columna legible, que es la forma más rápida de refrescar la memoria. El único complemento que este artículo usa en algún punto es golang.org/x/text para conjuntos de caracteres legacy, instalado con go get golang.org/x/text. Aparece una vez, en su propia sección, y el resto es biblioteca estándar pura.
Tu primera decodificación
Noventa por ciento de la vida decodificando en Go es un solo método del tipo Encoding:
func (enc *Encoding) DecodeString(s string) ([]byte, error)
Le das una cadena base64, y te devuelve los bytes que representa, más un error cuando la entrada se porta mal:
package main
import (
"encoding/base64"
"fmt"
)
func main() {
decoded, err := base64.StdEncoding.DecodeString("TWFu")
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(string(decoded)) // Man
}
Dos cosas de esa firma merecen la pena memorizarlas. Primera, el resultado es un []byte, no un string, porque los bytes que desenmascaras pueden ser base64 perfectamente válido y texto perfectamente terrible: un encabezado PNG, un archivo comprimido, un protocolo binario. Envuélvelo en string(...) solo cuando sepas que el payload es texto. Segunda, el método siempre devuelve dos valores. Un error nil significa que la cadena era base64 limpio; un error no nil significa que la entrada estaba rota en algún sitio, y el slice de bytes que recibiste puede ser un resultado parcial en lugar de uno vacío. Verás las dos caras de ese comportamiento en la sección de errores de más abajo.
Cuatro decodificadores, una pregunta: ¿qué alfabeto?
Go trae cuatro valores Encoding ya preparados, y elegir el correcto es la primera decisión real de cada decodificación. La tabla de abajo es el plano de asientos:
| Variable | Alfabeto | Padding | Dónde lo encontrarás |
|---|---|---|---|
StdEncoding |
A-Z a-z 0-9 + / |
= |
Correo MIME, data URLs, autenticación HTTP Basic, archivos PEM, JSON general |
URLEncoding |
A-Z a-z 0-9 - _ |
= |
Rutas y consultas de URL, nombres de archivo |
RawStdEncoding |
A-Z a-z 0-9 + / |
ninguno | Base64 estándar sin padding de productores compactos |
RawURLEncoding |
A-Z a-z 0-9 - _ |
ninguno | Segmentos de JWT, identificadores compactos de API |
La forma más rápida de elegir es mirar los datos en sí. Una cadena que contiene + o / solo puede ser una cadena del alfabeto estándar, así que necesita uno de los dos decodificadores Std. Una cadena que contiene - o _ es la variante URL-safe de RFC 4648, así que necesita uno de los dos decodificadores URL. Después comprueba la cola: los caracteres = finales significan la variante con padding, y su ausencia significa la Raw. Así se siente la elección equivocada:
decoded, err := base64.StdEncoding.DecodeString("P29_")
// err: illegal base64 data at input byte 3
// el guion bajo no está en el alfabeto estándar, así que
// el decodificador se detiene en el último carácter que no reconoce
decoded, err = base64.URLEncoding.DecodeString("P29_")
// decoded son los tres bytes 0x3f 0x6f 0x7f, err es nil
Si estás decodificando datos de un productor que definió su propio alfabeto de 64 caracteres, base64.NewEncoding("...64 chars...") te construye un decodificador para él. El alfabeto debe tener exactamente 64 valores de byte únicos y no debe contener un salto de línea - la función lanza un pánico en caso contrario - y aunque la documentación exige que el alfabeto excluya el carácter de padding, la función no lo impone - un alfabeto que contiene '=' se acepta sin pánico. En el día a día rara vez lo necesitarás, pero está ahí, y es la única forma de decodificar un esquema privado.
El problema de la tolerancia: ¿qué entrada acepta Go?
Cada decodificador base64 tiene que tomar una decisión incómoda: ¿cuánta basura está dispuesto a tragar? La respuesta de Go es una línea trazada con cuidado. Del lado indulgente, el decodificador salta los retornos de carro y los saltos de línea en cualquier parte de la entrada, así que una cadena que un cliente de correo o una herramienta PEM rompió en muchas líneas se decodifica sin ningún preprocesado:
decoded, err := base64.StdEncoding.DecodeString("T\nW\nF\r\nu")
// decoded es "Man", err es nil
// cada \r y \n de la cadena se ignoró simplemente
Del lado estricto, todo lo demás está prohibido. Un espacio, una tabulación, un carácter de anchura cero copiado de un PDF, un colón errante de un encabezado: en el momento en que el decodificador se encuentra con un carácter que no está en el alfabeto y no es un salto de línea, se detiene e informa el offset. Y conserva todo lo que ya había decodificado:
decoded, err := base64.StdEncoding.DecodeString("TWFu junk")
// decoded es "Man" (la parte antes del espacio),
// err es: illegal base64 data at input byte 4
Esa combinación sorprende a la gente: una decodificación fallida aún puede entregarte un medio resultado utilizable. Si eso es una característica o un peligro depende de ti; el punto es que err == nil es la única condición bajo la cual los datos son completos.
El padding tiene sus propias reglas, y difieren entre las variantes con padding y las raw. Los decodificadores con padding trabajan en grupos: un grupo son o cuatro caracteres reales o dos caracteres reales seguidos de ==. Un carácter por sí solo nunca es un grupo completo, así que "T" falla, y "TWF" también falla, porque tres caracteres necesitan un signo de padding que falta. Los decodificadores raw descartan el requisito de padding, pero tampoco pueden aceptar una longitud donde a un grupo le faltaran tres de sus cuatro caracteres, así que "T" falla ahí también, mientras "TW" se decodifica felizmente a un solo byte.
base64.StdEncoding.DecodeString("T") // error en el byte 0 de la entrada
base64.StdEncoding.DecodeString("TWF") // error en el byte 0 de la entrada
base64.RawStdEncoding.DecodeString("TW") // 1 byte, sin error
base64.StdEncoding.DecodeString("TWFu====") // "Man" más error en el byte 4
Hay un cambio de humor más: Strict(), añadido en Go 1.8. En modo estricto el decodificador impone la forma canónica de la sección 3.5 de RFC 4648: los bits de cola sin usar del grupo final deben ser cero. El modo normal no se preocupa, porque esos bits simplemente nunca se usan, así que "Qm==" se decodifica al byte B sin quejarse. El modo estricto lo rechaza en su lugar:
decoded, err := base64.StdEncoding.DecodeString("Qm==")
// decoded es "B", err es nil (los bits de cola se descartaron)
decoded, err = base64.StdEncoding.Strict().DecodeString("Qm==")
// err es: illegal base64 data at input byte 2
Ojo: incluso en modo estricto los saltos de línea se siguen saltando, como señala la documentación. Usa Strict() cuando hables un protocolo que se preocupa por la codificación canónica, o cuando quieras rechazar a los productores descuidados en lugar de absorber sus bits en silencio.
Errores que te dicen dónde
Cada fallo en este paquete llega como un valor concreto e inspeccionable. Cuando la entrada contiene algo que el alfabeto no conoce, o el padding es incorrecto, el decodificador devuelve un base64.CorruptInputError, y su mensaje incluye el offset del byte del problema:
type CorruptInputError int64
func (e CorruptInputError) Error() string {
return "illegal base64 data at input byte " + strconv.FormatInt(int64(e), 10)
}
Ese offset es la diferencia entre "algo falló" y "el carácter 4.102 de esta cadena de 900 kilobytes es una tabulación que se coló desde el portapapeles". Captúralo con el idiomatismo habitual de Go:
package main
import (
"encoding/base64"
"errors"
"fmt"
)
func main() {
_, err := base64.StdEncoding.DecodeString("TWF$")
var corrupt base64.CorruptInputError
if errors.As(err, &corrupt) {
fmt.Printf("bad byte at offset %d: %v\n", int(corrupt), err)
// bad byte at offset 3: illegal base64 data at input byte 3
return
}
fmt.Println("not a corrupt-input error:", err)
}
Aquí va el cuadro de síntomas para las entradas que más confunden:
| Entrada (StdEncoding) | Resultado | Por qué |
|---|---|---|
TWF$ |
error en el byte 3 | $ no está en el alfabeto |
T |
error en el byte 0 | un carácter nunca es un grupo completo |
TWF |
error en el byte 0 | tres caracteres necesitan un = que falta |
TWFu junk |
Man más error en el byte 4 |
el espacio no es un salto de línea, así que la decodificación se detiene ahí |
TWFu\t |
Man más error en el byte 4 |
las tabulaciones no se saltan, solo \r y \n |
T\nW\nF\nu |
Man, sin error |
los saltos de línea se ignoran en cualquier parte |
==== |
error en el byte 0 | el padding al inicio de un grupo no es válido |
(cadena vacía) |
resultado vacío, sin error | cero bytes de base64 se decodifican a cero bytes |
Un consejo práctico: cuando una decodificación falla en producción, registra el offset y una ventana corta alrededor. Noventa por ciento de las veces el byte "corrupto" es un espacio en blanco que el transporte, el portapapeles o un visor de PDF colaron en la cadena, y la solución es un recorte o una limpieza, no un rediseño.
Abriendo archivos
Los archivos base64 son solo archivos de texto que contienen base64, así que las herramientas de archivos habituales de Go aplican. Para un archivo que cabe cómodamente en memoria, léelo entero y decodifica la cadena:
package main
import (
"encoding/base64"
"fmt"
"io"
"os"
)
func main() {
f, err := os.Open("payload.b64")
if err != nil {
fmt.Println("open failed:", err)
return
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
fmt.Println("read failed:", err)
return
}
decoded, err := base64.StdEncoding.DecodeString(string(raw))
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println("decoded", len(decoded), "bytes")
}
Para archivos grandes, el patrón mejor es el streaming, y usa la otra mitad de la API del paquete: NewDecoder envuelve cualquier io.Reader en un lector que decodifica base64, así que puedes pasar de archivo a archivo por un pipe sin tener nunca el payload entero en memoria:
in, err := os.Open("payload.b64")
if err != nil {
panic(err)
}
defer in.Close()
dec := base64.NewDecoder(base64.StdEncoding, in)
out, err := os.Create("payload.bin")
if err != nil {
panic(err)
}
defer out.Close()
written, err := io.Copy(out, dec)
if err != nil {
panic(err)
}
fmt.Println("wrote", written, "bytes")
Hay una opción intermedia cuando quieres evitar la asignación extra que hace DecodeString: Decode escribe en un buffer de destino que tú controlas. Dále el tamaño con DecodedLen, que devuelve el número máximo de bytes de salida para una longitud de entrada dada:
raw, err := os.ReadFile("payload.b64")
if err != nil {
panic(err)
}
buf := make([]byte, base64.StdEncoding.DecodedLen(len(raw)))
n, err := base64.StdEncoding.Decode(buf, raw)
if err != nil {
panic(err)
}
data := buf[:n] // el tamaño decodificado real
fmt.Println(len(data), "bytes")
Cuidado con ese último, eso sí: Decode confía en que tú le des el tamaño al buffer. Si es demasiado pequeño, el método no devuelve un error; lanza un pánico por índice fuera de rango. DecodedLen es el número que hay que usar, no len(raw).
Una línea de comandos base64 para Go
Los sistemas Unix traen una utilidad base64 con coreutils, y Go no trae ningún binario equivalente. La respuesta idiomática en el mundo Go no es un paquete que instalas sino un programa que es tuyo: una pequeña herramienta de línea de comandos construida alrededor de encoding/base64, el paquete flag y la entrada estándar. Aquí va una completa, unas cuarenta líneas, que decodifica lo que le pipees y escribe los bytes crudos afuera:
package main
import (
"encoding/base64"
"flag"
"fmt"
"io"
"os"
)
func main() {
urlSafe := flag.Bool("url", false, "use the URL-safe alphabet")
flag.Parse()
enc := base64.StdEncoding
if *urlSafe {
enc = base64.URLEncoding
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "read failed:", err)
os.Exit(1)
}
decoded, err := enc.DecodeString(string(raw))
if err != nil {
fmt.Fprintln(os.Stderr, "decode failed:", err)
os.Exit(1)
}
os.Stdout.Write(decoded)
}
Cómpiala una vez con go build -o b64 . y se convierte en un decodificador multiplataforma que puedes meter en un Makefile, un pipeline de CI o una función del shell: printf 'TWFu' | ./b64 imprime Man, y ./b64 -url < token.b64 > token.bin desenmarca un token URL-safe en un archivo. Dos propiedades del diseño merecen la pena. Como lee todo el stdin antes de decodificar, una entrada envuelta con saltos de línea se decodifica sin problema, gracias a la tolerancia a saltos de línea del decodificador. Y como sale con estado 1 ante una entrada mala y escribe la queja en stderr, se comporta como una herramienta en un pipeline en lugar de un script que se disculpa. Ese es todo el arte de un CLI en Go: un paquete, un flag, entrada estándar, salida estándar, y un código de salida.
Decodificación URL-safe
La variante URL-safe existe porque el alfabeto estándar colisiona con la gramática de las URLs: + se lee a menudo como un espacio en las cadenas de consulta, y / empieza un nuevo segmento de ruta, así que una cadena base64 estándar incrustada en una URL tiene que escaparse con porcentaje carácter a carácter, lo cual es lento de analizar y feo de leer. El alfabeto alternativo de RFC 4648 cambia + y / por - y _, ambos legales sin escape en rutas de URL, consultas y nombres de archivo.
En Go el cambio es simplemente una variable de decodificador distinta. Si tus datos son URL-safe con padding, usa URLEncoding; si son URL-safe sin padding, usa RawURLEncoding. El caso clásico es un identificador que vive en una URL o en un nombre de archivo:
decoded, err := base64.RawURLEncoding.DecodeString("-w9n")
// decoded son los tres bytes 0xfb 0x0f 0x67
// el guion y el guion bajo forman parte del alfabeto URL-safe,
// así que RawURLEncoding los maneja donde StdEncoding fallaría
Dónde lo encontrarás en código Go real: segmentos de JWT (cubiertos a continuación), identificadores opacos que los sistemas generan y guardan en URLs, nombres de archivo que no deben romper un servidor web o un almacén de objetos en la nube, y cualquier API que prometió "base64url" en su documentación. Una advertencia: URL-safe es un contrato entre productor y consumidor, no una propiedad de los datos. Si la cadena contiene un + o un /, no es URL-safe, punto, y reintentar con el decodificador de URL no servirá de nada. Mira primero los caracteres, y elige el decodificador después.
Asomándose dentro de los JWT
Un JSON Web Token son tres segmentos base64url separados por puntos: un encabezado, un payload de claims, y una firma, sin padding en ninguno de los segmentos. Eso convierte a un JWT en una de las cosas más comunes que decodificarás en Go, y el encabezado y el payload se pueden leer sin ninguna clave, algo que vale la pena recordar tanto para depurar como para revisiones de seguridad:
package main
import (
"encoding/base64"
"fmt"
"log"
"strings"
)
func main() {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parts := strings.Split(token, ".")
if len(parts) != 3 {
log.Fatal("not a JWT: expected three dot-separated parts")
}
for i, name := range []string{"header", "payload"} {
plain, err := base64.RawURLEncoding.DecodeString(parts[i])
if err != nil {
log.Fatalf("bad %s: %v", name, err)
}
fmt.Printf("%s: %s\n", name, plain)
}
// header: {"alg":"HS256","typ":"JWT"}
// payload: {"name":"Go Developer","sub":"1234567890"}
}
Fíjate en la elección del decodificador: RawURLEncoding, no StdEncoding. Los segmentos de JWT usan el alfabeto URL-safe y no llevan padding, y un segmento cuya longitud queda a uno o dos de un múltiplo de cuatro hará fallar a un decodificador con padding justo al final, que es un error confuso de perseguir. El segmento de firma no se puede leer sin la clave, y no deberías intentar confiar en nada basándote solo en el payload, porque nada impide que un cliente falsifique los dos primeros segmentos. Cuando necesites verificación, usa una biblioteca mantenida. La de facto es github.com/golang-jwt/jwt/v5 (instálala con go get github.com/golang-jwt/jwt/v5):
package main
import (
"fmt"
"log"
"github.com/golang-jwt/jwt/v5"
)
func main() {
secret := []byte("hmac-secret")
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiR28gRGV2ZWxvcGVyIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.NwJQAKfJpMJQuK0gEECtXtO8cIoFnDp0ovXyl7dY1BQ"
parsed, err := jwt.Parse(token, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
log.Fatal("token rejected:", err)
}
claims, _ := parsed.Claims.(jwt.MapClaims)
fmt.Println("subject:", claims["sub"])
}
Dos detalles de la biblioteca merecen la pena saberlos. Primero, la codificación y decodificación base64url de los tres segmentos se maneja por dentro, así que nunca tocas encoding/base64 directamente cuando firmas o verificas. Segundo, la biblioteca v5 rechaza tokens con alg=none a menos que pases explícitamente su constante UnsafeAllowNoneSignatureType, lo cual te protege del clásico error del "token sin firma aceptado".
Data URLs
Una data URL es una URL cuyo payload son los datos en sí. La sintaxis, de RFC 2397, es data:[mediatype][;base64],data: un tipo de medio opcional, un flag ;base64 opcional, una coma, y después el contenido. Cuando el flag ;base64 está presente, el contenido es base64 estándar, y por eso las data URLs y este artículo comparten sección. Los navegadores las usan para incrustar imágenes y fuentes directamente en HTML y CSS, así que la página necesita una petición menos:
<img src="data:image/png;base64,iVBORw0KGgo=" alt="pixel">
La biblioteca estándar de Go no tiene ningún auxiliar de data URLs, pero el formato es lo bastante simple como para analizarlo a mano con strings, que es lo que hacen la mayoría de los programas Go:
package main
import (
"encoding/base64"
"fmt"
"strings"
)
func main() {
url := "data:image/png;base64,iVBORw0KGgo="
if !strings.HasPrefix(url, "data:") {
fmt.Println("not a data URL")
return
}
rest := url[len("data:"):]
comma := strings.Index(rest, ",")
if comma == -1 {
fmt.Println("missing comma")
return
}
meta := rest[:comma] // image/png;base64
encoded := rest[comma+1:] // iVBORw0KGgo=
if !strings.HasSuffix(meta, ";base64") {
fmt.Println("this variant is percent-encoded, not base64")
return
}
mediaType := strings.TrimSuffix(meta, ";base64")
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Println(mediaType, "carries", len(decoded), "bytes")
}
Tres trampas que conviene tener en cuenta. Primera, el flag ;base64 es opcional, y sin él el payload es ASCII con escape de porcentaje en lugar de base64, así que comprueba el sufijo antes de llamar a un decodificador. Segunda, cuando el tipo de medio se omite, el valor por defecto es text/plain;charset=US-ASCII, lo cual rara vez importa para imágenes pero sorprende a quien analiza otro contenido. Tercera, las data URLs son un truco de payload pequeño: el propio RFC dice que el esquema solo es útil para valores cortos, y la expansión de tamaño del 33 por ciento de base64 convierte un logo de 500 kilobytes en una cadena de 666 kilobytes pegada en tu HTML, imposible de cachear y de compartir. Úsalas para iconos y miniaturas, no para vídeos.
Trabajo HTTP y de API
La decodificación más común en servicios web de Go es el campo del cuerpo JSON: un formulario de subida, una respuesta de API o un webhook te entregan una cadena que en realidad es un archivo. Desenmarca el JSON en un struct, y luego decodifica el campo:
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
)
type payload struct {
Avatar string `json:"avatar"`
}
func main() {
body := []byte(`{"avatar": "iVBORw0KGgo="}`)
var p payload
if err := json.Unmarshal(body, &p); err != nil {
fmt.Println("bad JSON:", err)
return
}
img, err := base64.StdEncoding.DecodeString(p.Avatar)
if err != nil {
fmt.Println("bad avatar:", err)
return
}
fmt.Println("avatar is", len(img), "bytes")
}
Si tu API acepta cadenas estándar y URL-safe, el patrón pragmático es probar un decodificador, y si falla con un CorruptInputError cerca del final, probar el otro antes de rendirte. No hagas esta danza más de una vez, y nunca te refugies en "quitar los signos de igualdad y esperar" como estrategia general.
Para la autenticación HTTP Basic, no decodificas nada en absoluto, porque Go lo hace por ti. Request.BasicAuth, disponible desde Go 1.4, parte el encabezado Authorization por ti y devuelve el nombre de usuario y la contraseña, después de haber corrido ya el decodificador base64 estándar sobre el par user:pass que define RFC 2617:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "alice" || pass != "s3cret" {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "hello", user)
})
http.ListenAndServe(":8080", mux)
}
Recuerda que la autenticación Basic es autenticación, no protección: el encabezado es base64, no cifrado, así que solo debe viajar por HTTPS. Si tú eres el cliente, la llamada espejo es req.SetBasicAuth(user, pass), que construye el mismo encabezado por ti con el codificador estándar.
Un hábito defensivo para los manejadores de API: limita el cuerpo antes de decodificarlo, con http.MaxBytesReader o una comprobación de longitud equivalente. Una cadena base64 se decodifica a aproximadamente tres cuartos de su propia longitud, así que un límite de cuerpo de N bytes mantiene el resultado decodificado por debajo de N bytes, y la memoria sigue acotada sin importar qué publique un cliente malicioso. Decodificar un cuerpo sin límite es un vector clásico de agotamiento de memoria, porque el atacante controla cuántos megabytes de texto puede convertir en binario.
Conjuntos de caracteres legacy
Decodificar base64 te da bytes, y en los sistemas modernos esos bytes son casi siempre UTF-8, en cuyo caso string(decoded) es la historia entera. Pero base64 es un formato antiguo y mucha de él fue producida por sistemas que usaban Windows-1252, ISO-8859-1, Shift JIS u otro charset legacy de un byte o de dos bytes. Si el productor hizo eso, los bytes que decodificas no son UTF-8 válido, y Go no va a fingir que lo son: te mostrará caracteres de reemplazo donde haya una secuencia rota.
La respuesta de Go es el módulo golang.org/x/text, que convierte bytes codificados en legacy a UTF-8 (y de vuelta) para los charsets comunes. El sitio de la conversión va justo después de la decodificación, y cuesta una llamada a función:
package main
import (
"encoding/base64"
"fmt"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/transform"
)
func main() {
// "Café" guardado como Windows-1252 por una herramienta legacy,
// y después codificado en base64 para el transporte
encoded := "Q2Fm6Q=="
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
fmt.Println("decode failed:", err)
return
}
utf8, _, err := transform.Bytes(charmap.Windows1252.NewDecoder(), raw)
if err != nil {
fmt.Println("charset conversion failed:", err)
return
}
fmt.Println(string(utf8)) // Café
}
El módulo tiene un subpaquete por familia de charset: charmap para las tablas de un byte de Windows e ISO, japanese para Shift JIS y EUC-JP, korean para EUC-KR, simplifiedchinese para GB18030, y traditionalchinese para Big5. La regla de pulgar es convertir solo cuando de verdad sepas el charset del productor, porque convertir bytes UTF-8 una segunda vez no falla estrepitosamente; solo desgarbilla el texto. Ante la duda, trata el payload como bytes y deja que el consumidor de aguas abajo decida.
Streaming y decodificaciones por trozos
Viste NewDecoder en la sección de archivos; aquí está lo que le merece una sección propia. Es un adaptador de streaming de verdad: extrae del lector subyacente solo lo necesario, decodifica en el sitio, y devuelve un CorruptInputError en el momento en que el stream se estropea. El stream entero puede ser terabytes; la memoria que mantienes es tu buffer y la salida que escribes. Los dos patrones de consumo comunes son io.ReadAll para streams pequeños y io.Copy para todo lo demás:
small, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, r))
// bien para un blob de configuración o un adjunto pequeño
w, err := io.Copy(out, base64.NewDecoder(base64.StdEncoding, r))
// bien para un vídeo, un tarball o un trabajo de restauración
Desde Go 1.22 el paquete también tiene AppendDecode, que decodifica en un buffer que reutilizas en lugar de asignar un slice nuevo por llamada. Es la herramienta para las rutas calientes que decodifican muchos trozos en un bucle, como un procesador de líneas o un decodificador de protocolo:
var buf []byte
for _, chunk := range chunks {
buf, err = base64.StdEncoding.AppendDecode(buf, chunk)
if err != nil {
return err
}
process(buf)
}
El método añade el trozo decodificado a lo que buf ya contiene y devuelve el slice ampliado, creciendo el array subyacente según haga falta. En estado estable, donde el buffer ya ha crecido al tamaño correcto, realiza cero asignaciones por trozo, algo que se nota claramente en un benchmark. Si tu carga de trabajo es "decodificar una vez, rara vez", DecodeString es la elección más simple; si es "decodificar miles de veces en un bucle apretado", AppendDecode es la que debes buscar.
Manteniéndolo seguro
Unas notas de seguridad específicas de cómo los programas Go usan este paquete en la práctica. Primera, base64 es codificación, no cifrado. Una cadena base64 es legible por cualquiera con las herramientas de desarrollo de un navegador web, así que "base64 la contraseña antes de enviarla" no es una medida de seguridad; es una comodidad de transporte. La confidencialidad tiene que venir de TLS, no del alfabeto.
Segunda, acota tus entradas. El tamaño decodificado de una cadena base64 es como máximo DecodedLen de su longitud, así que comprueba ese número contra un límite antes de asignar memoria, y envuelve los cuerpos de petición con un tope de tamaño antes de que nada toque a un decodificador. Las dos comprobaciones son una línea cada una, y juntas convierten una decodificación sin límite en una acotada.
Tercera, decide tu postura ante la entrada floja. El modo normal descarta en silencio los bits de cola sin usar del grupo final, lo que significa que dos cadenas distintas pueden decodificarse a los mismos bytes. Para la mayoría de los datos eso no importa. Para cualquier cosa que sea parte de un protocolo, un mensaje firmado, o un valor que se compara o se guarda, Strict() es la elección conservadora, porque convierte la forma canónica en la única forma aceptada.
Cuarta, cuida a dónde van los bytes decodificados. Si un valor decodificado se convierte en un nombre de archivo, una ruta, un fragmento SQL o un argumento de comando, la capa base64 no te protegió de nada: los bytes son ahora la entrada no confiable de tu programa, y las reglas habituales de saneado aplican exactamente como con cualquier otro dato de usuario.
¿Qué tan rápido es el decodificador?
Base64 en Go es rápido, y se mantiene rápido con datos grandes porque la implementación es un bucle simple de búsqueda en tabla, sin reflexión y sin asignación por carácter. En una CPU de escritorio reciente corriendo Go 1.26, una cadena de 500 bytes se decodifica en aproximadamente una cuarta parte de un microsegundo con una asignación, lo que se traduce en unas dos gigabytes por segundo. Un megabyte de base64 se decodifica en mucho menos de un milisegundo; un gigabyte en mucho menos de un segundo. Los números cambian con el hardware, pero la forma no: la decodificación base64 casi nunca es el cuello de botella; casi siempre lo es la red o el disco que la rodean.
Si estás en un bucle caliente, el perfil de asignaciones es lo que hay que vigilar. DecodeString asigna el slice de resultado en cada llamada. Decode con un destino de tamaño predefinido y AppendDecode con un buffer reutilizado evitan esa asignación por completo en estado estable. Para una decodificación que pasa unas pocas veces por petición, nada de esto importa; para una decodificación que pasa unos pocos millones de veces por segundo, es la diferencia entre un perfil de memoria plano y un recolector de basura a tope.
Una breve historia del paquete
El paquete base64 es una de las partes más antiguas de la biblioteca estándar de Go. El encabezado de copyright del archivo de fuente dice 2009, el año en que se creó el lenguaje, y el paquete ha sido parte de la biblioteca estándar desde la primera versión estable, Go 1.0, en marzo de 2012. Eso significa que el DecodeString que llamas hoy es la misma API, con el mismo comportamiento, que los programas Go llevan llamando desde hace más de una década.
El crecimiento desde entonces ha sido modesto y útil. Go 1.5 en agosto de 2015 añadió los valores RawStdEncoding y RawURLEncoding sin padding, que abrieron la puerta a cadenas compactas estilo JWT. Go 1.8 en febrero de 2017 añadió Strict(), dando a los protocolos una forma de exigir entrada canónica. Go 1.22 en febrero de 2024 añadió AppendDecode y AppendEncode a toda la familia de codificaciones base, y endureció WithPadding para rechazar argumentos sin sentido. Y a fecha de septiembre de 2026, con Go 1.27.1 como la versión más nueva y Go 1.26 como la otra línea soportada, la API es exactamente la que describe este artículo: cuatro codificaciones preparadas, un decodificador de stream, un modo estricto, y una familia de append para rendimiento.
El dato más profundo es la promesa de compatibilidad. La garantía de Go 1 significa que el paquete seguirá aceptando y rechazando las mismas entradas para siempre, así que un decodificador que escribas este año contra un formato de datos producido en 2015 seguirá funcionando. Para un formato tan antiguo y tan aburrido, esa es la mejor noticia que hay.
Cosas que te sorprenderán
Después de un rato en Go dejas de sorprenderte con base64, pero las primeras veces, algunos de estos datos caen con fuerza, así que aquí están:
- El decodificador salta
\ry\nen cualquier parte de la entrada, pero no un espacio, no una tabulación, no un espacio de anchura cero. La indulgencia es deliberada; existe para que la entrada envuelta por MIME funcione, y se detiene exactamente donde se detiene la especificación. - Una decodificación fallida aún puede devolver datos reales. El resultado parcial es todo lo decodificado antes del byte malo, y el error llega junto a él, no en su lugar.
CorruptInputErrores literalmente solo unint64con un método colgado. El "error" es el offset, y el mensaje se construye bajo demanda.DecodeyEncodeconfían en que tú les des el tamaño a sus buffers de destino. Dales un buffer demasiado pequeño y no recibes un error; recibes un pánico.- Un carácter solo no es entrada válida para ninguna de las cuatro codificaciones integradas. Un carácter base64 transporta seis bits, y un byte necesita ocho, así que no hay grupo completo en un carácter, con padding o sin él.
- A fecha de agosto de 2026, más de 244.000 paquetes públicos en pkg.go.dev listan
encoding/base64entre sus imports. Es, en silencio, uno de los paquetes de los que más depende el ecosistema entero.
Dónde se equivocan las decodificaciones
Estos son los errores de decodificación que siguen apareciendo en codebases Go, más o menos en el orden en que aparecen en los hilos de soporte:
- Elegir
StdEncodingpara datos URL-safe (o al revés). El síntoma es un error en el primer-,_,+o/, y la solución es mirar la cadena antes de elegir el decodificador. - Pegar una cadena desde un terminal, un correo o un PDF, que mete espacios, tabulaciones o artefactos de fin de línea. Go salta los saltos de línea reales, pero un espacio a mitad de cadena es un byte corrupto, y el offset del error apuntará justo a él.
- Olvidar que el resultado es un
[]byte. Imprimirlo en crudo te da una lista de números, y pasárselo a una función que espera un string necesita una conversiónstring(...). - Comprobar el error y aun así usar los datos parciales. El prefijo medio decodificado es real, pero no es el payload, y el código que lo trata como tal falla en producción con datos que miden exactamente la mitad de la longitud correcta.
- Dimensionar el buffer de
Decodeconlen(src)en lugar deDecodedLen(len(src)). El primer tamaño está mal en la otra dirección de la que esperas, y el pánico que provoca solo pasa con entradas grandes, lo que lo convierte en un favorito de los entornos de staging. - Dar por sentado que los segmentos de JWT llevan padding. No lo llevan, y un decodificador con padding falla en el último carácter con un error que se lee como un misterio. Usa
RawURLEncoding. - Creer que todo el espacio en blanco se salta. No es así. Solo se saltan los dos caracteres de salto de línea, y el "espacio en blanco" del portapapeles es una familia mucho más grande que esa.
- Decodificar dos veces, o no decodificar dos veces, cuando el valor es base64 de base64 (un archivo adjunto a un correo que a su vez estaba adjunto). La comprobación es un viaje de ida y vuelta: decodifica una vez, mira si el resultado sigue pareciéndose a base64, y solo entonces decodifica otra vez.
La otra mitad del trabajo
Esa es la parte entera del lado decodificación de la historia: un paquete, cuatro decodificadores preparados, un decodificador de stream para datos grandes, un modo estricto para protocolos caprichosos, y mensajes de error que te dicen el byte donde las cosas se torcieron. Aprende las reglas de tolerancia, elige tu decodificador mirando los caracteres, acota tus entradas, y base64 en Go se convierte en la utilidad aburrida, predecible y de cero dependencias para la que fue diseñada.
Cuando el trabajo se invierte, y tu programa Go necesita producir cadenas base64 en lugar de abrirlas, el artículo relacionado sobre codificación Base64 en Go cubre ese lado en detalle: la API de un solo método del codificador, la llamada a Close que se traga en silencio tus dos últimos bytes, el salto de línea para MIME, y cómo las cuatro codificaciones se mapean a los canales por los que viajan.
Última actualización: 2026-09-08
Artículo relacionado: Codificación Base64 en Go: una guía completa