¿Tiene que ocuparse del formato Base64? Entonces esta página es perfecta para Ud. Utilice nuestra práctica herramienta en línea para codificar o decodificar sus datos.

Decodificación Base64 en PHP: una guía completa

Aparece en un ticket de soporte, en un log de API, en un archivo de configuración o a mitad de una URL: una larga cadena de letras, dígitos, el ocasional + o /, y quizá un = o dos al final. Lo reconoces en un abrir y cerrar de ojos. Base64 es un formato de binario a texto: reescribe cada tres bytes de datos crudos como cuatro caracteres sacados de un alfabeto de 64 letras, y un par de signos = rematan la cola cuando el número de bytes no es múltiplo de tres. La decodificación es la dirección que encoge ese trueque: cuatro caracteres entran y tres bytes salen. La página principal de este sitio repasa el formato paso a paso, así que este artículo gasta su energía donde toca: en el lado PHP del trabajo.

Primero, la gran noticia. PHP lleva un decodificador Base64 en su núcleo desde PHP 4. base64_decode() no necesita extensión, ni paquete de Composer, ni configuración, y funciona en todas partes donde funciona PHP. La noticia menos buena: su modo predeterminado se traga en silencio la entrada corrupta y te entrega basura sin decir una palabra. La buena noticia mejora aún más: un flag ($strict) convierte la función en un auténtico portero, y en cuanto sepas cómo elegir el modo, demostrar que la entrada es auténtica y devolver los bytes a su significado, Base64 deja de ser una fuente de bugs misteriosos y se convierte en una rutina que puedes automatizar.

Una nota rápida sobre el tamaño: decodificar encoge los datos en torno a una cuarta parte (tres bytes de salida por cada cuatro caracteres de entrada), así que la salida siempre ocupa menos memoria que la entrada. Nunca tendrás que preocuparte de que una decodificación explote. Ahora vamos a conocer la herramienta.

La función que hace el trabajo

Aquí está la firma completa, tal y como la reporta el PHP moderno:

base64_decode(string $string, bool $strict = false): string|false

Tres palabras de esa línea hacen todo el trabajo. $string no tiene límite de tamaño: un megabyte se decodifica en mucho menos de un milisegundo, así que nada te impide decodificar un archivo entero en una sola llamada. El tipo de retorno lo dice todo: o una cadena de bytes decodificados, o false. No hay excepciones, no hay códigos de error, no hay un segundo canal. false es la única señal que recibes, así que comprobarla forma parte del trabajo. Y una frase del manual merece la pena memorizarla: los datos devueltos pueden ser binarios. En el momento en que el resultado contiene un PNG, un ZIP o un hash, ya no es una "cadena de texto" en ningún sentido amplio, y PHP te dejará tratarlo como tal sin el menor problema. Esa flexibilidad es un superpoder y una trampa, y las secciones de abajo la mantienen a raya.

Un repaso rápido a las etiquetas de versión, porque el código heredado tiene la costumbre de dar cosas por supuestas. La función lleva en el núcleo desde PHP 4. Su parámetro $strict llegó en PHP 5.2.0, en noviembre de 2006. Desde PHP 8.0, la firma lleva tipos nativos de verdad (los string y bool que ves arriba, más el retorno string|false), así que los IDE y los analizadores estáticos por fin saben que la función puede fallar. Desde PHP 8.1, pasar null lanza un aviso de deprecación; si quieres decir "nada", escribe '' explícitamente:

$decoded = base64_decode('');
var_dump($decoded); // string(0) ""

Modo estricto o limpieza silenciosa

El flag $strict es un interruptor entre dos personalidades muy distintas. Apagado (el predeterminado), el decodificador es un olvidadizo amable: cada carácter fuera del alfabeto Base64 se descarta en silencio, el resto se decodifica, y nadie se entera. El manual lo dice sin rodeos: en caso contrario, los caracteres no válidos se descartarán en silencio. Encendido, el decodificador es un portero: el primer carácter que no reconoce condena a todo el payload a un false.

Aquí va el informe de daños. Cada fila de abajo es el comportamiento real de base64_decode() en PHP 8.x:

Entrada Permisivo (predeterminado) Estricto
Zm9vYmFy, limpio "foobar" "foobar"
Zm9v\r\nYmFy, CRLF a mitad de cadena "foobar" "foobar"
" Zm9vYmFy ", espacios a ambos extremos "foobar" "foobar"
Zm9v\x0bYmFy, tabulación vertical "foobar" false
Zm9v\x00YmFy, byte NUL embebido "foobar" false
V@hpcy, @ perdido 3 bytes de basura false
Zm9vY, cinco caracteres "foo", último carácter descartado false
Z, una sola letra "", una cadena vacía false
=Zm9, relleno al principio "fo" false
Zm9vYmFy==, rellenos tras un grupo completo "foobar" false
Zm9vYmFy==A, datos tras los rellenos "foobar" false
Zm9vYmF, siete caracteres, sin rellenos "fooba" "fooba"

Tres filas merecen una segunda mirada. La fila de V@hpcy muestra por qué el modo permisivo es peligroso en cualquier sitio donde la entrada no sea de confianza: el @ perdido no detiene la decodificación; simplemente desaparece, y los tres bytes que salen no significan nada. La fila de la Z sola muestra que un resultado vacío casi no demuestra nada; un payload de un solo carácter "se decodifica" a una cadena vacía sin fallar. La fila de Zm9vYmFy==A muestra al decodificador ignorando sin ningún problema los datos que aparecen tras el relleno, que es la forma en que un payload truncado o manipulado puede parecer perfectamente válido.

¿Qué deja pasar aun así el modo estricto? Exactamente cuatro caracteres de espacio en blanco: espacio, tabulador, retorno de carro y salto de línea, en cualquier posición, incluso justo al lado de los signos =. Es intencional. Los payloads de correo con envolvente MIME llevan saltos de línea CRLF dentro del flujo codificado, y el modo estricto los devora sin preprocesar nada (la sección de correo de abajo explica por qué). Todo lo demás que no sea un carácter del alfabeto, desde bytes NUL hasta tabulaciones verticales, se lleva un false.

Hay una permisividad de verdad que conviene conocer, aunque no sea un rasgo exclusivo de PHP: PHP rellena el padding que falta por ti, en silencio. El payload de siete caracteres Zm9vYmF (sin rellenos en absoluto) se decodifica a "fooba" exactamente igual que su primo con relleno Zm9vYmF=, en ambos modos. El RFC 4648 pide relleno en el caso general, así que aceptar una cola sin relleno es una relajación deliberada, y no es específica de PHP: el RawStdEncoding de Go y el decodificador de Java aceptan la misma entrada sin relleno. Si tu lado PHP y un sistema partner no se ponen de acuerdo sobre un payload de caso límite, suele ser ahí donde hay que mirar: un relleno que falta.

El estándar está de acuerdo con el modo estricto. El RFC 4648, sección 3.3, dice que las implementaciones deben rechazar datos codificados que contengan caracteres fuera del alfabeto, salvo que la especificación de contexto diga lo contrario (MIME es el caso clásico de "decir lo contrario"). La misma sección explica por qué: los caracteres no alfabéticos pueden explotarse como canal encubierto, ocultando información en caracteres que tu decodificador tira, y se han usado para provocar bugs en decodificadores. Si tu entrada viene del mundo exterior, el modo estricto no es una elección de estilo. Es lo que el estándar pide.

Probar que un payload es Base64

Un decodificador que puede fallar en silencio se merece una tubería de validación delante. Tres capas, cada una atrapando lo que las otras dejan escapar.

La capa uno es una comprobación de forma con una expresión regular: solo caracteres del alfabeto, y como mucho dos rellenos al final.

$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;

La expresión regular atrapa la basura obvia (espacios perdidos, signos @, un relleno a mitad de la cadena) antes de que nada más se ejecute. Aunque no es un validador: no puede ver que Zm9vYmFy= son nueve caracteres con un relleno, algo que el modo estricto también rechaza. Por eso existe la capa dos. La decodificación estricta es la única comprobación que entiende la semántica de Base64, así que tiene la última palabra.

La capa tres es la que todos olvidan: tratar false explícitamente, porque es la única señal que recibes.

function decode_payload(string $payload): string
{
  $clean = str_replace(["\r", "\n"], '', $payload);
  $decoded = base64_decode($clean, true);
  if ($decoded === false) {
    throw new InvalidArgumentException('Not a valid Base64 payload.');
  }
  return $decoded;
}

El str_replace() al principio es un confort opcional: el modo estricto ya tolera CRLF, pero quitarlo mantiene limpias las cuentas de longitud que hagas después, porque el número de caracteres de un payload limpio es siempre múltiplo de cuatro. (Uno más que un múltiplo de cuatro, como cinco o nueve, es imposible en Base64, y el modo estricto lo rechazará.) Ten en cuenta que la función nunca lanza una excepción por sí sola; la comprobación te toca escribirla a ti.

Base64 URL-safe

En el mundo real te encontrarás con un segundo alfabeto, y es el que muerde. El Base64 estándar usa + y /, dos caracteres que son un problema en las URLs: un + en una query string se interpreta como espacio antes de que PHP lo vea, y / es un separador de rutas. El RFC 4648, sección 5, define la solución: el alfabeto seguro para URLs y nombres de archivo, donde + se convierte en -, / se convierte en _, y el relleno = final suele descartarse para ahorrar caracteres. El RFC es contundente al afirmar que esto "no debería considerarse igual que la codificación base64", y el nombre que más escucharás es base64url. Los JSON Web Tokens, los parámetros state de OAuth, los IDs de sesión de API y las URLs de sitios de vídeo viven todos en este dialecto.

El lado decodificador tiene dos pasos: intercambia el alfabeto de vuelta, y luego restaura el relleno que falte. Aquí tienes la función de ayuda que al final reutilizarás en todas partes:

function base64url_decode(string $data): string|false
{
  $standard = strtr($data, '-_', '+/');
  $missing = strlen($standard) % 4;
  if ($missing !== 0) {
    $standard .= str_repeat('=', 4 - $missing);
  }
  return base64_decode($standard, true);
}
var_dump(base64url_decode('aGk_PnRoZXJl')); // string(9) "hi?>there"

El PHP moderno está de tu parte aquí: rellena el padding que falta por ti, así que la restauración explícita es un doble seguro (y mantiene tu código portable a versiones más antiguas de PHP). El peligro va en un solo sentido. Si alimentas el decodificador estándar en modo permisivo con texto URL-safe, los caracteres - y _ simplemente no están en el alfabeto estándar, así que se descartan. Tu salida sale más corta de lo que debería, sin error, sin aviso, sin nada. Ejecuta siempre el intercambio de strtr() primero, o mejor todavía, pasa siempre por la función de ayuda.

Una salvedad honesta: si un payload URL-safe ocurre por no contener ni - ni _, los dos alfabetos son idénticos byte a byte para esos datos en particular, y da igual qué decodificador uses. El peligro solo aparece cuando esos caracteres están presentes, porque ese es el único lugar donde los alfabetos difieren.

Texto, bytes y conjuntos de caracteres

Base64 no tiene idea de qué significan tus bytes, y el decodificador de PHP hereda esa ceguera. El codec es ciego a los charset: devuelve los mismos valores de 8 bits que entraron, ya sea texto UTF-8, texto Windows-1252, un JPEG o un hash. El propio PHP está en la misma página: una cadena es una secuencia de bytes, nada más. En el momento en que quieres mostrar el resultado o compararlo con otro texto, alguien tiene que responder dos preguntas: ¿esto es texto en absoluto, y si lo es, en qué charset?

La prueba práctica tiene dos cubos. El binario casi siempre se anuncia con bytes NUL y bytes de control bajos, y el texto que no es UTF-8 válido es el segundo cubo. La extensión mbstring (no activada por defecto) te da la comprobación estricta de UTF-8:

function looks_binary(string $bytes): bool
{
  if ($bytes === '') {
    return false;
  }
  if (strpbrk($bytes, "\x00\x01\x02\x03\x04") !== false) {
    return true;
  }
  return !mb_check_encoding($bytes, 'UTF-8');
}
var_dump(looks_binary("\x89PNG\r\n\x1a\n...png body")); // bool(true)
var_dump(looks_binary("héllo wörld, 日本語"));          // bool(false)

Cuando el payload es texto en un charset heredado, conviértelo antes de que toque tu HTML. Windows-1252 es la codificación heredada más común para datos web y de escritorio, y la diferencia entre ella y el ISO-8859-1 puro decide si el byte 0x93 es una comilla curva o un carácter de control invisible:

// "café" en Windows-1252: la e con acento es un solo byte, 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café": la e con acento es ahora dos bytes UTF-8

Una advertencia sobre el célebre mb_detect_encoding(): el propio manual de PHP dice que la detección automática "nunca puede ser del todo fiable", y la compara con descifrar un mensaje sin la clave. Si le das un "café" en Windows-1252, puede decir Windows-1252; si le das una cabecera PNG, puede decir Windows-1252 sin despeinarse, porque la familia de charsets ISO-8859 está definida para todos los valores de byte posibles y por eso puede coincidir con cualquier cosa. Trata la detección como último recurso, confía en un charset declarado (una cabecera, una línea de configuración, una colación de base de datos) siempre que exista, y deja el resto en UTF-8 o binario por defecto.

Cuando el payload es un archivo

El trabajo de archivo más común es la inversa de lo que hizo alguna rutina de exportación: llega un archivo de texto .b64, y necesitas el archivo original de vuelta. Con decodificación estricta y una comprobación de false, esto ya tiene forma de producción:

$encoded = file_get_contents('/var/www/uploads/blob.b64');
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
  http_response_code(400);
  exit('That upload is not valid Base64.');
}

Las cadenas de PHP son solo bytes, así que nada en este camino se preocupa de si el payload es un archivo de texto, un archivo ZIP o un vídeo. La cuenta del tamaño juega a tu favor: la salida decodificada es tres cuartas partes de la longitud de la entrada codificada, así que decodificar nunca empeora la memoria.

Un buen hábito es dejar que los bytes se anuncien por sí solos antes de confiar en cualquier etiqueta. La clase finfo (la extensión fileinfo, incluida en las compilaciones estándar de PHP) te dice qué son realmente los datos:

$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
var_dump($mime); // string(9) "image/png"
$extensions = ['image/png' => 'png', 'application/pdf' => 'pdf', 'application/zip' => 'zip'];
$ext = $extensions[$mime] ?? 'bin';
$target = '/var/www/uploads/file-' . bin2hex(random_bytes(4)) . '.' . $ext;
file_put_contents($target, $decoded);

Este último paso importa más de lo que parece. Un payload que se hace pasar por imagen pero se decodifica a otra cosa es exactamente el tipo de cosas que atrapa una segunda opinión. Y si más tarde sirves el archivo restaurado de vuelta a un navegador, el Content-Type que envíes debería salir de esa misma comprobación finfo, no del nombre del archivo.

Data URIs, el formato del portapapeles

Una llegada favorita: alguien pega una imagen en un formulario, y el front end te entrega un data URI completo: data:image/png;base64,iVBORw0KGgo.... El RFC 2397 define la forma: data:, un tipo de medio opcional, un flag opcional ;base64, una coma, y luego los datos. Cuando el flag está presente, el payload es Base64; cuando falta, el payload es texto plano percent-encoded, menos común pero legal. Si se omite el tipo de medio, el predeterminado es text/plain;charset=US-ASCII. ¿Por qué Base64 aquí en absoluto? Porque un URI no puede contener con seguridad bytes crudos ni comas, y Base64 te da un alfabeto que no necesita ningún escapado.

function split_data_uri(string $uri): ?array
{
  if (!str_starts_with($uri, 'data:') || !str_contains($uri, ',')) {
    return null;
  }
  $meta = substr($uri, 5, strpos($uri, ',') - 5);
  $payload = substr($uri, strpos($uri, ',') + 1);
  $isBase64 = str_ends_with($meta, ';base64');
  $mime = $isBase64 ? substr($meta, 0, -7) : $meta;
  if ($mime === '') {
    $mime = 'text/plain;charset=US-ASCII';
  }
  return [$mime, $isBase64, $payload];
}
$uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ';
[$mime, $isBase64, $payload] = split_data_uri($uri);
var_dump($mime); // string(9) "image/png"

Dos trampas viven en este formato. El flag ;base64 que falta es la primera: un data URI legal sin el flag lleva un payload percent-encoded, y pasarla por base64_decode() produce basura. La segunda es el tipo de medio declarado: es una pista del remitente, no un hecho. La comprobación finfo de la sección de archivos es tu hecho. Y recuerda el propio consejo del RFC de que los data URIs solo son útiles para valores cortos; una imagen de varios megabytes dentro de una URL es un mal olor, no un patrón.

JWT: tokens a los que puedes echar un vistazo

El payload Base64 más famoso de la web es el JSON Web Token, y el menos aterrador en cuanto conoces la forma. Según el RFC 7519, un JWT compacto son tres partes Base64 URL-safe separadas por puntos: una cabecera, un payload y una firma, cada una codificada sin relleno y sin saltos de línea (el RFC 7515 es explícito en que ningún carácter extra puede colarse). La cabecera y el payload son JSON plano, y por eso todos pueden leerlos, y por eso todos deberían entender el siguiente párrafo antes de tocar un token.

Leer las dos primeras partes es cuestión de cinco líneas con la función de ayuda de arriba, y es una gran manera de desmitificar un token:

$token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.8GljXWCrvkTYln_WtTVhyWSzflOC1iGL8jBDUHQmaEE';
[$headerPart, $payloadPart] = explode('.', $token);
$header  = json_decode(base64url_decode($headerPart), true);
$payload = json_decode(base64url_decode($payloadPart), true);
var_dump($header);
// array(2) { ["alg"] => string(5) "HS256" ["typ"] => string(3) "JWT" }
var_dump($payload);
// array(3) { ["sub"] => string(10) "1234567890" ["name"] => string(8) "John Doe" ["iat"] => int(1516239022) }

Ahora la parte que importa: la tercera parte es una firma, y las dos partes que acabas de decodificar no son secretas y no están autenticadas. Cualquiera con una captura de paquetes puede leerlas, y cualquiera con un editor de texto puede reescribirlas. Confiar en el payload antes de verificar la firma es el bug clásico de JWT. En producción, no escribas esa comprobación a mano. La respuesta de la comunidad es el paquete firebase/php-jwt, actualmente en v7, conforme al RFC 7519 y que requiere PHP 8.0 o más nuevo. Instálalo con Composer:

composer require firebase/php-jwt

Luego la API verifica primero y te entrega el payload solo si la firma encaja:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$secret = 'correct-horse-battery-staple-long-enough-secret';
try {
  $claims = JWT::decode($token, new Key($secret, 'HS256'));
  var_dump($claims->sub); // una propiedad, y solo después de que la firma encajó
} catch (UnexpectedValueException $e) {
  // token malformado, firma incorrecta o claims caducados
}

Una nota de versión: la v7 de la librería impone longitudes mínimas de clave para los algoritmos HMAC, así que un secreto HS256 de menos de 32 bytes se rechaza con una DomainException antes de que se verifique la firma. Mantén tus secretos largos; la librería no te deja olvidarlo.

Fíjate en el orden de esa API: JWT::decode() lanza una excepción ante una firma incorrecta, un token caducado o un algoritmo que falta, en vez de devolver basura, así que un payload que recibes de vuelta es uno en el que puedes confiar. La versión a mano de arriba es para entender, y para echar un vistazo a tokens que no iban destinados a ti; la librería es para confiar.

HTTP Basic Auth, la cabecera más antigua

La cabecera de autenticación más antigua de la web sigue montada sobre Base64. Según el RFC 7617, una petición HTTP Basic envía Authorization: Basic seguido de la codificación Base64 de username:password. El RFC es explícito en que esto es codificación, no protección: cualquiera con una captura de paquetes puede decodificar ambas mitades en una pulsación. Tu trabajo en el lado decodificador es analizar la cabecera, decodificar en estricto y comparar con una función segura ante timing attacks.

function basic_credentials(string $header): ?array
{
  if (!str_starts_with($header, 'Basic ')) {
    return null;
  }
  $decoded = base64_decode(substr($header, 6), true);
  if ($decoded === false || !str_contains($decoded, ':')) {
    return null;
  }
  [$user, $password] = explode(':', $decoded, 2);
  return [$user, $password];
}
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$creds = basic_credentials($header);
if ($creds !== null
  && hash_equals('alice', $creds[0])
  && hash_equals('secret123', $creds[1])
) {
  // autenticado
}

Dos detalles mantienen esto a salvo. El límite de 2 en explode() importa porque una contraseña puede contener colones legítimamente, y la comparación debería ser hash_equals(), nunca ==, para que un atacante no pueda recorrer tu lista de usuarios midiendo el tiempo. Y sirve esto solo por HTTPS; en una conexión plana la capa Base64 es pura decoración.

Correo, donde todo empezó

Base64 nació por un problema concreto: el transporte de correo solo llevaba ASCII de 7 bits, y aun así la gente quería enviar binarios. El estándar MIME (RFC 2045, sección 6.8) convirtió Base64 en una de las codificaciones de transferencia binaria y añadió dos normas de la casa. Primera: las líneas codificadas no deben superar los 76 caracteres. Segunda: el software decodificador debe ignorar todo carácter fuera del alfabeto, saltos de línea incluidos. Esa segunda norma es exactamente por qué el decodificador de PHP, en cualquiera de sus modos, devora un payload envuelto en CRLF sin ningún preprocesamiento tuyo. (Este es también el origen de la tolerancia al \r\n que viste en la tabla del modo estricto de arriba.)

$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// después, en el lado receptor, sin necesidad de limpieza:
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true): cada byte sobrevivió al viaje de ida y vuelta

Dos notas prácticas. Primera: el envolvido añade peso; con un CRLF cada 76 caracteres, un adjunto de 100 KB llega como unos 137 KB de texto (el habitual factor de cuatro tercios, más el sobrecoste de los saltos de línea). Segunda: para el correo real del mundo con cabeceras, múltiples partes y hermanos quoted-printable, la extensión opcional mailparse disecciona mensajes completos RFC 822 parte por parte; para un único adjunto conocido, la decodificación estricta es todo lo que necesitas.

Armadura PEM: claves y certificados

Los certificados y las claves viajan con armadura PEM: una etiqueta BEGIN, un bloque de Base64 en líneas de 64 caracteres, y una etiqueta END. La longitud de línea de 64 caracteres es una convención heredada de la especificación original Privacy Enhanced Mail (RFC 1421), y las herramientas de OpenSSL se la esperan, así que importa cuando vuelves a blindar. Cuando decodificas, da exactamente igual: el decodificador simplemente ignora los saltos de línea.

$pem = file_get_contents('/etc/ssl/my-key.pem');
preg_match('/-----BEGIN ([A-Z ]+)-----\s*(.*?)\s*-----END \1-----/s', $pem, $m);
$label = $m[1];
$der = base64_decode(preg_replace('/\s+/', '', $m[2]), true);
if ($der === false) {
  // al final no era Base64
}
var_dump($label); // string(11) "PRIVATE KEY"

Los bytes decodificados son DER, una serialización binaria compacta, y eso es con lo que las funciones openssl_* trabajan al final. La referencia inversa \1 de la expresión regular es el héroe silencioso: garantiza que la etiqueta END coincida con la etiqueta BEGIN, que es la manera de evitar coser un END de un certificado al BEGIN de una clave cuando un archivo contiene varios bloques.

Streams y payloads grandes

Decodificar es la dirección que te ayuda: la salida es tres cuartas partes del tamaño de la entrada, así que la presión de memoria por Base64 es rara. Aun así, cuando un archivo .b64 de varios cientos de megabytes aterriza en disco, tienes dos herramientas para mantener la huella plana.

La primera es la decodificación por trozos. Divide la entrada limpia en fragmentos cuya longitud es múltiplo de cuatro caracteres, decodifica cada fragmento en estricto y concatena. Cada trozo es un payload válido y autónomo, así que nada se pierde en los bordes, y un archivo corrupto falla rápido con un offset que puedes reportar.

$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // un múltiplo de cuatro caracteres, unos 150 KB de salida por llamada
for ($offset = 0; $offset < strlen($clean); $offset += $chunkSize) {
  $part = base64_decode(substr($clean, $offset, $chunkSize), true);
  if ($part === false) {
    exit('Corrupted payload near offset ' . $offset);
  }
  $decoded .= $part;
}

Un megabyte de Base64 se decodifica en mucho menos de un milisegundo en hardware moderno, así que este bucle cuesta casi nada; elígelo por sus propiedades de validación e informe, no por velocidad.

La segunda herramienta es una vecina del mundo de los streams: el filtro de stream convert.base64-decode. Funciona con cualquier stream de PHP, así que puedes decodificar directamente desde un puntero de archivo, php://input o un stream de memoria sin llegar a sostener todo el texto codificado en una sola variable. Igual que la función permisiva, simplemente salta todo carácter fuera del alfabeto Base64:

$in = fopen('/var/www/uploads/huge.b64', 'rb');
$out = fopen('/var/www/uploads/huge.bin', 'wb');
stream_filter_append($in, 'convert.base64-decode', STREAM_FILTER_READ);
stream_copy_to_stream($in, $out);
fclose($in);
fclose($out);

¿Qué herramienta eliges? El filtro cuando los datos fluyen por un stream y quieres que PHP se encargue de la fontanería; el bucle de trozos cuando necesitas validación por trozo, informe de progreso o el offset de la corrupción.

Bases de datos, archivos de configuración y variables de entorno

Base64 es un contenedor de texto, y por eso aparece en sitios donde no 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 unos 33 por ciento más grande que el original, y dimensiona tus columnas 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 de base de datos con punto y coma, una contraseña con comillas, un valor con un salto de línea.

// .env o config, escrito por la persona de ops:
//   DB_DSN_B64 = cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
$dsn = base64_decode(getenv('DB_DSN_B64') ?: '', true);
if ($dsn === false) {
  exit('DB_DSN_B64 is not valid Base64.');
}
// $dsn queda así: pg:host=db;password=qu"ote

La misma cautela 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. Nunca almacenes un secreto como Base64 y lo llames cifrado. Segunda: valida al arrancar; un valor de entorno corrupto o medio pegado es un false de la llamada estricta, y una comprobación de una línea convierte un error en runtime criptico en un mensaje de arranque accionable.

Desde la línea de comandos

No toda la decodificación ocurre dentro de una petición web. Los scripts de CLI, los trabajos de cron y los one-liners decodifican Base64 todo el tiempo, y la línea de comandos es donde la función se encuentra con php://stdin:

php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin

El shell ya tiene su propia utilidad Base64 (coreutils base64 -d), y está bien para trabajo rápido; el one-liner de PHP es para cuando el siguiente paso es lógica PHP: escribir en una base de datos, llamar a una API, ejecutar una validación. Dos piedras en el camino específicas del shell. La salida de una decodificación son bytes crudos, así que envíala a un archivo o a un comando que entienda bytes, no a un terminal que los va a destrozar. Y deja el flag estricto encendido en el one-liner, porque un pegado truncado en un terminal se merece un false, no tres bytes de basura.

Trampas con acento PHP

Un recorrido rápido por las trampas específicas de PHP, reunidas en un solo sitio:

  • El predeterminado permisivo es la gran trampa. base64_decode('V@hpcy') devuelve tres bytes de basura sin ningún aviso, así que cada decodificador de entrada no confiable necesita el flag estricto y una comprobación de false.
  • Un carácter solo se decodifica a una cadena vacía en modo permisivo, y lo mismo hace una cadena de solo espacios. Un resultado vacío casi no demuestra nada; solo false significa fallo, y solo lo obtienes en modo estricto.
  • El + en una query string ya es un espacio antes de que PHP lo vea. Si un cliente envía ?token=abc+def sin percent-encoding, PHP te entrega abc def (ese es el comportamiento de la form-encoding, compartido por parse_str() y urldecode()), y ninguna cantidad de magia de decodificación devuelve el más. El Base64 URL-safe (sin ningún más) es la solución para tokens en URLs.
  • El relleno que falta se completa por ti, en silencio. Siete caracteres se decodifican como ocho; eso es conveniente, pero significa que un payload truncado por un relleno o dos aún puede decodificarse sin quejarse, así que una decodificación limpia nunca demuestra del todo que el payload llegó entero (los codificadores raw de Go y Java son igualmente indulgentes).
  • El fantasma de mbstring.func_overload. La configuración de larga data deprecada que reescribía strlen() y compañía para contar caracteres (eliminada en PHP 8.0) solía romper las cuentas de bytes de Base64 en cadenas UTF-8. El código legado que heredes puede seguir llevando comentarios y arreglos para ello. Elimínalos.
  • Los bytes decodificados no son una cadena UTF-8. Ejecutar preg_match() con el flag /u o mb_substr() sobre binario decodificado es una fuente instantánea de errores de "entrada malformada". Olfatea primero, luego decide.
  • Pasar null está deprecado desde PHP 8.1. Si una variable puede ser null, coalesce el valor a '' antes de la llamada.
  • $_GET y compañía se decodifican con reglas de formulario, no de URL. Si un valor llegó percent-encoded, rawurldecode() es la inversa más segura, porque deja + en paz.

Una breve historia de base64_decode

El propio Base64 es más antiguo que la mayor parte de la web moderna (el estándar que lo rige, el RFC 4648, data de 2006, y codificó la codificación MIME de 1996, que a su vez desciende de la armadura PEM de principios de los 90). La historia de PHP es su propio pequeño changelog.

PHP 4 incluyó base64_decode() como función del núcleo, sin opciones y sin modo estricto; el modo permisivo era el único modo, y no había forma de pedir al decodificador que se quejara. PHP 5.2.0, en noviembre de 2006, añadió el flag $strict, y la entrada del changelog merece la pena leerla: se añadió para aplicar el cumplimiento del RFC 3548, el predecesor del RFC 4648 de hoy. Ese único flag resultó ser la adición más útil de la vida de la función.

Luego vinieron los años de depuración. PHP 5.3 corrigió una racha de bugs del modo estricto en dos releases puntuales: el bug #52327 (relleno inicial mal manejado en modo estricto, corregido en 5.3.4) y el bug #55273 (espacios en blanco tras el relleno rechazados en modo estricto, corregido en 5.3.9). (Una corrección de desbordamiento de entero de 2016 también está archivada bajo el nombre de esta función: el bug #72836, titulado oficialmente "desbordamiento de entero en base64_decode que causaba corrupción del heap" y corregido en 5.6.25, pero el propio código de reproducción del reporte y la función parcheada muestran que el desbordamiento real estaba en el cálculo de longitud de base64_encode(), no en el decodificador; el título es un nombre inexacto heredado del reporte original.) Cada corrección afinó el comportamiento que ves en la tabla de arriba. PHP 8.0 dio a ambas funciones Base64 tipos nativos de parámetro y de retorno, la firma que viste al inicio de este artículo, y esa misma línea de releases eliminó mbstring.func_overload, la configuración que llevaba años rompiendo en silencio las cuentas de bytes. PHP 8.1 deprecó pasar null a ellas. Desde entonces, la superficie está congelada: un parámetro, un flag, un tipo de retorno, inalterados.

Algunas delicias de friki

Como esta es una referencia de formato largo, aquí van algunos datos específicos de PHP que son simplemente divertidos:

  • La identidad vacía. base64_encode('') y base64_decode('') son ambos ''. Las funciones tratan el vacío como un valor de primera clase en ambas direcciones, sin false de por medio.
  • Una dirección rara. En el manual de PHP, ambas funciones Base64 viven en el capítulo "URLs" del libro "Otras extensiones básicas". No hay un capítulo dedicado a "codificación"; es ahí donde las encontrarás, al inicio de la lista de ese capítulo, por delante de parse_url() y compañía.
  • El decodificador es un homomorfismo. Una nota clásica de usuario de php.net observa que la función es un homomorfismo entre cadenas segmentadas módulo 4 y módulo 3, que es la forma formal de decir que cualquier corte en múltiplos de cuatro es un corte válido. Por eso la sección de decodificación por trozos es posible en primer lugar, y por qué un archivo de 1 MB puede decodificarse en rebanadas de 50 KB sin pérdida alguna.
  • Un parámetro, un flag. En más de veinte años, base64_decode() ganó exactamente un parámetro ($strict) y base64_encode() no ganó ninguno.
  • Tiene hermanos mayores. La misma extensión del núcleo también lleva convert_uuencode() y convert_uudecode() (listadas bajo String Functions, funciones de cadena, en el manual), los fósiles de la era del dial-up cuando uuencode era el transporte binario de moda. Casi nunca las necesitarás, pero si un antiguo archivo .uu aterriza algún día en tu bandeja de entrada, PHP puede abrirlo.
  • El modo estricto deja una puerta abierta para el correo. Los cuatro caracteres de espacio en blanco (espacio, tabulador, retorno de carro y salto de línea) navegan a través del modo estricto a propósito, así que un adjunto envuelto en MIME no necesita preprocesamiento. Todo lo demás, bytes NUL incluidos, es un false.

La otra dirección

Ese es el lado del decodificador, y es donde vive la mayor parte del dolor, porque decodificar es donde te encuentras con los datos de otras personas: sus elecciones de relleno, sus saltos de línea, sus charsets, sus tokens. La otra dirección, convertir bytes en una cadena Base64 con base64_encode(), es un animal más tranquilo: nunca falla, no tiene modo estricto, y su propio conjunto de trampas (doble codificación, discrepancias de envolvido, la factura del tamaño) tiene su propia guía. La codificación Base64 en PHP, enlazada desde esta página, cubre el codificador con la misma profundidad.

Última actualización: 2026-09-08

Artículo relacionado: Codificación Base64 en PHP: una guía completa