Décodage Base64 en PHP : un guide complet
Il apparaît dans un ticket de support, un log d'API, un fichier de configuration ou au milieu d'une URL : une longue chaîne de lettres, de chiffres, de temps en temps un + ou un /, et peut-être un = ou deux à la fin. Vous le reconnaissez en un battement de cœur. Base64 est un format binaire vers texte : il réécrit chaque groupe de trois octets de données brutes sous forme de quatre caractères tirés d'un alphabet de 64 caractères, et un petit nombre de signes = terminent la queue quand le nombre d'octets n'est pas un multiple de trois. Le décodage est la direction qui rétrécit dans cet échange : quatre caractères entrent, trois octets sortent. La page d'accueil de ce site détaille le format pas à pas, donc cet article met son énergie là où elle doit aller : sur la partie PHP du travail.
D'abord la grande nouvelle. PHP fournit un décodeur Base64 dans son cœur depuis PHP 4. base64_decode() n'a besoin d'aucune extension, d'aucun package Composer et d'aucune configuration, et il tourne partout où PHP tourne. La nouvelle moins bonne : son humeur par défaut avale en silence les entrées corrompues et vous rend du n'importe quoi sans un mot. La bonne nouvelle devient meilleure : un seul flag ($strict) transforme la fonction en vrai portier, et dès que vous savez choisir l'humeur, prouver que l'entrée est authentique et traduire les octets en sens, Base64 cesse d'être une source de bugs mystérieux et devient une routine que vous pouvez automatiser.
Une note rapide sur la taille : le décodage rétrécit les données d'environ un quart (trois octets sortent pour chaque groupe de quatre caractères qui entre), donc la sortie prend toujours moins de mémoire que l'entrée. Vous n'aurez jamais à craindre qu'un décodage explose. Maintenant, faisons connaissance avec l'outil.
La fonction qui fait le travail
Voici la signature complète, telle que la rapporte le PHP moderne :
base64_decode(string $string, bool $strict = false): string|false
Trois mots de cette ligne font tout le travail. $string n'a aucune limite de taille : un mégaoctet se décode en bien moins d'une milliseconde, donc rien ne vous empêche de décoder un fichier entier en un seul appel. Le type de retour énonce tout le contrat : soit une chaîne d'octets décodés, soit false. Pas d'exceptions, pas de codes d'erreur, pas de second canal. false est le seul signal que vous recevez, donc le vérifier fait partie du travail. Et une phrase du manuel mérite d'être mémorisée : les données renvoyées peuvent être binaires. Dès que le résultat contient un PNG, un ZIP ou un hash, ce n'est plus une « chaîne de texte » au sens large, et PHP vous laissera volontiers la traiter comme telle quand même. Cette souplesse est un super-pouvoir et un piège, et les sections ci-dessous la gardent en laisse.
Un rapide tour des versions, parce que le code hérité a l'habitude de supposer des choses. La fonction est dans le cœur depuis PHP 4. Son paramètre $strict est arrivé dans PHP 5.2.0, en novembre 2006. Depuis PHP 8.0, la signature porte de vrais types natifs (le string et le bool que vous voyez ci-dessus, plus le retour string|false), donc les IDE et les analyseurs statiques savent enfin que la fonction peut échouer. Depuis PHP 8.1, passer null provoque un avertissement de non-recommandation ; si vous voulez dire « rien », écrivez '' explicitement :
$decoded = base64_decode('');
var_dump($decoded); // string(0) ""
Mode strict ou nettoyage silencieux
Le flag $strict est un interrupteur entre deux personnalités très différentes. Éteint (par défaut), le décodeur est un oublieux amical : chaque caractère en dehors de l'alphabet Base64 est jeté en silence, le reste est décodé, et personne n'est prévenu. Le manuel est on ne peut plus direct : sinon, les caractères invalides sont silencieusement ignorés. Allumé, le décodeur est un portier : le premier caractère qu'il ne reconnaît pas vaut à tout le payload un false.
Voici le rapport des dégâts. Chaque ligne ci-dessous est le comportement réel de base64_decode() sur PHP 8.x :
| Entrée | Tolérant (par défaut) | Strict |
|---|---|---|
Zm9vYmFy, propre |
"foobar" |
"foobar" |
Zm9v\r\nYmFy, CRLF au milieu de la chaîne |
"foobar" |
"foobar" |
" Zm9vYmFy ", espaces aux deux extrémités |
"foobar" |
"foobar" |
Zm9v\x0bYmFy, tabulation verticale |
"foobar" |
false |
Zm9v\x00YmFy, octet NUL intégré |
"foobar" |
false |
V@hpcy, un @ errant |
3 octets de n'importe quoi | false |
Zm9vY, cinq caractères |
"foo", le dernier caractère est jeté |
false |
Z, une seule lettre |
"", une chaîne vide |
false |
=Zm9, padding en tête |
"fo" |
false |
Zm9vYmFy==, padding après un groupe complet |
"foobar" |
false |
Zm9vYmFy==A, données après le padding |
"foobar" |
false |
Zm9vYmF, sept caractères, sans padding |
"fooba" |
"fooba" |
Trois lignes méritent un second regard. La ligne V@hpcy montre pourquoi le mode tolérant est dangereux partout où l'entrée n'est pas fiable : le @ errant n'arrête pas le décodage, il disparaît tout simplement, et les trois octets qui sortent ne veulent rien dire. La ligne du Z seul montre qu'un résultat vide ne prouve presque rien ; un payload d'un seul caractère se « décode » en chaîne vide sans échouer. La ligne Zm9vYmFy==A montre le décodeur ignorant joyeusement les données qui apparaissent après le padding, et c'est ainsi qu'un payload tronqué ou manipulé peut avoir l'air parfaitement correct.
Que laisse passer le mode strict ? Exactement quatre caractères blancs : espace, tabulation, retour chariot et retour à la ligne, n'importe où, même juste à côté des signes =. C'est voulu. Les payloads e-mail habillés en MIME portent des retours à la ligne CRLF dans le flux encodé, et le mode strict les avale sans prétraitement (la section e-mail ci-dessous explique pourquoi). Tout le reste qui n'est pas un caractère de l'alphabet, des octets NUL aux tabulations verticales, se prend un false.
Il y a une vraie indulgence à connaître, même si ce n'est pas une particularité PHP : PHP complète le padding manquant pour vous, en silence. Le payload de sept caractères Zm9vYmF (aucun padding du tout) se décode en "fooba" exactement comme son cousin avec padding Zm9vYmF=, dans les deux humeurs. RFC 4648 demande du padding dans le cas général, donc accepter une fin sans padding est un adoucissement délibéré, et il n'est pas spécifique à PHP : RawStdEncoding de Go et le décodeur de Java acceptent la même entrée sans padding. Si votre côté PHP et un système partenaire ne sont pas d'accord sur un payload de cas limite, c'est souvent vers un padding manquant qu'il faut regarder.
La norme est d'accord avec l'humeur stricte. RFC 4648, section 3.3, dit que les implémentations doivent refuser les données encodées qui contiennent des caractères hors de l'alphabet, sauf si la spécification environnante dit le contraire (MIME est le cas classique de « dit le contraire »). La même section explique pourquoi : les caractères hors alphabet peuvent servir de canal clandestin, cachant de l'information dans des caractères que votre décodeur jette, et ils ont servi à déclencher des bugs de décodeur. Si votre entrée vient du monde extérieur, le mode strict n'est pas un choix de style. C'est ce que la norme demande.
Prouver qu'un payload est du Base64
Un décodeur qui peut échouer en silence mérite un pipeline de validation devant lui. Trois couches, qui attrapent chacune ce que les autres manquent.
La couche une est un contrôle de forme avec une expression régulière : uniquement des caractères de l'alphabet, et au plus deux paddings tout à la fin.
$shapeLooksPlausible = preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $payload) === 1;
L'expression régulière attrape les n'importe quoi évidents (espaces errants, signes @, un padding au milieu de la chaîne) avant que quoi que ce soit ne s'exécute. Ce n'est pas un validateur, quand même : elle ne peut pas voir que Zm9vYmFy= fait neuf caractères avec un padding, ce que le mode strict refuse aussi. C'est exactement pour ça que la couche deux existe. Le décodage strict est la seule vérification qui comprend la sémantique Base64, donc c'est lui qui a le dernier mot.
La couche trois est celle que tout le monde oublie : traiter false explicitement, parce que c'est le seul signal que vous recevez.
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;
}
Le str_replace() en tête est un confort facultatif : le mode strict tolère déjà CRLF, mais l'enlever garde propres tous les calculs de longueur que vous ferez plus tard, parce que le nombre de caractères d'un payload propre est toujours un multiple de quatre. (Un chiffre supérieur d'une unité à un multiple de quatre, comme cinq ou neuf, est impossible en Base64, et le mode strict le refusera.) Notez que la fonction ne lève jamais d'exception d'elle-même ; le contrôle, c'est à vous de l'écrire.
Base64 sûr pour les URL
Dans la vraie vie, vous croiserez un second alphabet, et c'est celui qui mord. Le Base64 standard utilise + et /, deux caractères qui posent problème dans les URL : un + dans une chaîne de requête est interprété comme une espace avant même que PHP ne le voie, et / est un séparateur de chemin. RFC 4648, section 5, définit la solution : l'alphabet sûr pour les URL et les noms de fichiers, où + devient -, / devient _, et le padding = de la fin est d'habitude retiré pour économiser des caractères. Le RFC est catégorique : cela « ne doit pas être considéré comme le même que l'encodage base64 », et le nom que vous entendrez le plus est base64url. Les JSON Web Tokens, les paramètres de state OAuth, les identifiants de session d'API et les URL de sites de vidéo vivent tous dans ce dialecte.
Côté décodeur, il y a deux étapes : rétablir l'alphabet, puis restaurer le padding manquant. Voici l'assistant que vous finirez par réutiliser partout :
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"
Le PHP moderne est de votre côté ici : il complète le padding manquant pour vous, donc la restauration explicite est une précaution en plus (et elle garde votre code portable vers d'anciennes versions de PHP). Le danger est à sens unique. Si vous alimentez le décodeur standard en texte sûr pour les URL en mode tolérant, les caractères - et _ ne sont simplement pas dans l'alphabet standard, donc ils sont jetés. Votre sortie sort plus courte qu'elle ne devrait l'être, sans erreur, sans avertissement, rien. Faites toujours passer le remplacement strtr() en premier, ou mieux, passez toujours par l'assistant.
Une mise en garde honnête : si un payload sûr pour les URL ne contient ni - ni _, les deux alphabets sont byte-identiques pour ces données-là, et peu importe quel décodeur vous avez utilisé. Le danger n'apparaît que quand ces caractères sont présents, parce que c'est le seul endroit où les alphabets diffèrent.
Texte, octets et jeux de caractères
Base64 n'a aucune idée de ce que vos octets veulent dire, et le décodeur de PHP hérite de cette cécité. Le codec est aveugle aux jeux de caractères : il rend les mêmes valeurs 8 bits qui sont entrées, que ce soit du texte UTF-8, du texte Windows-1252, un JPEG ou un hash. PHP est sur la même longueur d'onde : une chaîne est une séquence d'octets, rien de plus. Le moment venu où vous voulez afficher le résultat ou le comparer à d'autres textes, quelqu'un doit répondre à deux questions : est-ce du texte du tout, et si oui, dans quel jeu de caractères ?
Le test pratique a deux casiers. Le binaire se fait presque toujours remarquer par des octets NUL et des octets de contrôle bas, et le texte qui n'est pas du UTF-8 valide, c'est le second casier. L'extension mbstring (pas activée par défaut) vous donne le contrôle strict du 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)
Quand le payload est du texte dans un jeu de caractères legacy, convertissez-le avant qu'il touche votre HTML. Windows-1252 est l'encodage legacy le plus courant pour les données web et bureau, et la différence entre lui et le ISO-8859-1 tout simple décide si l'octet 0x93 est un guillemet courbé ou un caractère de contrôle invisible :
// "café" en Windows-1252 : le é est un seul octet, 0xE9
$legacy = base64_decode('Y2Fm6Q==', true);
$utf8 = mb_convert_encoding($legacy, 'UTF-8', 'Windows-1252');
var_dump($utf8); // string(5) "café" : le é fait maintenant deux octets UTF-8
Une mise en garde sur le célèbre mb_detect_encoding() : le manuel PHP lui-même dit que la détection automatique « ne peut jamais être entièrement fiable », et la compare au déchiffrement d'un message sans la clé. Alimenter avec un « café » en Windows-1252 et il peut dire Windows-1252 ; alimenter avec un en-tête PNG et il peut joyeusement dire Windows-1252 encore, parce que la famille de jeux de caractères ISO-8859 est définie pour chaque valeur d'octet possible et peut donc matcher n'importe quoi. Traitez la détection comme un dernier recours, faites confiance à un jeu de caractères déclaré (un en-tête, une ligne de configuration, une collation de base de données) chaque fois qu'il en existe un, et mettez le reste par défaut en UTF-8 ou binaire.
Quand le payload est un fichier
Le travail de fichier le plus courant est l'inverse de ce que certaine routine d'export a fait : un fichier texte .b64 arrive, et il vous faut le fichier d'origine. Avec le décodage strict et un contrôle de false, c'est déjà de la forme production :
$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.');
}
Les chaînes PHP ne sont que des octets, donc rien dans ce chemin ne se soucie que le payload soit un fichier texte, une archive ZIP ou une vidéo. Le calcul de taille joue en votre faveur : la sortie décodée fait les trois quarts de la longueur de l'entrée encodée, donc le décodage ne fait jamais empirer la mémoire.
Une bonne habitude est de laisser les octets se présenter eux-mêmes avant de faire confiance à n'importe quel libellé. La classe finfo (l'extension fileinfo, fournie avec les builds PHP standards) vous dit ce que les données sont vraiment :
$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);
Cette dernière étape compte plus qu'elle n'en a l'air. Un payload qui prétend être une image mais se décode en autre chose, c'est exactement le genre de chose qu'une seconde opinion attrape. Et si vous renvoyez plus tard le fichier restauré à un navigateur, le Content-Type que vous envoyez devrait venir du même contrôle finfo, pas du nom de fichier.
Data URIs, le format du presse-papiers
Une arrivée favorite : quelqu'un colle une image dans un formulaire, et le front end vous tend une data URI complète : data:image/png;base64,iVBORw0KGgo.... RFC 2397 définit la forme : data:, un type média facultatif, un flag ;base64 facultatif, une virgule, et puis les données. Quand le flag est présent, le payload est du Base64 ; quand il est absent, le payload est du texte brut encodé en pourcentage, plus rare mais légal. Si le type média est omis, la valeur par défaut est text/plain;charset=US-ASCII. Pourquoi du Base64 ici ? Parce qu'une URI ne peut pas contenir en sécurité des octets bruts ou des virgules, et que Base64 vous donne un alphabet qui n'a besoin d'aucun échappement.
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"
Deux pièges vivent dans ce format. Le flag ;base64 manquant, c'est le premier : une data URI légale sans le flag porte un payload encodé en pourcentage, et le faire passer dans base64_decode() produit du n'importe quoi. Le second, c'est le type média prétendu : c'est un indice du côté qui envoie, pas un fait. Le contrôle finfo de la section fichiers est votre fait. Et rappelez-vous le conseil du RFC lui-même : les data URIs ne sont utiles que pour de courtes valeurs ; une image de plusieurs mégaoctets dans une URL est une mauvaise odeur, pas un motif.
JWTs : des tokens que vous pouvez regarder
Le payload Base64 le plus célèbre sur le web est le JSON Web Token, et le moins effrayant une fois que vous connaissez la forme. Selon RFC 7519, un JWT compact est trois parties Base64 sûres pour les URL séparées par des points : un header, un payload, et une signature, chacune encodée sans padding et sans retours à la ligne (RFC 7515 est explicite : aucun caractère supplémentaire ne peut s'incruster). Le header et le payload sont du JSON brut, c'est pour ça que tout le monde peut les lire, et c'est pour ça que tout le monde devrait comprendre le paragraphe suivant avant de toucher à un token.
Lire les deux premières parties, c'est cinq lignes de travail avec l'assistant d'au-dessus, et c'est un excellent moyen de désacraliser 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) }
Voici maintenant la partie qui compte : la troisième partie est une signature, et les deux parties que vous venez de décoder ne sont ni secrètes ni authentifiées. N'importe qui avec une capture de paquets peut les lire, et n'importe qui avec un éditeur de texte peut les réécrire. Faire confiance au payload avant de vérifier la signature, c'est le bug classique des JWT. En production, ne bricolez pas ce contrôle vous-même. La réponse de la communauté, c'est le package firebase/php-jwt, actuellement en v7, conforme à RFC 7519 et exigeant PHP 8.0 ou plus récent. Installez-le avec Composer :
composer require firebase/php-jwt
Puis l'API vérifie d'abord et ne vous rend le payload que si la signature passe :
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); // une propriété, et seulement après que la signature a passé
} catch (UnexpectedValueException $e) {
// token malformé, mauvaise signature ou claims expirés
}
Une note de version : la v7 de la bibliothèque impose des longueurs minimales de clé pour les algorithmes HMAC, donc un secret HS256 de moins de 32 octets est refusé avec un DomainException avant même la vérification de la signature. Gardez vos secrets longs ; la bibliothèque ne vous laisse pas oublier.
Regardez l'ordre dans cette API : JWT::decode() lève une exception sur une mauvaise signature, un token expiré ou un algorithme manquant, au lieu de rendre du n'importe quoi, donc un payload que vous recevez est un payload auquel vous pouvez faire confiance. La version bricolée ci-dessus est pour comprendre, et pour jeter un œil aux tokens qui ne vous étaient pas destinés ; la bibliothèque est pour faire confiance.
HTTP Basic Auth, le plus vieux header
Le plus vieux header d'authentification du web roule encore sur du Base64. Selon RFC 7617, une requête HTTP Basic envoie Authorization: Basic suivi de l'encodage Base64 de username:password. Le RFC est explicite : c'est un encodage, pas une protection ; n'importe qui avec une capture de paquets peut décoder les deux moitiés en une touche. Votre travail côté décodage est de parser ce header, de décoder strictement, et de comparer avec une fonction sûre contre les attaques par temps d'exécution.
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])
) {
// authentifié
}
Deux détails gardent cela en sécurité. La limite de 2 dans explode() compte, parce qu'un mot de passe peut légalement contenir des deux-points, et la comparaison devrait être hash_equals(), jamais ==, pour qu'un attaquant ne puisse pas se frayer un chemin dans votre liste d'utilisateurs au chronomètre. Et ne servez ceci que sur HTTPS ; sur une connexion en clair, la couche Base64 est de la décoration.
E-mail, où tout a commencé
Base64 est né pour un problème précis : le transport postal ne portait que du ASCII 7 bits, et pourtant les gens voulaient envoyer des binaires. Le standard MIME (RFC 2045, section 6.8) a fait de Base64 l'un des encodages de transfert binaire et a ajouté deux règles de la maison. Premièrement, les lignes encodées ne doivent pas dépasser 76 caractères. Deuxièmement, le logiciel de décodage doit ignorer tous les caractères en dehors de l'alphabet, retours à la ligne compris. Cette seconde règle est exactement la raison pour laquelle le décodeur de PHP, dans les deux humeurs, avale un payload habillé en CRLF sans aucun prétraitement de votre part. (C'est aussi l'origine de la tolérance \r\n que vous avez vue dans le tableau du mode strict ci-dessus.)
$png = "\x89PNG\r\n\x1a\n" . random_bytes(256);
$wrapped = chunk_split(base64_encode($png), 76, "\r\n");
// plus tard, sur le côté récepteur, pas de nettoyage nécessaire :
$decoded = base64_decode($wrapped, true);
var_dump($decoded === $png); // bool(true) : chaque octet a fait le voyage aller-retour
Deux notes pratiques. Premièrement, l'habillage ajoute du poids : avec un CRLF tous les 76 caractères, une pièce jointe de 100 Ko arrive comme environ 137 Ko de texte (le facteur habituel des quatre tiers, plus le surcoût des retours à la ligne). Deuxièmement, pour les e-mails du monde réel avec en-têtes, plusieurs parties et cousins quoted-printable, l'extension facultative mailparse dissèque les messages RFC 822 complets partie par partie ; pour une seule pièce jointe connue, le décodage strict suffit.
Armure PEM : clés et certificats
Les certificats et les clés voyagent en armure PEM : un libellé BEGIN, un bloc de Base64 en lignes de 64 caractères, et un libellé END. La longueur de ligne de 64 caractères est une convention héritée de la spécification originale Privacy Enhanced Mail (RFC 1421), et les outils OpenSSL l'attendent, donc ça compte quand vous re-armez. Quand vous décodez, ça ne compte pas du tout : le décodeur ignore simplement les retours à la ligne.
$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) {
// finalement pas du Base64
}
var_dump($label); // string(11) "PRIVATE KEY"
Les octets décodés sont du DER, une sérialisation binaire compacte, et c'est avec ça que les fonctions openssl_* travaillent au fond. La référence arrière \1 dans l'expression régulière est le héros discret : elle garantit que le libellé END matche le libellé BEGIN, et c'est comme ça qu'on évite de coudre un END de certificat sur un BEGIN de clé quand un fichier contient plusieurs blocs.
Flux et gros payloads
Le décodage est la direction qui vous aide : la sortie fait les trois quarts de la taille de l'entrée, donc la pression mémoire venant de Base64 est rare. Quand même, quand un fichier .b64 de plusieurs centaines de mégaoctets atterrit sur le disque, vous avez deux outils pour garder l'empreinte à plat.
Le premier est le décodage par blocs. Découpez l'entrée nettoyée en morceaux dont la longueur est un multiple de quatre caractères, décodez chaque morceau strictement, et concaténez. Chaque bloc est un payload valide autonome, donc rien n'est perdu aux bordures, et un fichier corrompu échoue vite avec un décalage que vous pouvez signaler.
$clean = str_replace(["\r", "\n"], '', file_get_contents('/var/www/uploads/huge.b64'));
$decoded = '';
$chunkSize = 4 * 50000; // un multiple de quatre caractères, environ 150 Ko de sortie par appel
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 mégaoctet de Base64 se décode en bien moins d'une milliseconde sur du matériel moderne, donc cette boucle ne coûte presque rien ; choisissez-la pour ses propriétés de validation et de signalement, pas pour la vitesse.
Le second outil est un citoyen du monde du streaming : le filtre de flux convert.base64-decode. Il fonctionne sur n'importe quel flux PHP, donc vous pouvez décoder directement depuis un pointeur de fichier, php://input, ou un flux en mémoire sans jamais tenir tout le texte encodé dans une seule variable. Comme la fonction tolérante, il saute simplement tous les caractères en dehors de l'alphabet 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);
Quel outil choisir ? Le filtre quand les données passent à travers un flux et que vous voulez que PHP gère la plomberie ; la boucle par blocs quand vous avez besoin d'une validation par bloc, d'un rapport de progression, ou du décalage de la corruption.
Bases de données, fichiers de configuration et variables d'environnement
Base64 est un conteneur de texte, et c'est pour ça qu'il apparaît dans des endroits où vous ne l'attendez pas. Dans les bases de données, un blob binaire (un fichier, une icône, une structure sérialisée) peut vivre dans une colonne TEXT sous forme de Base64, survivant à tous les outils qui supposent du texte. Attendez-vous à une valeur stockée environ 33 % plus grosse que l'originale, et dimensionnez vos colonnes en conséquence. Dans les fichiers de configuration et les variables d'environnement, Base64 est l'astuce pour faire passer en contrebande des valeurs qui casseraient sinon le format : un DSN de base de données avec des points-virgules, un mot de passe avec des guillemets, une valeur avec un retour à la ligne.
// .env ou configuration, écrit par la personne 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 vaut maintenant : pg:host=db;password=qu"ote
La même prudence s'applique deux fois ici. Premièrement, c'est une sécurité de format, pas un secret : au moment où un développeur lit le fichier de configuration, il peut décoder la valeur en un appel. Ne stockez jamais un secret en Base64 en l'appelant chiffré. Deuxièmement, validez au démarrage : une valeur d'environnement corrompue ou collée à moitié est un false de l'appel strict, et un contrôle d'une ligne transforme une erreur d'exécution cryptique en un message de démarrage actionnable.
Depuis la ligne de commande
Tout le décodage ne se passe pas dans une requête web. Les scripts CLI, les tâches cron et les one-liners décodent du Base64 tout le temps, et c'est à la ligne de commande que la fonction rencontre php://stdin :
php -r 'fwrite(STDOUT, base64_decode(file_get_contents("php://stdin"), true));' < payload.b64 > restored.bin
Le shell a déjà son propre utilitaire Base64 (coreutils base64 -d), et il va bien pour le travail rapide ; le one-liner PHP est pour quand l'étape suivante est de la logique PHP : écrire dans une base de données, appeler une API, lancer une validation. Deux pièges spécifiques au shell. La sortie d'un décodage est des octets bruts, donc envoyez-la dans un fichier ou vers une commande qui comprend les octets, pas dans un terminal qui va les abîmer. Et gardez le flag strict dans le one-liner, parce qu'un collage tronqué dans un terminal mérite un false, pas trois octets de n'importe quoi.
Pièges à l'accent PHP
Un tour rapide des pièges spécifiques à PHP, réunis au même endroit :
- Le défaut tolérant est le grand.
base64_decode('V@hpcy')rend trois octets de n'importe quoi sans avertissement, donc chaque décodeur d'entrée non fiable a besoin du flag strict et d'un contrôle defalse. - Un seul caractère se décode en chaîne vide en mode tolérant, et une chaîne d'espaces uniquement aussi. Un résultat vide ne prouve presque rien ; seul
falsesignifie l'échec, et vous ne l'obtenez qu'en mode strict. - Le
+dans une chaîne de requête est déjà une espace avant que PHP ne le voie. Si un client envoie?token=abc+defsans l'encoder en pourcentage, PHP vous tendabc def(c'est le comportement de l'encodage de formulaire, partagé parparse_str()eturldecode()), et aucune magie de décodage ne ramènera le plus. Le Base64 sûr pour les URL (sans plus du tout) est la solution pour les tokens dans les URL. - Le padding manquant est complété pour vous, en silence. Sept caractères se décodent comme huit ; c'est pratique, mais ça veut dire qu'un payload tronqué d'un ou deux paddings peut encore se décoder sans plainte, donc un décodage propre ne prouve jamais tout à fait que le payload est arrivé entier (les encodeurs bruts de Go et Java sont tout aussi indulgents).
- Le fantôme de
mbstring.func_overload. Le réglage depuis longtemps non recommandé qui réécrivaitstrlen()et ses amis pour compter des caractères (retiré dans PHP 8.0) cassait les calculs d'octets Base64 sur des chaînes UTF-8. Le code legacy que vous héritez peut encore porter des commentaires et des contournements pour lui. Supprimez-les. - Les octets décodés ne sont pas une chaîne UTF-8. Faire tourner
preg_match()avec le flag/uoumb_substr()sur du binaire décodé est une source immédiate d'erreurs « entrée malformée ». Sniffez d'abord, puis décidez. - Passer
nullest non recommandé depuis PHP 8.1. Si une variable peut être nulle, fusionnez-la avec''avant l'appel. $_GETet ses amis sont décodés avec les règles de formulaire, pas les règles d'URL. Si une valeur est arrivée encodée en pourcentage,rawurldecode()est l'inverse plus sûr, parce qu'il laisse+tranquille.
Une brève histoire de base64_decode
Base64 lui-même est plus vieux qu'une bonne partie du web moderne (la norme qui le régit, RFC 4648, date de 2006, et elle a codifié l'encodage MIME de 1996, qui descend lui-même de l'armure PEM du début des années 1990). L'histoire PHP est son propre petit journal de bord.
PHP 4 a livré base64_decode() comme fonction du cœur, sans options et sans mode strict ; l'humeur tolérante était la seule humeur, et il n'y avait pas moyen de demander au décodeur de se plaindre. PHP 5.2.0, en novembre 2006, a ajouté le flag $strict, et l'entrée du journal mérite d'être lue : il a été ajouté pour imposer la conformité à RFC 3548, le prédécesseur de l'actuelle RFC 4648. Ce flag-là s'est avéré être l'ajout le plus utile de toute la vie de la fonction.
Puis sont venus les années de débogage. PHP 5.3 a corrigé une série de bugs du mode strict sur deux versions ponctuelles : bug #52327 (padding en tête mal géré en mode strict, corrigé dans 5.3.4) et bug #55273 (espaces après le padding rejetés en mode strict, corrigé dans 5.3.9). (Une correction de dépassement d'entier de 2016 est aussi classée sous le nom de cette fonction : bug #72836, officiellement intitulé « dépassement d'entier dans base64_decode provoquant une corruption du tas » et corrigé dans 5.6.25, mais le code de reproduction du rapport lui-même et la fonction corrigée montrent que le dépassement réel était dans le calcul de longueur de base64_encode(), pas dans le décodeur ; le titre est un contresens hérité du rapport d'origine.) Chaque correction a resserré le comportement que vous voyez dans le tableau ci-dessus. PHP 8.0 a donné aux deux fonctions Base64 des types natifs de paramètres et de retour, la signature que vous avez vue en tête de cet article, et la même ligne de versions a retiré mbstring.func_overload, le réglage qui cassait en silence les calculs d'octets depuis des années. PHP 8.1 a marqué non recommandé le passage de null aux deux. Depuis, la surface est gelée : un paramètre, un flag, un type de retour, inchangés.
Quelques friandises pour les geeks
Parce que c'est une référence longue, voici quelques faits spécifiques à PHP qui sont simplement fun :
- L'identité vide.
base64_encode('')etbase64_decode('')valent tous les deux''. Les fonctions traitent le vide comme une valeur de plein droit dans les deux directions, sansfalseen vue. - Une adresse étrange. Dans le manuel PHP, les deux fonctions Base64 vivent dans le chapitre « URLs » du livre « Autres extensions de base ». Il n'y a pas de chapitre « encodage » dédié ; c'est là que vous les trouverez, en tête de la liste du chapitre, devant
parse_url()et ses amis. - Le décodeur est un homomorphisme. Une note d'utilisateur classique de php.net observe que la fonction est un homomorphisme entre des chaînes segmentées modulo 4 et modulo 3, ce qui est la façon formelle de dire que tout découpage en multiple de quatre est un découpage valide. C'est pour ça que la section de décodage par blocs fonctionne du tout, et pour ça qu'un fichier de 1 Mo peut se décoder en tranches de 50 Ko sans aucune perte.
- Un paramètre, un flag. En plus de vingt ans,
base64_decode()a gagné exactement un paramètre ($strict) etbase64_encode()n'en a gagné aucun. - Elle a des aînées. La même extension du cœur porte aussi
convert_uuencode()etconvert_uudecode()(listées sous « fonctions de chaîne » dans le manuel), les reliques de l'ère de la ligne modulaire quand uuencode était le transport binaire de choix. Vous en aurez presque jamais besoin, mais si un antique fichier.uuatterrit un jour dans votre boîte mail, PHP peut l'ouvrir. - Le mode strict garde une porte ouverte pour l'e-mail. Les quatre caractères blancs (espace, tabulation, retour chariot et retour à la ligne) passent en mode strict exprès, donc une pièce jointe habillée en MIME n'a besoin d'aucun prétraitement. Tout le reste, octets NUL compris, se prend un
false.
L'autre direction
Voilà pour le côté décodeur, et c'est là que vit la plupart de la douleur, parce que le décodage, c'est là qu'on rencontre les données des autres : leurs choix de padding, leurs retours à la ligne, leurs jeux de caractères, leurs tokens. L'autre direction, transformer des octets en chaîne Base64 avec base64_encode(), est un animal plus calme : il n'échoue jamais, il n'a pas de mode strict, et son propre lot de pièges (double encodage, désaccords d'habillage, la facture de taille) a son propre guide. L'encodage Base64 en PHP, relié depuis cette page, couvre l'encodeur avec la même profondeur.
Dernière mise à jour : 2026-09-08
Article associé : Encodage Base64 en PHP : un guide complet