Vous avez à traiter le format Base64 ? Alors ce site est parfait pour vous ! Utilisez notre outil en ligne super pratique pour encoder ou décoder vos données.

Décodage Base64 en JavaScript/Node.js : un guide complet

Votre application reçoit une chaîne Base64. Ce peut être l'en-tête Authorization d'une requête entrante, un champ à l'intérieur d'un payload JSON, une image qui se cache dans une data URL, ou un certificat collé dans un fichier de configuration. Tous, c'est la même chose : des octets bruts en déguisement ASCII. Cet article parle de retirer ce déguisement en JavaScript et Node.js, et de le faire sans perdre le moindre octet en chemin.

Un mot rapide sur le format lui-même : le Base64 est un encodage texte qui remplace chaque groupe de trois octets d'entrée par quatre caractères imprimables. La page d'accueil de ce site détaille l'alphabet, le calcul et le remplissage, donc ici cela reste en une seule phrase. Une conséquence mérite d'être gardée en poche : les données encodées sont environ 33 pour cent plus grosses que les octets qu'elles transportent, ce qui signifie que le décodage est une opération de rétrécissement, et que rien dans cet article n'ajoute ni n'enlève aucun secret. Vous dépaquetez, vous ne rompez pas un sceau.

La bonne nouvelle : vous n'installez rien. Les navigateurs embarquent atob() depuis deux décennies, Node.js a la classe Buffer avec un mode base64 intégré, et les runtimes modernes embarquent maintenant Uint8Array.fromBase64(), un nouvel arrivé strict et configurable venu de la spécification ES2026. L'art, c'est de choisir le bon outil pour le bon boulot, et de savoir exactement ce que chaque outil pardonne, parce que sur un serveur vous décodez des données envoyées par des inconnus, et c'est la tolérance qui fait dérailler les choses.

Choisir un décodeur

Trois API couvrent l'immense majorité du travail de décodage. Elles diffèrent par leur tempérament, et c'est cette différence qui fait toute l'histoire :

Décodeur Disponible dans Tempérament
Buffer.from(string, 'base64') Node.js (toutes les versions qui comptent) Tolérant : saute les caractères inconnus, s'arrête au premier =, ne lève jamais d'erreur
atob(string) Tous les navigateurs, Node.js 16 et au-delà Strict : lève InvalidCharacterError sur une entrée invalide, saute les espaces ASCII, pardonne le remplissage manquant
Uint8Array.fromBase64(string) Chrome 140+, Firefox 133+, Safari 18.2+, Node.js 25+ Configurable : vous choisissez l'alphabet et le niveau de rigueur du dernier bloc

Les trois ouvrent le même payload classique de la même manière :

// Le cheval de trait de Node.js
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// La paire de toujours (tous les navigateurs, Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world", en "chaîne binaire"
// La méthode moderne ES2026 (Chrome 140+, Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"

Un avertissement avant de compter sur atob() : il renvoie une chaîne, mais une « chaîne binaire », une chaîne où chaque caractère porte un octet brut en point de code, de 0 à 255. L'afficher, ça va. La ranger dans du JSON, une base de données ou un cookie, c'est embarquer ces valeurs d'octets bruts avec vous, donc convertissez-la en vrais octets ou en vrai texte immédiatement après le décodage.

Le décodeur tolérant et ce qu'il avale

Le Buffer de Node est un lecteur indulgent, et c'est une arme à double tranchant. Il est merveilleux pour les données qui ont voyagé par des routes cahoteuses : des e-mails MIME avec leurs retours à la ligne, des chaînes recopiées à la main, des sorties de logs avec des espaces égarés. Il est dangereux pour les données que vous n'avez pas produites vous-même, parce qu'il ne se plaint jamais. Voici ce qui se passe vraiment :

Entrée Ce que Buffer.from(input, 'base64') fait
'!!!' Renvoie un Buffer vide. Tout le junk est sauté, rien ne se décode, pas d'erreur.
'aGVsbG8== garbage' Renvoie « hello ». Le premier = termine le décodage ; le reste est ignoré.
'aG!VsbG8' Renvoie « hello ». Le point d'exclamation est sauté, pas une erreur.
'aGVs=bG8' Renvoie « hel ». Un = au milieu de la chaîne arrête le spectacle avant l'heure.
'aGVsbG8====' Renvoie « hello ». Le remplissage en excès à la fin est ignoré.
'=aGVsbG8' Renvoie un Buffer vide. Un remplissage avant les données ne veut rien dire.

Le remède pour les entrées non fiables, c'est un validateur, et la grammaire du Base64 est assez petite pour tenir dans une seule expression régulière :

const STRICT = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
function decodeStrict (base64) {
  if (!STRICT.test(base64)) {
    throw new TypeError('Not a valid base64 string');
  }
  return Buffer.from(base64, 'base64');
}
console.log(decodeStrict('aGVsbG8gd29ybGQ=').toString('utf8')); // "hello world"
try {
  decodeStrict('aGVs!bG8');
} catch (error) {
  console.log(error.message); // "Not a valid base64 string"
}

L'expression régulière vérifie la forme : des groupes de quatre avec le bon remplissage. Une règle qu'elle ne peut pas vérifier, c'est la règle d'encodage canonique du RFC 4648, qui dit que les bits de remplissage inutilisés du dernier groupe doivent être à zéro. Le mode strict de Uint8Array.fromBase64(), lui, vérifie ça, donc sur Node.js 25 ou n'importe quel navigateur moderne vous pouvez sauter l'expression régulière entièrement et laisser la plateforme faire l'audit :

console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello", le mode loose pardonne le remplissage manquant
try {
  Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
  console.log(error.name); // "SyntaxError", les bits de remplissage ne sont pas à zéro
}

L'option lastChunkHandling a trois réglages qui valent le coup d'être connus. "loose" (le défaut) saute les espaces, accepte le remplissage manquant et ignore les bits de remplissage résiduels. "strict" exige un dernier groupe complet, remplit, avec tous les bits de remplissage à zéro. Et "stop-before-partial" ne décode que les groupes de quatre caractères complets et laisse le fragment de fin pour que vous le reportiez, et c'est la pièce qui rend le décodage en streaming agréable, comme vous le verrez plus tard dans cet article.

Des octets au texte : la question du charset

Décoder du Base64, ça vous remet des octets. Les octets deviennent du texte seulement quand vous choisissez un charset, et ce choix est le vôtre, en général basé sur ce que l'expéditeur a promis. Le défaut de Node, c'est celui que vous voulez la plupart du temps :

const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // les deux octets C3 A9
console.log(bytes.toString('utf8'));   // "é", les deux octets se rejoignent en un seul caractère
console.log(bytes.toString('latin1')); // "é", les mêmes octets lus un caractère à la fois

Il y a un détail embêtant avec le UTF-8 : quand une suite d'octets n'est pas du UTF-8 valide, Node ne lève pas d'erreur. Il substitue le caractère de remplacement Unicode (U+FFFD, le losange avec un point d'interrogation) et continue, ce qui veut dire qu'un payload corrompu peut filer dans votre pipeline jusqu'à votre base de données. Le vrai décodeur de texte de la plateforme, TextDecoder (une globale dans Node.js et tous les navigateurs), a une option fatal qui transforme la corruption en TypeError que vous pouvez attraper :

const stray = new Uint8Array([0xe9]); // un octet tout seul, pas du UTF-8 valide
console.log(new TextDecoder().decode(stray)); // le caractère de remplacement, pas d'erreur
try {
  new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
  console.log(error.name); // "TypeError"
}

Les systèmes légués ne meurent jamais, et TextDecoder sait encore les lire. Il accepte la table complète des étiquettes de la norme Encoding WHATWG, donc un payload Base64 venant d'une appli Windows des années 1990, d'une grande machine japonaise ou d'un vieux miroir FTP peut encore se décoder avec des étiquettes comme 'windows-1250', 'shift_jis', 'euc-kr' ou 'gb18030', toutes insensibles à la casse. Une étiquette mérite un avertissement, parce qu'elle a coûté un vrai temps de débogage : la norme associe 'iso-8859-1', 'latin1' et même 'us-ascii' au décodeur Windows-1252. L'octet 0x80, un caractère de contrôle en Latin-1 pur, sort en signe euro :

console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€", pas le Latin-1 que vous avez demandé
// Pour une vraie lecture Latin-1 octet par octet, utilisez le côté Buffer :
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // le caractère de contrôle 0x80 brut

Si vous avez vraiment besoin de ce mapping brut, l'encodage 'latin1' de Buffer (dont l'alias légué 'binary' est, selon les mots de la documentation de Node, un nom très trompeur) mappe l'octet N sur le point de code N sans le détour par Windows. Pour tout ce qui est moderne, le duo sûr, c'est UTF-8 plus fatal: true.

Ouvrir un JWT

Le payload Base64 qu'un service JavaScript décode le plus souvent, c'est un JSON Web Token, la chaîne xxxxx.yyyyy.zzzzz qui voyage dans l'en-tête Authorization de la moitié du web. Selon le RFC 7515, un JWS compact, ce sont trois parties séparées par des points, et les deux premières sont des objets JSON encodés en base64url sans remplissage. Les lire dans Node.js ne demande aucune cérémonie, parce que le mode base64url est un encodage à part entière :

const { Buffer } = require('node:buffer');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWRhIn0.JMjpmDdNzQZpTuUO1H33GJsj7nWhBu-qxkPD0GL2uaA';
const [head, body, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))); // { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))); // { sub: '123', name: 'Ada' }

Dit assez souvent, mais ça vaut le coup d'être répété : le décodage n'est pas une vérification. L'en-tête et le payload sont habillés, pas chiffrés, et n'importe qui tient le token peut lire les deux. La partie qu'il faut vérifier, c'est la troisième, la signature. Pour un token HMAC-SHA256 classique, toute la vérification tient en quelques lignes du module crypto intégré, et la seule partie subtile, c'est la comparaison avec timingSafeEqual pour qu'un attaquant ne puisse pas chronométrer votre comparaison octet par octet :

const crypto = require('node:crypto');
const expected = crypto.createHmac('sha256', 'topsecret').update(head + '.' + body).digest();
const actual = Buffer.from(signature, 'base64url');
console.log(crypto.timingSafeEqual(expected, actual)); // true
console.log(crypto.timingSafeEqual(crypto.createHmac('sha256', 'wrong-secret').update(head + '.' + body).digest(), actual)); // false

Dans un service réel, vous ne le ferez généralement pas à la main. Le package jose (zéro dépendance, tourne dans Node.js, les navigateurs et les edge runtimes) et le package jsonwebtoken qui a de l'ancienneté (Node.js) encapsulent toute la danse, gèrent les familles d'algorithmes RSA et ECDSA, et imposent les claims exp, aud et iss. Quelle que soit la bibliothèque que vous choisissez, la tuyauterie Base64 en dessous, ce sont les mêmes deux appels que vous venez de voir.

HTTP : en-têtes, chaînes de requête et cookies

Trois coins du fil regorgent de Base64. Le plus ancien, c'est l'authentification HTTP Basic, définie dans le RFC 7617 : le client envoie Authorization: Basic plus le Base64 de user-id:password. Côté serveur, c'est une tranche et un décodage, avec le petit détail protocolaire que seul le premier deux-points sépare le nom d'utilisateur du mot de passe, si bien qu'un mot de passe peut légalement contenir d'autres deux-points, ce qui n'est pas le cas d'un nom d'utilisateur :

const { Buffer } = require('node:buffer');
const header = 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==';
const credentials = Buffer.from(header.slice(6), 'base64').toString('utf8');
const [user, ...rest] = credentials.split(':');
console.log(user, rest.join(':')); // "Aladdin" "open sesame"

Et n'oubliez pas ce que l'authentification Basic est vraiment : de l'obfuscation, pas de la sécurité. Les identifiants traversent le fil en déguisement, c'est pour ça que ce schéma n'est acceptable que sur HTTPS. Le deuxième coin, c'est la chaîne de requête, et elle cache la mine la plus méchante du monde Base64 :

const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=", le plus est devenu un espace

Votre Base64 ne s'est pas corrompu tout seul. La couche URL l'a fait poliment, au nom des règles de l'encodage de formulaire, qui traitent + comme un espace. C'est précisément pour ça que les tokens qui vivent dans les chaînes de requête utilisent l'alphabet URL-safe, couvert dans la section ci-dessous. Le troisième coin, c'est le cookie : les cookies ne sont qu'ASCII, donc n'importe quelle valeur non-ASCII stockée dedans est quasi certainement du Base64, et le vieux schéma qui range un blob JSON en Base64 dans un cookie vit encore dans un nombre surprenant de systèmes de production. Le décodage est le même que vous connaissez déjà ; validez juste la forme d'abord, parce qu'un cookie, c'est le genre d'endroit où un utilisateur, ou une extension de navigateur, peut vous remettre du junk.

Fichiers, images et data URLs

Le système de fichiers de Node parle Base64 directement, donc un fichier entier peut traverser une frontière JSON en une seule ligne :

const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // le fichier, environ 33 pour cent plus lourd
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);

L'autre payload en forme de fichier, c'est la data URL, la chaîne data:image/png;base64,... que les front ends adorent pour les images en ligne. La recette est la même dans n'importe quel runtime : couper à la première virgule, analyser les métadonnées qui la précèdent, décoder le reste. Voici un vrai PNG d'un pixel qui revient à la vie :

const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const comma = dataUrl.indexOf(',');
const meta = dataUrl.slice(5, comma);
const bytes = Buffer.from(dataUrl.slice(comma + 1), 'base64');
console.log(meta); // "image/png;base64"
console.log(bytes.subarray(0, 8).toString('hex')); // "89504e470d0a1a0a", la signature PNG

Vérifier la signature, c'est une habitude pas chère. Les 8 premiers octets d'un PNG sont toujours 89 50 4E 47 0D 0A 1A 0A, et un JPEG commence par FF D8 FF. Si une « image base64 » venue d'un client ne commence pas par les octets magiques qu'elle a promis, vous le savez avant de faire quoi que ce soit de coûteux avec.

Base64 URL-safe : l'alphabet des tokens

Le Base64 classique utilise + et / comme ses deux caractères spéciaux (RFC 4648, section 4), et les deux sont des ennuis dans les URLs : + devient un espace pendant le décodage de formulaire, et / est un séparateur de chemin. La variante sûre pour les URLs et les noms de fichiers de la section 5, que tout le monde appelle base64url, les échange contre - et _, et peut supprimer le remplissage = final entièrement quand la longueur est connue d'après le contexte. C'est exactement la combinaison dont les JWT, les tokens OAuth et les deep links ont besoin, donc base64url est l'alphabet que vous croiserez le plus souvent dans la nature.

Le Buffer de Node rend tout ça anodin. Les deux modes de décodage 'base64' et 'base64url' acceptent les quatre caractères spéciaux et les mappent vers les mêmes valeurs, donc une partie de JWT, un token OAuth et un blob Base64 classique se décodent tous sans aucune cérémonie d'échange de caractères :

const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVs-bG8', 'base64').toString('hex'));    // "68656cf9b1bc"
console.log(Buffer.from('aGVs+bG8', 'base64url').toString('hex')); // "68656cf9b1bc", exactement les mêmes six octets

L'API ES2026 est plus pointilleuse exprès, et elle vous donne la même souplesse avec une molette explicite. L'option alphabet choisit entre "base64" (le défaut, + et /) et "base64url" (- et _), et lui donner un caractère de l'autre alphabet, c'est une SyntaxError, pas un décodage silencieux entre alphabets :

console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
  Uint8Array.fromBase64('aGVs-bG8'); // l'alphabet par défaut est le classique
} catch (error) {
  console.log(error.name); // "SyntaxError", le tiret n'est pas un caractère classique
}

Dans un navigateur qui n'a pas encore les nouvelles méthodes, le détour, c'est un petit échange avant de passer la chaîne à atob(), qui ne connaît que l'alphabet classique. Il faut aussi restaurer le remplissage si l'expéditeur l'a supprimé, ce qui est la norme pour les payloads de type token :

function decodeBase64Url (value) {
  const classic = value.replace(/-/g, '+').replace(/_/g, '/');
  const padded = classic + '='.repeat((4 - (classic.length % 4)) % 4);
  const binary = atob(padded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}
console.log(new TextDecoder().decode(decodeBase64Url('aGVsbG8gd29ybGQ'))); // "hello world"

Le Base64 dans la nature : où se cachent les payloads

Le Base64 est le service postal des octets dans le monde JavaScript. Un tour des endroits où il apparaît, avec la recette de décodage pour chaque étape :

  • Champs d'API JSON, de loin le porteur le plus fréquent : avatars, miniatures, documents générés et uploads arrivent en chaînes Base64 dans du JSON ordinaire, parce que le JSON n'a pas de mot pour « ce sont des octets ». Décodez le champ avant de faire quoi que ce soit d'autre avec.
  • Variables d'environnement et fichiers de configuration : plusieurs gestionnaires de secrets, systèmes de CI et le CLI npm lui-même vous remettent des blobs Base64 (les vieilles versions de npm stockaient l'identifiant du registre dans .npmrc sous forme de Base64 de user:password ; le npm moderne écrit un bearer token brut dans _authToken). Décodez une fois au démarrage, et gardez le texte en clair en mémoire seulement aussi longtemps que nécessaire.
  • Kubernetes et outillage de cluster : les secrets k8s sont, de notoriété publique, encodés en Base64 dans l'API et dans etcd, et la doc officielle répète sans cesse que c'est de l'encodage, pas du chiffrement. Votre code de décodage devrait traiter le résultat comme un secret, pas comme une preuve de sécurité.
  • Bases de données : tout ce qui est binaire stocké dans une colonne JSON (Postgres jsonb, documents MongoDB, Redis) est souvent une chaîne Base64. Décodez-le dans le chemin de lecture en Buffer ou en Uint8Array, et laissez la base de données rester en texte uniquement.
  • E-mail : le Base64 MIME avec son enveloppement de 76 caractères par ligne, c'est comme ça que les pièces jointes et les en-têtes binaires traversent SMTP, un protocole qui était à l'origine 7 bits uniquement. Le décodeur de Node saute les retours à la ligne pour vous, donc tout le corps se décode en un seul appel, sans nettoyage.
  • Pipelines CI et CD : les systèmes de build et les injecteurs de secrets passent des tokens comme valeurs d'environnement Base64 ; décodez dans le script du pipeline, et n'affichez jamais la valeur décodée dans un log.
  • Données d'annuaire et SAML : les fichiers LDIF stockent les attributs binaires (pensez : certificats) en Base64, et les réponses SAML sont souvent dégonflées puis encodées en Base64 avant de traverser une frontière HTTP.
  • Worker threads et edge runtimes : les chaînes Base64 traversent la frontière worker_threads comme de simples chaînes clonables par structured clone, donc un décodage lourd peut vivre sur un worker pendant que la boucle d'événements du thread principal reste libre.

Deux de ces étapes méritent un coup d'œil de plus près, parce qu'elles apparaissent à la fois dans les entretiens et dans la production :

const { Buffer } = require('node:buffer');
// Variable d'environnement : le secret arrive encodé en Base64
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// Champ d'API JSON : dépaqueter avant de faire quoi que ce soit d'autre
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", encore la signature PNG
// E-mail MIME : les retours à la ligne sont sautés, pas de nettoyage nécessaire
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"

L'anti-pattern à repérer pendant ce tour, c'est le même partout : du Base64 dans un endroit où les octets bruts étaient déjà permis. Un frame WebSocket, un stream de fichier, une colonne bytea Postgres, tous portent des octets de façon native, donc un aller-retour Base64 là, c'est du surcoût pur, la taxe de 33 pour cent de taille sans aucun bénéfice à montrer. Quand un chemin binaire natif existe, prenez-le.

Décoder par morceaux : streams et grosses données

Le Base64 encode 3 octets par groupe de 4 caractères, donc un stream de chunks peut couper un groupe en deux. L'approche naïve, décoder chaque chunk et prier, corrompt la sortie sur des frontières aléatoires. L'API ES2026 a été conçue pour exactement ça : setFromBase64() écrit dans un tableau pré-alloué et rapporte combien de caractères d'entrée il a consommés, et le mode "stop-before-partial" le fait s'arrêter au dernier groupe complet, en laissant le fragment pour le chunk suivant. Le pattern reproduit l'API de stream de TextDecoder :

const { Buffer } = require('node:buffer');
const chunks = ['aGVsbG8', 'gd29ybGQ='];
let leftover = '';
const parts = [];
for (const chunk of chunks) {
  const pending = leftover + chunk;
  const space = new Uint8Array(Math.ceil(pending.length * 3 / 4));
  const { read, written } = space.setFromBase64(pending, { lastChunkHandling: 'stop-before-partial' });
  parts.push(Buffer.from(space.buffer, space.byteOffset, written));
  leftover = pending.slice(read);
}
parts.push(Buffer.from(Uint8Array.fromBase64(leftover)));
console.log(Buffer.concat(parts).toString('utf8')); // "hello world"

Dans les runtimes sans les nouvelles méthodes (et la ligne LTS de Node ne les avait pas un moment), la même boucle marche avec un petit décodeur en userland qui suit le groupe partiel, ou vous mettez simplement en buffer les chunks entrants jusqu'à ce que vous puissiez couper sur les frontières de groupes. L'idée importante, c'est le report : ne décodez jamais un fragment tout seul.

Les gros payloads amènent deux limites de plus à votre attention. D'abord, la chaîne elle-même : buffer.constants.MAX_STRING_LENGTH de Node, c'est 536870888 caractères, soit environ 512 MiB de texte, ce qui se décode en environ 400 Mo d'octets. Un « fichier base64 » plus gros que ça a besoin d'une approche en streaming, pas d'un readFileSync unique. Ensuite, la mémoire : la chaîne encodée vit dans le tas JavaScript en UTF-16, 2 octets par caractère, et le Buffer décodé est une seconde copie des données. Pour les gros payloads, vous tenez les deux brièvement, donc gardez la forme encodée en vie le moins longtemps que le code le permet, et préférez les streams pour tout ce qui est de la taille d'un fichier.

Depuis le terminal

Node sert aussi de décodeur Base64 en ligne de commandes parfaitement correct, ce qui est pratique quand vous déboguez une requête ou inspectez une valeur de configuration :

# Décoder une chaîne Base64 classique passée en argument
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# La variante URL-safe, remplissage optionnel
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# Décoder depuis stdin, c'est à ça que servent les pipes
echo -n "aGVsbG8gd29ybGQ=" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(Buffer.from(d.trim(),"base64").toString("utf8")))'

Les trois affichent hello world. Si vous avez aussi la commande base64 classique de coreutils sur la machine, elle fait le même boulot avec base64 -d, mais les versions Node connaissent base64url, ce que l'outil traditionnel ne fait pas.

Les pièges à l'accent JavaScript

Chacun de ceux-ci a été un après-midi perdu de quelqu'un en JavaScript ou Node.js :

  • Le décodeur silencieux : Buffer.from('!!!', 'base64') renvoie un Buffer vide, pas une erreur. Une entrée à moitié corrompue se décode en données à moitié corrompues sans avertissement. Validez les entrées non fiables avec l'expression régulière stricte (ou le mode strict fromBase64), et traitez un Buffer vide venant d'une chaîne non vide comme un drapeau rouge.
  • L'argument d'encodage manquant : Buffer.from('aGVsbG8=') sans second argument ne décode rien. Il construit un Buffer à partir des octets UTF-8 de ces lettres, donc vos données « décodées », ce sont les lettres elles-mêmes, ré-emballées en octets. L'argument 'base64' est toute la ruse.
  • Le déguisement de « chaîne binaire » : la sortie de atob() n'est pas du texte tant que vous ne le déclarez pas. La fourrer dans une réponse JSON, un cookie ou une ligne de log « marche », et ça préserve aussi chaque octet nul, ce qui surprend les expéditeurs de logs et les sérialiseurs à parts égales. Convertissez avec charCodeAt() en Uint8Array, ou en texte UTF-8, immédiatement.
  • Le plus dans la chaîne de requête : un + dans une valeur de requête décodée en formulaire est un espace dès que URLSearchParams vous la remet. Préférez base64url pour tout ce qui vit dans une URL, et ne collez jamais un token Base64 classique non échappé dans une chaîne de requête.
  • Le caractère de remplacement : le UTF-8 invalide devient un losange-point d'interrogation silencieux en mode UTF-8 de Buffer au lieu d'une erreur, donc un payload corrompu peut passer votre pipeline et atterrir dans une base de données. Activez fatal: true avec TextDecoder là où la corruption devrait être un échec bruyant.
  • Le détour par Windows : demander 'iso-8859-1' ou 'latin1' à TextDecoder, on obtient le décodeur Windows-1252, où l'octet 0x80 devient le signe euro. Pour un vrai Latin-1 octet par octet, lisez le Buffer avec toString('latin1') à la place. Et souvenez-vous que 'binary' est juste un alias trompeur pour le même mapping Latin-1.
  • Les plafonds de taille : buffer.constants.MAX_LENGTH, c'est 9007199254740991 octets (2 à la puissance 53, moins un) sur les systèmes 64 bits, mais la chaîne qui porte le Base64 ne peut pas dépasser MAX_STRING_LENGTH de 536870888 caractères. Une seule chaîne peut donc porter à peine plus de 400 Mo de données décodées ; au-delà, streammez.
  • La facture mémoire : une chaîne Base64 coûte deux octets de tas par caractère (UTF-16), et le Buffer décodé est une seconde copie complète. Un fichier de 100 Mo devient environ 133 Mo de chaîne plus 100 Mo de Buffer, brièvement, dans votre processus. Réduisez la fenêtre pendant laquelle la forme encodée reste référencée.
  • Le désaccord strict côté distant, tolérant côté local : votre décodeur Node pardonne ce qu'un décodeur strict ailleurs refuse (un script Python, un service Go, une appli mobile). Si un côté de votre système est strict et l'autre indulgent, le bug n'apparaît que pour certaines longueurs de payload, ce qui est le pire genre de bug. Mettez-vous d'accord sur la rigueur au niveau du protocole, pas dans votre tête.

Comment JavaScript a cultivé ses décodeurs

Côté navigateur, c'est une histoire longue, ennuyeuse et fiable. atob() et btoa() ont été spécifiés dans le brouillon HTML5 début 2011 (les navigateurs les avaient avant la spec), et depuis lors, ils sont dans tous les grands navigateurs, sans changement de comportement depuis plus d'une décennie. Ils précèdent les typed arrays dans la norme du langage (ES2015), d'où le fait qu'ils parlent en « chaînes binaires » plutôt qu'en octets.

Node.js a fait pousser son décodeur sur une autre frise. La classe Buffer est devenue une globale à la version 0.1.103, à l'été 2010, près de cinq ans avant Node 1.0, et elle portait le mode 'base64' dès le début. Pour la plus grande partie de la vie de Node, c'était le seul décodeur en ville. Puis est arrivée la vague des standards web : Node 16 en 2021 a ajouté atob() et btoa() comme globales pour que le code écrit pour le navigateur tourne sur le serveur sans polyfill, et a marqué les deux comme Legacy dès le premier jour. Node 25, sorti le 15 octobre 2025, a mis V8 à niveau 14.1 et a apporté dans le runtime les méthodes ES2026, Uint8Array.fromBase64(), setFromBase64() et leurs sœurs hex. En chemin, le vieux constructeur new Buffer() a été déprécié (Node 10 a lancé les avertissements en 2018) au profit de Buffer.from(), alloc() et allocUnsafe(), en partie parce qu'une allocation non initialisée pouvait fuiter la mémoire qui s'y trouvait avant.

Dans les navigateurs, la même vague est arrivée un peu plus tôt : Firefox 133 et Safari 18.2 ont livré les nouvelles méthodes en 2024, et Chrome 140 (stable le 2 septembre 2025) a complété le set, moment où la fonctionnalité a été déclarée Baseline Newly available dans le programme Baseline des éditeurs de navigateurs. Bun, le runtime JavaScript tout-en-un, les a reçus en version 1.1.22, en août 2024. Et si vous ne pouvez pas exiger un runtime récent, core-js et le package es-arraybuffer-base64 du projet es-shims embarquent des polyfills pour tout ça, ce qui est aussi la route que la plupart des frameworks prennent en interne.

Le format qu'ils servent a une généalogie encore plus ancienne. L'alphabet a été standardisé pour la première fois pour Privacy-Enhanced Mail en 1987 (RFC 989), la révision de 1993 (RFC 1421) a gardé le même alphabet, et MIME l'a adopté en 1996 (RFC 2045), environ trois ans après cette révision, avec son enveloppement de 76 caractères par ligne ; le RFC 3548 en 2003 a consolidé base16, base32 et base64 dans un seul document, et le RFC 4648 en 2006 l'a réédité, en gardant l'alphabet URL-safe que le RFC 3548 avait ajouté - celui qui finirait, une décennie plus tard, dans tous les JWT. La variante URL-safe est une jolie anecdote : elle a été proposée dans un post de mailing-list de 2001 sur les identifiants peer-to-peer avant de croiser un token.

Faits amusants pour votre prochaine standup

  • La clé d'exemple du RFC WebSocket, dGhlIHNhbXBsZSBub25jZQ==, se décode en « the sample nonce ». Le comité de normalisation a glissé un clin d'œil dans son propre exemple, et atob() de Node décortique la blague en un seul appel.
  • Buffer.from('!!!', 'base64') renvoie un Buffer de longueur zéro. Une vraie allocation avec rien dedans. Rien. C'est le geste le plus proche d'un haussement d'épaules chez Node.
  • Les décodeurs Base64 de Node sont bilingues d'une façon que la spec n'a jamais demandée : +, -, / et _ sont tous les bienvenus dans les modes 'base64' et 'base64url', chaque paire se mappant vers la même valeur.
  • La documentation Node de atob() contient la phrase « Utilisez plutôt Buffer.from(data, 'base64') ». Un runtime qui vous dit d'arrêter d'utiliser l'une de ses propres globales, avec un codemod officiel (npx codemod@latest @nodejs/buffer-atob-btoa) pour faire la migration à votre place.
  • Les petits Buffers sont taillés dans une tranche partagée : Buffer.poolSize fait 65536 octets, et chaque petite allocation réutilise des morceaux de ce pool. C'est la raison pour laquelle la création de Buffer est rapide, et pour laquelle l'allocation « unsafe » est une expression dont vous devriez connaître le sens.
  • Le petit package base64-js, trois fonctions et zéro dépendance, attire largement plus de 100 millions de téléchargements par semaine sur npm, presque tous comme dépendance cachée dans d'autres packages. Le Base64 est le code le plus contrabandé de l'écosystème.
  • Uint8Array.fromBase64() a un mode appelé "stop-before-partial" qui existe purement pour que vous puissiez décoder un stream sans jamais scinder un groupe de quatre caractères. Un mode nommé d'après ce qu'il refuse de faire, c'est une rare pièce de poésie API.
  • Le monde des mots de passe Unix utilise ses propres alphabets à saveur Base64, sans remplissage, et, de façon confuse, ils ne sont pas tous dans le même ordre. L'alphabet « hash64 » classique de crypt(3) est ./0-9A-Za-z, mais bcrypt mélangue les mêmes 64 caractères dans ./A-Za-z0-9 à la place. Vous croiserez la version bcrypt dans les hashes $2b$ que beaucoup de projets JavaScript stockent pour les mots de passe des utilisateurs, et c'est la raison pour laquelle « base64 » dans un contexte de sécurité peut vouloir dire plusieurs alphabets différents, pas seulement deux.

Il reste une direction

Décoder du Base64 en JavaScript et Node.js, c'est une pile de trois outils honnêtes : Buffer.from(string, 'base64'), le cheval de trait indulgent qui accepte les deux alphabets et saute chaque caractère égaré, le mieux gardé par une expression régulière stricte ; TextDecoder, pour le vrai texte dans n'importe quel charset que le vieux web a inventé, avec le mode fatal quand la corruption devrait faire mal ; et le nouveau Uint8Array.fromBase64(), pour le code centré sur les octets qui veut des alphabets stricts, des bits de remplissage stricts et du streaming sans acrobaties. Fixez le charset, validez ce que des inconnus vous envoient, comparez les signatures avec timingSafeEqual, et le format cesse d'être un mystère des deux côtés de la frontière navigateur/serveur.

Et quand vous avez fini d'ouvrir les paquets, souvenez-vous que quelqu'un a dû les sceller. Le côté encodage a ses propres pièges : le mur de l'Unicode qui arrête btoa() en pleine phrase, l'enveloppement MIME, les règles de remplissage base64url, et le nouveau Uint8Array.toBase64() avec son option omitPadding. Cette histoire, avec des exemples de code pour chaque étape, est couverte en profondeur dans l'article sur l'encodage Base64 lié sur notre site sœur. Lisez-le ensuite, parce que les pièges sont différents et plus drôles de ce côté de l'alphabet.

Dernière mise à jour : 2026-09-07

Article associé : Encodage Base64 en JavaScript/Node.js : un guide complet