Encodage Base64 en JavaScript/Node.js : un guide complet
Vous avez des données qui doivent devenir du texte. Un fichier qui doit voyager dans un champ JSON, une image qui veut vivre dans un fichier CSS, un secret qui sera posé dans une variable d'environnement, un token qui traversera une chaîne de requête. La réponse en JavaScript et Node.js est presque toujours la même : le Base64. Cet article est le manuel d'empaquetage, du premier octet que vous tenez jusqu'au moment où votre chaîne encodée quitte la machine.
La page d'accueil de ce site détaille le format en entier, l'alphabet, le calcul, le remplissage, donc ici en une seule phrase : chaque groupe de trois octets devient quatre caractères imprimables, c'est pour ça que votre sortie sera environ 33 pour cent plus grosse que l'entrée. Gardez ça en poche, parce que c'est la raison pour laquelle chaque section de cet article existe, et c'est la monnaie dans laquelle votre facture de stockage est calculée.
La partie qui rassure : vous n'installez rien. Chaque navigateur moderne embarque btoa() et le plus récent Uint8Array.toBase64(), et chaque version de Node.js qui compte porte la classe Buffer avec un mode 'base64' et, depuis la version 15.7.0, un mode 'base64url' à part entière. L'art, c'est de savoir quelle forme d'entrée vous tenez, quel alphabet le destinataire exige, et quelles règles d'enveloppement de ligne les vieux formats imposent encore.
Connaissez votre entrée avant d'encoder
Chaque question d'encodage commence par la même : que tenez-vous exactement ? Une chaîne JavaScript est du texte UTF-16, un Buffer est un tableau d'octets, et le bon appel dépend de celui que vous avez :
| Vous tenez | Appelez ceci | Notes |
|---|---|---|
| Une chaîne ASCII uniquement (caractères sous 256) | btoa(string) |
Le chemin le plus rapide dans les navigateurs et Node.js 16+, mais il s'arrête au premier caractère qui ne tient pas dans un octet |
| N'importe quelle chaîne Unicode | TextEncoder vers les octets, puis un appel base64 |
Le pont UTF-8 ; le seul chemin sûr pour les lettres accentuées et les emoji |
| Un Buffer ou Uint8Array | buffer.toString('base64') ou bytes.toBase64() |
Le cheval de trait de Node.js, et la méthode ES2026 dans les navigateurs modernes et Node.js 25+ |
Trois exemples, un par ligne du tableau :
// Texte ASCII uniquement : le raccourci de toujours (navigateurs et Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// N'importe quel texte dans Node.js : Buffer lit du UTF-8 par défaut
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// Des octets que vous possédez déjà
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (runtimes ES2026)
Regardez le deuxième exemple : le même texte produit une chaîne Base64 différente selon le charset dans lequel vous l'encodez. Ce n'est pas un bug - c'est tout le jeu. La couche Base64 encode des octets, et une chaîne ne devient des octets que quand vous avez choisi un charset, donc « encodez ce texte » veut toujours dire en secret « encodez les octets UTF-8 de ce texte » (ou les octets Latin-1, si vous le dites).
Le mur de l'Unicode et les ponts par-dessus
btoa() est l'API la plus vieille de la pièce, et son contrat date des années 1990 : chaque caractère de la chaîne d'entrée doit tenir dans un seul octet, points de code entre 0 et 255. Tout ce qui est au-dessus, un emoji, une lettre cyrillique accentuée, un caractère chinois, lève une erreur :
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // "Invalid character" dans Node ; la formulation "plage Latin1" dans les navigateurs
}
Le remède, c'est d'arrêter de penser en caractères et de penser en octets. TextEncoder (une globale dans tous les navigateurs et dans Node.js) transforme la chaîne en sa suite d'octets UTF-8 ; vous hissez ces octets dans une chaîne Latin-1, et btoa() reçoit exactement ce qu'il s'était engagé à gérer :
function encodeUnicode (text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true, les mêmes octets
Vous croiserez aussi le vieux idiome dans les codebases, et ça marche de la même façon en dessous : btoa(unescape(encodeURIComponent(text))). L'appel encodeURIComponent produit des octets UTF-8 en encodage pourcentage, et unescape remet les échappements pourcentage en caractères bruts. escape et unescape sont des fonctions léguées, donc le nouveau code devrait préférer le pont TextEncoder, mais quand vous héritez de la vieille forme, vous savez maintenant exactement ce qu'elle fait au lieu de hauser les épaules.
Dans Node.js, le mur est surtout un non-événement, parce que Buffer.from(text) suppose du UTF-8 et fait la conversion d'octets pour vous dans le même appel. Le pont compte le plus dans le navigateur, où btoa() est l'option léguée et où l'étape UTF-8, c'est à vous de la faire explicitement.
Des octets en entrée, des lettres en sortie : Buffers, remplissage et saveurs
Une fois que vous avez des octets, le côté encodage de Node.js tient en une méthode : toString('base64'). Elle gère le calcul des groupes, le remplissage, tout, et elle produit toujours une sortie canonique au sens du RFC 4648, c'est-à-dire les bits de remplissage inutilisés du dernier groupe sont à zéro :
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 octets
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60 caractères, la taxe de 33 pour cent en action
Le remplissage à la fin fait du vrai travail, ce n'est pas de la décoration. Un dernier groupe avec un octet restant devient deux caractères Base64 plus deux =, et un groupe avec deux octets restants devient trois caractères plus un =. Le fait que votre sortie puisse porter ce remplissage dépend du destinataire, et c'est ça la différence entre les deux saveurs de Base64 que vous utiliserez au quotidien :
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2026, remplissage inclus)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", Node supprime le remplissage en mode base64url
Pensez au ratio quand vous dimensionnez : trois octets en entrée, quatre caractères en sortie, donc 1 Mo de données deviennent environ 1,33 Mo de texte, et si vous enveloppez le texte en lignes pour l'e-mail ou PEM, les retours à la ligne ajoutent quelques pour cent par-dessus.
Faire traverser le fil aux octets
Le problème de fil le plus courant en JavaScript, c'est que le JSON n'a pas d'octets. Il a des chaînes, et la chaîne qui peut voyager en sécurité à travers n'importe quel parseur JSON, n'importe quel proxy HTTP et n'importe quel système de logs, c'est celle en Base64. Le pattern est le même aux deux bouts de la connexion : encoder à la frontière, décoder à la frontière, tenir les octets entre les deux :
const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
name: 'photo.jpg',
contentType: 'image/jpeg',
data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true, le fichier voyage maintenant dans du JSON ordinaire
Sachez quand résister à ce pattern. Si votre transport supporte déjà le binaire, utilisez-le : un upload multipart/form-data envoie le fichier brut sans taxe de taille, un frame WebSocket porte des octets bruts, et une colonne bytea Postgres les stocke nativement. Du Base64 dans un endroit où les octets bruts étaient permis, c'est du surcoût pur, la taxe de 33 pour cent sans rien à montrer en échange. Le Base64 gagne sa vie quand le canal est texte uniquement : les API JSON, les corps d'e-mail, les variables d'environnement, les chaînes de requête des URLs, et les nombreux ponts (SDK mobiles, applis de bureau, systèmes de chat) qui ne laissent passer que du texte.
Data URLs : des images qui vivent dans le texte
La data URL, la chaîne data:image/png;base64,..., c'est du Base64 qui porte une étiquette MIME, et c'est pour ça que vous pouvez ranger une image entière dans un seul attribut HTML. Le RFC de 1998 qui a défini le schéma dit même que c'est « utile seulement pour de courtes valeurs », parce que le HTML des débuts avait une limite de 1024 caractères sur les valeurs d'attributs. Les navigateurs modernes rient de cette limite et affichent joyeusement des data URLs de la taille de mégaoctets, ce qui est à la fois un super-pouvoir et un piège.
Dans le navigateur, l'API canvas fait tout le travail pour vous, pixels en entrée, data URL en sortie :
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 24)); // "data:image/png;base64,iV"
Et dans n'importe quel runtime, y compris Node.js, en construire une, c'est juste de la concaténation de chaînes avec les métadonnées à la bonne place : un préfixe data:, le type média, le marqueur ;base64 optionnel, une virgule, et le payload. Sans le marqueur ;base64, le payload est attendu en texte en encodage pourcentage, et c'est pour ça que le marqueur existe :
const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true
Les compromis honnêtes : une data URL fait partie du document, donc elle n'est pas mise en cache comme ressource à part entière, elle compte dans la taille du HTML ou du CSS où elle se trouve, et le DOM doit la parser et la retenir. Pour une icône de 20 kilo-octets, c'est une bonne affaire. Pour une image hero de 4 mégaoctets, expédiez le fichier en HTTP où la mise en cache et la compression marchent toutes les deux, et gardez la data URL pour les petites choses.
Des fichiers qui voyagent en chaînes
Le passage d'un fichier au Base64, c'est une danse en deux temps que les deux runtimes compressent en un seul appel. Dans Node.js, le système de fichiers accepte 'base64' comme encodage de lecture, et le côté écriture l'accepte aussi :
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // le fichier, environ 33 pour cent plus lourd
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
Dans le navigateur, c'est FileReader qui fait le même travail, avec un détail : son mode de lecture de données vous remet une data URL, donc vous coupez le préfixe pour obtenir le payload Base64 nu :
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:application/pdf;base64,..."
const payload = {
name: fileInput.files[0].name,
data: dataUrl.slice(dataUrl.indexOf(',') + 1)
};
console.log(payload.data.length); // le fichier, prêt pour une requête JSON
};
reader.readAsDataURL(fileInput.files[0]);
});
Si vous avez besoin du Base64 brut sans le préfixe data URL dans le navigateur, file.arrayBuffer() suivi de Uint8Array.toBase64() (sur les runtimes qui l'ont) saute le préfixe entièrement et c'est le chemin plus propre pour les pipelines d'upload.
base64url : l'alphabet qui survit aux URLs
Le Base64 classique porte deux caractères auxquels les URLs sont allergiques. Le + devient un espace chaque fois qu'une chaîne de requête est décodée en formulaire, le / est un séparateur de chemin, et le remplissage = ressemble à une affectation. La variante sûre pour les URLs et les noms de fichiers de la section 5 du RFC 4648, base64url, échange les deux spéciaux contre - et _ et supprime le remplissage chaque fois que la longueur est connue d'après le contexte. C'est l'alphabet des JWT, des tokens OAuth et des deep links, et il mérite une place dédiée dans votre boîte à outils mentale.
Le Buffer de Node parle ce dialecte depuis la version 15.7.0, et le côté encodage tient en un argument :
const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"
Deux choses à remarquer. Le + est devenu un -, le / est devenu un _, et le remplissage a disparu, parce que le mode base64url l'omet exprès. Et l'IETF est explicite : c'est un encodage différent, pas le même avec un déguisement, donc quand une spec dit « base64url », vous devez produire du base64url, pas du Base64 classique avec un rechercher-remplacer. La méthode ES2026 fait des mêmes choix des options explicites, et son flag omitPadding vous rend le remplissage quand le contexte l'exige :
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", deux octets ont besoin d'un caractère de remplissage
Dans les runtimes qui n'ont ni l'un ni l'autre, la conversion, c'est un échange de deux caractères plus une coupe du remplissage, et c'est l'un des extraits les plus copiés-collés du monde JavaScript :
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"
Utilisez base64url pour tout ce qui vivra dans une URL, une chaîne de requête, un nom de fichier, ou un standard de token. Utilisez le Base64 classique pour les corps MIME, les data URLs et tout ce qui ne croisera jamais un décodeur pourcentage. Les mélanger, c'est le bug d'interop le plus courant de tout ce format.
Sceller des identifiants : Basic Auth, JWT et PKCE
Trois coins d'authentification du web sont bâtis sur le Base64, et les trois se construisent une fois à la main sans grande peine, ce qui est une bonne chose, parce que savoir ce qui se passe sous la bibliothèque, c'est ce qui vous garde calme quand la bibliothèque vous surprend.
D'abord, l'authentification HTTP Basic (RFC 7617) : le client envoie le mot de schéma Basic plus le Base64 de user-id:password. Une ligne, et un avertissement sérieux attaché :
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
Ici, le Base64 est de l'obfuscation, pas de la sécurité. Quiconque peut lire l'en-tête peut lire le mot de passe, donc ce schéma n'est acceptable que sur HTTPS, et même là, c'est un pattern légué : préférez les tokens. Deuxièmement, le JWT : les deux premières parties séparées par des points sont du base64url de JSON brut, et la troisième est la signature. Construire un token HMAC-SHA256 à la main, c'est une poignée de lignes du module crypto intégré :
const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3 parties, base64url sans remplissage de bout en bout
Remarquez les détails qui font ou défont un token : aucun remplissage nulle part (le RFC 7515 l'omet), la signature est calculée sur la chaîne littérale header + '.' + payload, pas sur les objets parsés, et l'ensemble est aussi secret que la clé, pas plus. En production vous utiliserez une bibliothèque, jose (zéro dépendance, navigateur et Node.js) ou jsonwebtoken (Node.js), mais elles font exactement ces appels en dessous. Troisièmement, PKCE (RFC 7636), l'extension qui permet aux clients publics comme les SPA et les applis mobiles de se connecter en sécurité : le client génère un code_verifier à haute entropie, publie BASE64URL(SHA256(verifier)) comme challenge, et prouve la possession du verifier à l'échange du token. L'aléatoire compte, donc le verifier vient du module crypto, jamais de Math.random() :
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43, les deux dans la plage autorisée 43-128
Le vieux courrier a besoin d'enveloppement : MIME et armure PEM
Deux des formats Base64 les plus anciens au monde imposent encore des longueurs de ligne, et les deux ont environ 30 ans. MIME, la norme e-mail du RFC 2045, enveloppe son Base64 à 76 caractères par ligne et exige que les lignes se terminent par CRLF, une relique des jours SMTP 8 bits propres où les lignes très longues faisaient tomber de vrais serveurs de mail. Le RFC 7468, qui écrit les règles PEM pour les certificats et les clés, est encore plus strict : les générateurs doivent envelopper à exactement 64 caractères par ligne, la dernière ligne plus courte, le tout encadré par des lignes d'armure -----BEGIN et -----END qui nomment le contenu.
L'enveloppement lui-même tient en une ligne, et l'armure, c'est un modèle :
const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200 caractères
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----
Deux notes pratiques. Quand vous produisez du MIME ou du PEM, enveloppez, parce que les consommateurs stricts (passerelles de mail, outils de l'ère OpenSSL, magasins de clés Java) rejetteront un blob Base64 d'une ligne de 4000 caractères. Quand vous consommez, vous n'en avez d'habitude pas besoin, parce que le décodeur de Node saute les espaces, donc Buffer.from gère les retours à la ligne pour vous - mais les lignes d'armure elles-mêmes ne sont pas du Base64, donc enlevez les lignes -----BEGIN/-----END (ou ne matchez que le corps) avant de décoder : Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64'). Cette asymétrie est un cadeau, mais ça ne veut pas dire que vous pouvez sauter l'étape de retrait de l'armure quand le Base64 va quelque part qui ne saute rien, comme un parseur DER.
Où vivent les données encodées : environnement, config et bases de données
Le Base64 est aussi un format de stockage, ce qui est à la fois pratique et dangereusement facile à prendre pour de la sécurité. Les variables d'environnement sont le domicile classique : plusieurs gestionnaires de secrets et systèmes de CI vous remettent des valeurs encodées en Base64, et le décodage tient en une ligne :
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
Dites la phrase importante à voix haute : l'encodage n'est pas du chiffrement. Un « secret » Base64 dans une variable d'environnement, un fichier .env ou un secret Kubernetes (k8s stocke ses secrets en Base64 dans l'API et dans etcd, et la doc le répète sans cesse) est lisible par quiconque peut lire l'environnement du processus, le fichier, ou le cluster. Utilisez le Base64 là parce que le transport (shell, YAML, JSON) est texte uniquement, jamais parce que vous croyez qu'il cache quelque chose.
Dans les bases de données, le Base64 est le pont standard pour le binaire dans les magasins de documents JSON, parce qu'une colonne jsonb ou un document MongoDB n'a pas de type octet à lui :
const document = {
name: 'logo',
mime: 'image/png',
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}
Stockez le type média à côté du payload, comme le fait l'exemple, et vous vous remercierez dans un an quand quelqu'un demandera ce que sont les octets. Si votre base de données a un type binaire natif (Postgres bytea est l'exemple de référence), préférez-le : les octets ne coûtent rien de plus, et vous sautez la taxe de 33 pour cent pour toujours.
Encoder en stream sans scinder les groupes
Le Base64 travaille par groupes de trois octets, donc un encodeur qui reçoit des chunks arbitraires doit porter son reste : un ou deux octets qui ne forment pas encore un groupe doivent attendre le chunk suivant avant de pouvoir être encodés. Faites le calcul par chunk et n'émettez que des groupes complets, et la sortie est identique, à l'octet près, à l'encodage du stream entier d'un coup :
const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
let pending = Buffer.alloc(0);
return new Transform({
transform (chunk, _encoding, done) {
pending = Buffer.concat([pending, chunk]);
const whole = Math.floor(pending.length / 3) * 3;
this.push(pending.subarray(0, whole).toString('base64'));
pending = pending.subarray(whole);
done();
},
flush (done) {
if (pending.length > 0) {
this.push(pending.toString('base64'));
}
done();
}
});
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h"; identique à un gros toString('base64') unique
});
encoder.end(Buffer.from('hello world, this is a stream!'));
Le callback flush est le détail que tout le monde oublie : les un ou deux derniers octets, ceux qui n'ont jamais trouvé de partenaire dans un chunk ordinaire, reçoivent leur remplissage et sont poussés à la fin. La même logique de report, c'est ce que vous reproduirez sur le côté décodage, sauf que de ce côté, l'API ES2026 vous la donne gratuitement : setFromBase64() avec "stop-before-partial" s'arrête exactement aux frontières de groupes et vous dit combien de caractères il a consommés.
Gros fichiers et la facture mémoire
Le Base64 est généreux en espace, donc les gros fichiers ont besoin d'une stratégie. Un fichier de 1 Go devient environ 1,33 Go de texte Base64, et une chaîne JavaScript stocke en UTF-16, deux octets de tas par caractère, donc ce texte seul réclame environ 2,7 Go de mémoire avant que votre Buffer n'arrive. Le plafond est explicite dans Node : buffer.constants.MAX_STRING_LENGTH, c'est 536870888 caractères, juste sous 512 MiB de texte, ce qui se décode en environ 400 Mo d'octets. Au-delà, une seule chaîne n'est pas une option, et le streaming est le seul jeu en ville :
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
const joined = Buffer.concat([carried, chunk]);
const whole = Math.floor(joined.length / 3) * 3;
process.stdout.write(joined.subarray(0, whole).toString('base64'));
carried = joined.subarray(whole);
});
source.on('end', () => {
if (carried.length > 0) {
process.stdout.write(carried.toString('base64'));
}
process.stdout.write('\n');
});
Le pattern, c'est l'encodeur de stream de la section précédente, aplati : lire par chunks de 64 KiB, porter le reste de 1 à 2 octets, émettre les groupes complets, flusher la queue. L'empreinte mémoire reste autour d'un chunk plus un reste, quel que soit le poids du fichier. Et si le bout récepteur peut accepter du binaire, demandez-vous pourquoi vous payez la taxe du tout.
One-liners pour le terminal
Node sert aussi d'encodeur Base64 en ligne de commandes, ce qui est pratique quand vous emballez une valeur de configuration, déboguez une API, ou déplacez un petit fichier entre machines à travers un message de chat :
# Encoder un fichier en Base64 classique sur stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# La variante URL-safe, remplissage supprimé
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# Lire depuis stdin, c'est à ça que servent les pipes
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'
Aucun des trois n'ajoute de retour à la ligne final de son propre chef, ce qui garde la sortie propre pour le copier-coller et pour la substitution $(...) dans les scripts shell. Si vous voulez un fichier bien présenté avec des lignes enveloppées, pipelinez le résultat vers votre éditeur de choix, ou ajoutez un \n à la fin du one-liner.
Les pièges qui coûtent des heures aux développeurs
Chacun de ceux-ci a coûté un vrai après-midi dans un vrai codebase :
- Le mur de l'Unicode :
btoa('héllo ⛳')lèveInvalidCharacterErrorparce que le drapeau de golf ne tient pas dans un octet. Le remède, c'est le pont UTF-8 :TextEncodervers les octets d'abord, puis encoder. Dans Node.js vous sautez le problème entièrement avecBuffer.from(text), qui suppose du UTF-8. - L'idiome légué : le vieux code rempli de
btoa(unescape(encodeURIComponent(x)))marche, maisescapeetunescapesont des fonctions léguées dépréciées. Quand vous refactorez ce code, remplacez-le par le pontTextEncoderet le comportement reste identique. - L'argument d'encodage manquant, en sens inverse : le piège côté décodage, c'est
Buffer.from(str)sans le mode 'base64' ; son jumeau côté encodage, c'est supposer queBuffer.from(someString)fait quelque chose de spécial avec le Base64. Ce n'est pas le cas. Sans encodage explicite, il construit un Buffer à partir des octets UTF-8 de la chaîne, et votre sortie « encodée » est le Base64 des octets de lettres de la chaîne, ce qui est presque jamais ce qui était voulu. Soyez explicite dans les deux sens. - Le désaccord de remplissage : les JWT et la plupart des standards de token veulent du base64url sans remplissage, le MIME veut du Base64 classique avec remplissage, et les deux sont faciles à croiser. Un
=de remplissage dans une partie de JWT casse les vérificateurs stricts ; un remplissage manquant là où la longueur est inconnue casse les décodeurs paresseux. Suivez le standard, pas votre habitude. - La queue non canonique : le RFC 4648 exige que les bits de remplissage inutilisés du dernier groupe soient à zéro. Les encodeurs intégrés produisent tous une sortie canonique, mais un encodeur fait à la main qui décale les bits à la main peut laisser du junk dans ces bits, et un décodeur strict rejettera votre payload sans raison apparente. Si vous écrivez votre propre encodeur, testez contre les vecteurs de test du RFC 4648, pas seulement vos propres données.
- L'enveloppement oublié : le MIME veut des lignes de 76 caractères et le PEM en veut de 64, et les consommateurs stricts (passerelles de mail, outillage de clés Java) rejettent un blob d'une ligne. L'inverse est plus rare mais réel : certains parseurs sont orientés lignes, et un CRLF manquant à la fin d'un fichier PEM a cassé plus de builds que n'importe quel bug dans le Base64 lui-même.
- L'illusion de sécurité : le Base64 dans une variable d'environnement, un fichier
.envou un secret Kubernetes n'est pas du chiffrement. Il se décode avec une ligne de code, dans n'importe quel langage, par quiconque peut lire le fichier ou le cluster. Traitez-le comme un déguisement de transport, laissez les vraies protections (permissions, TLS, rotation de clés) faire le travail. - Le gonflement JSON : le Base64 dans du JSON coûte 33 pour cent plus les échappements, et un upload de 5 Mo devient une chaîne de 6,7 Mo que votre parseur JSON doit copier en mémoire. Pour tout ce qui est de la taille d'un fichier en HTTP,
multipart/form-dataou un corps binaire brut est le meilleur transport, et le Base64 est pour quand le canal est texte uniquement. - La facture du tas : une chaîne encodée est en UTF-16 dans le tas JavaScript, deux octets par caractère, et le Buffer décodé ou source est une seconde copie des données. Un fichier de 100 Mo signifie brièvement environ 270 Mo de chaîne plus 100 Mo de Buffer. Streammez les gros, et gardez la forme encodée référencée le moins longtemps que le code le permet.
- Les globales léguées dans Node : la documentation de Node elle-même marque
btoa()etatob()comme Stabilité 3, Legacy, et vous dit d'utiliserBufferà la place. Dans un navigateur,btoa()est un outil tout à fait correct pour le texte ASCII ; dans Node.js, tendez la main vers le Buffer et laissez les globales au code de forme polyfill qui en a besoin.
Comment JavaScript a appris à emballer des octets
Côté navigateur, c'est une histoire longue et tranquille. btoa() a été spécifié dans le brouillon HTML5 début 2011, et il est dans tous les grands navigateurs depuis le milieu des années 2000, sans changement de comportement, avec son contrat d'un octet par caractère et sa sortie toujours remplie. Ce contrat précède les typed arrays - les « chaînes binaires » étaient le seul moyen de porter des octets avant 2009 - d'où le fait que btoa() pense encore en « chaînes binaires ». La moitié moderne de l'histoire est très récente : la proposition TC39 qui a ajouté le Base64 natif aux typed arrays (avec l'hexadécimal) a été standardisée comme partie de ES2026, et elle a atterri dans Firefox 133 et Safari 18.2 en 2024, dans Chrome 140 le 2 septembre 2025, et a alors été déclarée Baseline Newly available. Bun a livré les mêmes méthodes en version 1.1.22, en août 2024.
Node.js a emballé ses octets sur une autre horloge. 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 toString('base64') a été l'encodeur de choix pendant plus d'une décennie, avec les bizarreries d'alphabet de cette époque (il acceptait déjà les caractères URL-safe au décodage, une habitude bilingue que la spec n'a jamais demandée). La version 15.7.0 de janvier 2021 a ajouté le mode 'base64url' comme nom d'encodage à part entière, Node 16 cette même année a ajouté les globales navigateur btoa()/atob() (marquées Legacy immédiatement), et Node 22 en 2024 a livré davantage de travail de performance V8 et base64. Puis Node 25, sorti le 15 octobre 2025, a mis V8 à niveau 14.1 et a apporté dans le runtime les méthodes ES2026, toBase64() avec son option omitPadding et setFromBase64() pour l'autre direction. Pour les runtimes qui ne peuvent pas suivre, core-js embarque des polyfills (features/typed-array/to-base64 / from-base64), et le petit package base64-js (trois fonctions, zéro dépendance) porte l'écosystème en silence depuis des années comme dépendance transitive.
Le format qu'ils servent est plus vieux que tout ça. 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) l'a gardé, et MIME l'a adopté quelques mois plus tard la même année avec son enveloppement de 76 caractères ; le RFC 3548 a consolidé la famille Base-N en 2003 et a ajouté la variante URL-safe, que le RFC 4648 a rééditée en 2006. Une décennie plus tard, les RFC 7515 et 7519 ont fait du base64url sans remplissage la colonne vertébrale de chaque JWT, et le RFC 7636 l'a mis dans le flux PKCE d'OAuth. Les encodeurs de cet article sont le dernier kilomètre d'un format vieux de trente ans qui embarque encore des passagers.
Bon à savoir en soirée
btoa('GIF89a')renvoie"R0lGODlh", tout l'en-tête magique d'un GIF en huit caractères. C'est le plus petit « bonjour » qu'un fichier binaire peut dire en Base64, et c'est pour ça qu'il est le premier exemple Web API de l'article Wikipédia.toBase64()a une optionomitPaddingquebtoa()n'aurait jamais pu avoir, parce que le contrat de l'API Web remplit sans condition. Deux décennies du même alphabet, et l'API plus récente peut faire une chose que l'ancienne n'a jamais eu le droit de faire.- Un alphabet, deux longueurs de ligne officielles : le MIME enveloppe à 76, le PEM à 64. Les mêmes 64 caractères, le même remplissage, deux opinions différentes de 30 ans sur la largeur qu'une ligne de texte a le droit d'avoir.
- Le chiffre de 33 pour cent est exact : quatre caractères pour trois octets, c'est un ratio de 4/3, et l'e-mail de l'ère RFC ajoutait environ 3,5 pour cent de plus pour les retours à la ligne. Votre chaîne de configuration « petite » est 37 pour cent plus grosse pour rien.
- Le petit package
base64-jsattire plus de 100 millions de téléchargements par semaine sur npm, presque tous cachés dans les arbres de dépendances d'autres packages. Le Base64 est le code le plus contrabandé de l'écosystème JavaScript. - Les petits Buffers ne sont pas alloués un par un : Node les taille dans un pool partagé de 65536 octets (
Buffer.poolSize), c'est pour ça que la création de Buffer est rapide, et pour ça que les variantes d'allocation « unsafe » existent pour les cas où les données du locataire précédent ne comptent pas. - Le RFC qui a défini les data URLs en 1998 avertit qu'elles sont « utile seulement pour de courtes valeurs », en citant une limite d'attribut HTML de 1024 caractères. Les navigateurs modernes embarquent des images de la taille de mégaoctets en data URLs dans les mêmes attributs, ce qui est soit du progrès, soit de l'orgueil, selon votre image hero.
- Les hashes de mots de passe Unix utilisent leurs propres alphabets à saveur Base64, sans remplissage, et, de façon confuse, l'ordre diffère selon le schéma : les hashes
crypt(3)classiques utilisent./0-9A-Za-z, tandis que les chaînes bcrypt$2b$que les projets JavaScript stockent pour les mots de passe des utilisateurs mélangent les mêmes 64 caractères dans./A-Za-z0-9à la place. C'est un bon rappel que « Base64 » dans un contexte de sécurité est une famille, pas un format unique. - Le décodeur de Node accepte
-,_,+et/dans les modes'base64'et'base64url', quatre caractères, une seule table. L'encodeur, bien sûr, ne parle que le dialecte que vous avez demandé.
La moitié d'un aller-retour
Encoder du Base64 en JavaScript et Node.js revient à trois décisions : quels octets tenez-vous (une chaîne a besoin d'un charset, un Buffer n'en a besoin d'aucun), quel alphabet le destinataire exige (classique pour le MIME et les data URLs, base64url pour les tokens et les URLs, remplissage optionnel selon le contexte), et quelles règles de ligne le format impose encore (76 pour l'e-mail, 64 pour PEM, aucune pour le JSON). Répondez à ça et les intégrés font le reste : Buffer.toString() dans Node, btoa() plus le pont UTF-8 dans le navigateur, et Uint8Array.toBase64() dans les runtimes modernes qui en ont enfin un.
Et chaque paquet que vous scellez ici, quelqu'un d'autre l'ouvrira un jour. Le côté décodage a son propre lot de pièges : le décodeur indulgent qui avale le junk sans un son, le déguisement de « chaîne binaire » que atob() vous remet, les décisions de charset qui se prennent du côté lecteur du mur, et la logique de streaming qui reproduit le pattern de report que vous venez d'apprendre. Cette histoire, avec des exemples de code pour chaque étape, est couverte en profondeur dans l'article sur le décodage Base64 lié sur notre site sœur. Lisez-le ensuite, parce que les pièges de ce côté de l'alphabet sont plus silencieux, et le silence, c'est exactement comme ça qu'ils gagnent.
Dernière mise à jour : 2026-09-08
Article associé : Décodage Base64 en JavaScript/Node.js : un guide complet