Base64-codering in JavaScript/Node.js: een complete gids
Je hebt data die tekst moet worden. Een bestand dat binnen een JSON-veld moet reizen, een afbeelding die in een CSS-bestand wil wonen, een geheim dat in een omgevingsvariabele gaat zitten, een token dat door een query string zal reizen. Het antwoord in JavaScript en Node.js is bijna altijd hetzelfde: Base64. Dit artikel is het verpakkingshandboek, van de eerste byte die je in handen hebt tot het moment waarop je gecodeerde tekenreeks de machine verlaat.
De startpagina van deze site legt het formaat in al haar details uit, het alfabet, de wiskunde, de padding, dus hier is het in één zin: elke drie bytes worden vier afdrukbare tekens, en daarom zal je uitvoer ongeveer 33 procent groter zijn dan de invoer. Houd dat bij de hand, want het is de reden waarom elke sectie van dit artikel bestaat, en het is het getal waarin je opslagfactuur wordt berekend.
Het geruststellende deel: je hoeft niets te installeren. Elke moderne browser levert btoa() en het nieuwere Uint8Array.toBase64() mee, en elke Node.js-versie die ertoe doet heeft de Buffer-klasse met een 'base64'-mode en, sinds versie 15.7.0, een eersteklas 'base64url'-mode. De kunst zit hem in het weten welke invoervorm je in handen hebt, welk alfabet de bestemming eist, en welke regelomwikkelregels de oude formaten nog steeds afdwingen.
Ken je invoer voordat je encodeert
Elke coderingsvraag begint met dezelfde: wat heb je eigenlijk in handen? Een JavaScript-tekenreeks is UTF-16-tekst, een Buffer is een byte-array, en de juiste call hangt af van welke van de twee je hebt:
| Wat je in handen hebt | Bel dit aan | Opmerkingen |
|---|---|---|
| Een tekenreeks met alleen ASCII (tekens onder 256) | btoa(string) |
Snelste weg in browsers en Node.js 16+, maar hij stopt bij het eerste teken dat niet in een byte past |
| Elke Unicode-tekenreeks | TextEncoder naar bytes, daarna een base64-call |
De UTF-8-brug; de enige veilige weg voor accenten en emoji's |
| Een Buffer of Uint8Array | buffer.toString('base64') of bytes.toBase64() |
Het werkpaard van Node.js, en de ES2026-methode in moderne browsers en Node.js 25+ |
Drie voorbeelden, één per regel van de tabel:
// Alleen ASCII-tekst: de legacy-shortcut (browsers en Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Elke tekst in Node.js: Buffer leest standaard UTF-8
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// Bytes die je al bezit
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (ES2026-runtimes)
Kijk naar het tweede voorbeeld: dezelfde tekst produceert een andere Base64-tekenreeks afhankelijk van de charset waarin je hem encodeert. Dat is geen bug - het is het hele spel. De Base64-laag encodeert bytes, en een tekenreeks wordt bytes pas zodra je een charset hebt gekozen, dus "codeer deze tekst" betekent in het geheim altijd "codeer de UTF-8-bytes van deze tekst" (of de Latin-1-bytes, als je dat zegt).
De Unicode-muur en de bruggen erover
btoa() is de oudste API in de kamer, en zijn contract is een uit de jaren 1990: elk teken van de invoer-tekenreeks moet in een enkele byte passen, codepunten tussen 0 en 255. Alles daarboven, een emoji, een Cyrillisch teken met accent, een Chinees teken, gooit een fout:
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // "Invalid character" in Node; de Latin1-bewoording in browsers
}
De oplossing is stoppen met denken in tekens en beginnen met denken in bytes. TextEncoder (een global in elke browser en in Node.js) zet de tekenreeks om naar zijn UTF-8-bytesequentie; je til die bytes omhoog naar een Latin-1-tekenreeks, en btoa() krijgt precies wat hij beloofde te behandelen:
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, dezelfde bytes
Je zult ook de oudere idioom in codebases tegenkomen, en die werkt onderin op dezelfde manier: btoa(unescape(encodeURIComponent(text))). De call encodeURIComponent produceert percent-gecodeerde UTF-8-bytes, en unescape zet de percent-escapes terug naar ruwe tekens. Zowel escape als unescape zijn legacy-functies, dus nieuwe code moet de TextEncoder-brug de voorkeur geven, maar als je de oude vorm overerft, weet je nu precies wat die doet in plaats van je schouders te ophalen.
In Node.js is de muur grotendeels een niet-probleem, want Buffer.from(text) gaat uit van UTF-8 en doet de byte-conversie voor je in dezelfde call. De brug telt het meest in de browser, waar btoa() de legacy-optie is en de UTF-8-stap aan jou is om expliciet te maken.
Bytes erin, letters eruit: Buffers, padding en smaken
Zodra je bytes hebt, is de coderingszijde van Node.js één methode: toString('base64'). Die regelt de groepswiskunde, de padding, alles, en geeft altijd canonieke uitvoer in de zin van RFC 4648, wat betekent dat de ongebruikte pad-bits van de laatste groep nul zijn:
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 bytes
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60 tekens, de 33-procents-belasting in actie
De padding aan het eind doet écht werk, het is geen sierraad. Een laatste groep met één overgebleven byte wordt twee Base64-tekens plus twee =, en een groep met twee overgebleven bytes wordt drie tekens plus één =. Of je uitvoer die padding mag meedragen hangt af van de bestemming, en dat is het verschil tussen de twee Base64-smaken die je dagelijks zult gebruiken:
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2026, padding incluis)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", Node gooit de padding weg in base64url-mode
Onthoud de verhouding als je dingen afmet: drie bytes erin, vier tekens eruit, dus 1 MB data wordt zo'n 1,33 MB tekst, en als je de tekst voor e-mail of PEM in regels wikkelt, voegen de regeleinden er een paar procent bovenop toe.
Bytes de draad laten oversteken
Het meest voorkomende draadprobleem in JavaScript is dat JSON geen bytes heeft. Het heeft tekenreeksen, en de tekenreeks die veilig kan reizen door elke JSON-parser, elke HTTP-proxy en elk logsysteem, is de Base64-variant. Het patroon is aan beide ends van de verbinding hetzelfde: encodeer aan de grens, decodeer aan de grens, houd bytes er tussen:
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, het bestand rijdt nu mee in gewone JSON
Weet wanneer je dit patroon moet bestrijden. Als je transport al binair ondersteunt, gebruik het dan: een multipart/form-data-upload stuurt het ruwe bestand zonder groottebelasting, een WebSocket-frame draagt ruwe bytes, en een Postgres bytea-kolom bewaart ze natief. Base64 op een plek waar ruwe bytes al toegestaan waren, is pure overhead, de 33-procents-belasting met niets om te tonen. Base64 verdient zijn kost wanneer het kanaal puur tekstueel is: JSON-API's, e-mail-bodies, omgevingsvariabelen, URL-query strings, en de vele bruggen (mobiele SDK's, desktop-apps, chatsystemen) die alleen tekst doorlaten.
Data URLs: plaatjes die in tekst wonen
De data URL, de data:image/png;base64,...-tekenreeks, is Base64 in een MIME-label, en het is de reden waarom je een hele afbeelding in één HTML-attribuut kunt stoppen. De RFC uit 1998 die het scheme definieerde zegt zelfs dat het "alleen nuttig is voor korte waarden", omdat vroege HTML een limiet van 1024 tekens had voor attributewaarden. Moderne browsers lachen om die limiet en renderen blijmoedig data URLs van megabyte-grootte, wat zowel een superkracht als een valkuil is.
In de browser doet de canvas-API het hele werk voor je, pixels erin, data URL eruit:
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"
En in elke runtime, waaronder Node.js, bouwen is gewoon tekenreeks-concatenatie met de metadata op de juiste plek: een data:-prefix, de media type, de optionele ;base64-marker, een komma, en de payload. Zonder de ;base64-marker wordt de payload in plaats daarvan verwacht als percent-gecodeerde tekst, en dat is de reden dat de marker bestaat:
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
De eerlijke trade-offs: een data URL is onderdeel van het document, dus is hij niet cachebaar als eigen resource, hij telt mee voor de grootte van de HTML of CSS waarin hij zit, en de DOM moet hem parsen en vasthouden. Voor een 20-kilobyte-icon is dat een koopje. Voor een hero-afbeelding van 4 megabyte, lever het bestand dan over via HTTP, waar caching en compressie beide werken, en houd de data URL voor de kleine dingen.
Bestanden die als tekenreeksen reizen
Bestand naar Base64 is een tweetapsdans die beide runtimes comprimeren tot een enkele call. In Node.js accepteert het bestandssysteem 'base64' als leescodering, en de schrijfzijde accepteert die ook:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // het bestand, zo'n 33 procent zwaarder
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
In de browser doet FileReader hetzelfde werk, met één wending: zijn dataleesmode reikt je een data URL aan, dus je snijdt de prefix af om de naakte Base64-payload te krijgen:
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); // het bestand, klaar voor een JSON-request
};
reader.readAsDataURL(fileInput.files[0]);
});
Als je in de browser de ruwe Base64 zonder de data-URL-prefix nodig hebt, slaat file.arrayBuffer() gevolgd door Uint8Array.toBase64() (op runtimes die hem hebben) de prefix helemaal over en is de schone weg voor upload-pipelines.
base64url: het alfabet dat URLs overleeft
Klassieke Base64 draagt twee tekens waar URLs allergisch aan zijn. De + wordt een spatie telkens wanneer een query string form-gedecodeerd wordt, de / is een scheidingsteken voor paden, en de =-padding lijkt op een toewijzing. De voor URL's en bestandsnamen veilige variant uit RFC 4648 sectie 5, base64url, wisselt de twee specials om met - en _ en gooit de padding weg wanneer de lengte uit de context bekend is. Het is het alfabet van JWTs, OAuth-tokens en deep links, en het verdient een eigen plek in je mentale gereedschapskist.
De Buffer van Node spreekt dit dialect sinds versie 15.7.0, en de coderingszijde is één argument:
const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"
Twee dingen om op te letten. De + werd een -, de / werd een _, en de padding verdween, want base64url-mode laat hem bij ontwerp weg. En de IETF is expliciet dat dit een andere codering is, niet dezelfde met een vermomming, dus als een specificatie "base64url" zegt, moet je base64url produceren, niet klassieke Base64 met een zoek-en-vervang. De ES2026-methode maakt dezelfde keuzes expliciete opties, en zijn omitPadding-flag geeft je de padding terug wanneer de context dat eist:
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", twee bytes hebben één pad-teken nodig
Op runtimes zonder beide, is de conversie een twee-tekens-wissel plus een padding-knip, en het is een van de meest copy-pasted snippetjes in de JavaScript-wereld:
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"
Gebruik base64url voor alles dat in een URL, een query string, een bestandsnaam of een token-standaard zal wonen. Gebruik klassieke Base64 voor MIME-bodies, data URLs en alles dat nooit een percent-decoder zal tegenkomen. Ze verwisselen is de meest voorkomende interop-bug in dit hele formaat.
Aanmeldgegevens verzegelen: Basic auth, JWTs en PKCE
Drie authenticatiehoeken van het web zijn gebouwd op Base64, en alle drie zijn goedkoop om één keer handmatig te bouwen, wat een goed ding is, want weten wat er onder de bibliotheek gebeurt is wat je rustig houdt wanneer de bibliotheek je verrast.
Eerst, HTTP Basic-authenticatie (RFC 7617): de client stuurt het scheme-woord Basic plus de Base64 van user-id:password. Eén regel, en één serieuze waarschuwing eraan gekoppeld:
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
Base64 is hier obfuscatie, niet veiligheid. Iedereen die de header kan lezen, kan het wachtwoord lezen, dus dit mechanisme is alleen aanvaardbaar over HTTPS, en zelfs dan is het een legacy-patroon: prefereer tokens. Ten tweede, de JWT: de eerste twee puntgescheiden delen zijn base64url van gewoon JSON, en het derde is de handtekening. Een HMAC-SHA256-token handmatig bouwen is een handvol regels van de ingebouwde crypto-module:
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 delen, doorlopend padding-vrije base64url
Let op de details die een token maken of breken: nergens padding (RFC 7515 laat hem weg), de handtekening wordt berekend over de letterlijke tekenreeks header + '.' + payload, niet over de geparste objecten, en het hele ding is slechts zo geheim als de key. In productie gebruik je een bibliotheek, jose (nul dependencies, browser en Node.js) of jsonwebtoken (Node.js), maar die draaien deze exacte calls onder in de motorkap. Ten derde, PKCE (RFC 7636), de extensie die publieke clients zoals SPAs en mobiele apps veilig inloggen laat: de client genereert een hoog-entropie code_verifier, publiceert BASE64URL(SHA256(verifier)) als de challenge, en bewijst bezit van de verifier bij de token-ruil. Willekeurigheid telt, dus de verifier komt uit de crypto-module, nooit uit 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, beide binnen het toegestane 43-128-bereik
Oude post heeft verpakking nodig: MIME- en PEM-pancer
Twee van de oudste Base64-formaten ter wereld dwingen nog steeds regelelengtes af, en beide zijn zo'n 30 jaar oud. MIME, de e-mailstandaard uit RFC 2045, wikkelt zijn Base64 af bij 76 tekens per regel en vereist dat regels eindigen met CRLF, een relict van de 8-bit-clean SMTP-dagen wanneer zeer lange regels echte mail-servers kapot maakten. RFC 7468, die de PEM-regels voor certificaten en keys neerschrijft, is nog strenger: generatoren moeten afwikken op exact 64 tekens per regel, de laatste regel korter, geframed door -----BEGIN- en -----END-pancerregels die de inhoud benoemen.
De wikkeling zelf is een one-liner, en het pancer is een sjabloon:
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 tekens
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-----
Twee praktische notities. Als je MIME of PEM produceert, wikkel hem, want strenge consumenten (mail gateways, OpenSSL-tijdperk tools, Java key stores) zullen een 4000-tekens one-line Base64-blob afwijzen. Als je hem consumeert, heb je dat meestal niet nodig, want de decoder van Node slaat witte ruimtes over, dus Buffer.from behandelt de regeleinden voor je - maar de pancerregels zelf zijn geen Base64, dus strip de -----BEGIN/-----END-regels (of match alleen de body) voordat je decodeert: Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64'). Die asymmetrie is een geschenk, maar het betekent niet dat je de pancer-stripstap kunt overslaan wanneer de Base64 ergens heen gaat dat niets overslaat, zoals een DER-parser.
Waar gecodeerde data woont: env, config en databases
Base64 is ook een opslagformaat, wat zowel handig is als gevaarlijk gemakkelijk te verwarren met veiligheid. Omgevingsvariabelen zijn de klassieke thuisbasis: een aantal secret-managers en CI-systemen reiken je Base64-gecodeerde waarden aan, en de decode is een one-liner:
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
Spreek de belangrijke zin hardop uit: codering is geen versleuteling. Een Base64-"geheim" in een omgevingsvariabele, een .env-bestand of een Kubernetes-secret (k8s bewaart zijn secrets als Base64 in de API en in etcd, en de documentatie herhaalt het constant) is leesbaar door iedereen die de procesomgeving, het bestand of het cluster kan lezen. Gebruik Base64 daar omdat het transport (shell, YAML, JSON) puur tekstueel is, nooit omdat je gelooft dat het iets verbergt.
In databases is Base64 de standaardbrug voor binair binnen JSON-documentopslag, want een jsonb-kolom of een MongoDB-document heeft geen eigen byte-type:
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=="}
Bewaar de media type naast de payload, zoals het voorbeeld doet, en je zult jezelf over een jaar danken wanneer iemand vraagt wat de bytes zijn. Als je database een native binair type heeft (Postgres bytea is het referentievoorbeeld), prefereer die dan: de bytes kosten niets extra, en je slaat de 33-procents-belasting voor eeuwig over.
Streams coderen zonder groepen te splitsen
Base64 werkt in groepen van drie bytes, dus een encoder die willekeurige chunks ontvangt moet zijn rest meevoeren: één of twee bytes die nog geen groep kunnen vormen moeten wachten op de volgende chunk voordat ze gecodeerd kunnen worden. Doe de wiskunde per chunk en geef alleen complete groepen uit, en de uitvoer is byte-identiek aan het coderen van de hele stream in één keer:
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"; identiek aan één grote toString('base64')
});
encoder.end(Buffer.from('hello world, this is a stream!'));
De flush-callback is het detail dat iedereen vergeet: de laatste één of twee bytes, degenen die in een gewone chunk nooit een partner vonden, krijgen hun padding en worden aan het eind eruit geduwd. Dezelfde carry-logica is wat je op de decodersijde zult spiegelen, behalve dat je daar de ES2026-API hem gratis geeft: setFromBase64() met "stop-before-partial" stopt exact op groepsgrenzen en vertelt je hoeveel tekens hij verbruikte.
Grote bestanden en de geheugenrekening
Base64 is gul met ruimte, dus grote bestanden hebben een strategie nodig. Een bestand van 1 GB wordt zo'n 1,33 GB Base64-tekst, en een JavaScript-tekenreeks bewaart UTF-16, twee heap-bytes per teken, dus alleen die tekst wil al ruwweg 2,7 GB geheugen, nog vóórdat je Buffer aankomt. Het plafond is expliciet in Node: buffer.constants.MAX_STRING_LENGTH is 536870888 tekens, net onder de 512 MiB tekst, wat decodeert naar zo'n 400 MB bytes. Daarboven is een enkele tekenreeks geen optie, en streaming is het enige spel in de stad:
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');
});
Het patroon is de stream-encoder van de vorige sectie, vlakgeleegd: lees in chunks van 64 KiB, voer de rest van 1 tot 2 bytes mee, geef complete groepen uit, flush de staart. De geheugenvoetafdruk blijft rond één chunk plus één rest, wat het bestand ook weegt. En als de ontvangende kant binair kan accepteren, vraag jezelf dan af waarom je de belasting überhaupt betaalt.
One-liners voor de terminal
Node doet dienst als een command-line Base64-encoder, wat handig is als je een config-waarde verpakt, een API debugt, of een klein bestand tussen machines door een chatbericht heen verplaatst:
# Een bestand coderen naar klassieke Base64 op stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# De URL-veilige variant, padding weg
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# Lezen vanaf stdin, waar pipes voor zijn
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")))'
Geen van de drie voegt een eigen afsluitende regeleinde toe, wat de uitvoer schoon houdt voor copy-paste en voor $(...)-substitutie in shell-scripts. Als je een mooi afgedrukt bestand met gewikkelde regels wilt, pipe het resultaat dan naar je editor naar keuze, of voeg een \n toe aan het einde van de one-liner.
Valkuilen die developers uren kosten
Elk van deze heeft een échte namiddag gekost in een échte codebase:
- De Unicode-muur:
btoa('héllo ⛳')gooitInvalidCharacterError, want de golfvlag past niet in een byte. De oplossing is de UTF-8-brug: eerstTextEncodernaar bytes, daarna encoderen. In Node.js sla je het probleem helemaal over metBuffer.from(text), die uitgaat van UTF-8. - De legacy-idioom: oude code vol
btoa(unescape(encodeURIComponent(x)))werkt, maarescapeenunescapezijn afgeschreven legacy-functies. Wanneer je die code refactort, vervang hem dan door deTextEncoder-brug en het gedrag blijft identiek. - Het ontbrekende coderingsargument, omgekeerd: de valkuil aan de decodersijde is
Buffer.from(str)zonder de 'base64'-mode; de tegenhanger aan de encodersijde is aannemen datBuffer.from(someString)iets bijzonders doet met Base64. Dat doet het niet. Zonder expliciete codering bouwt het een Buffer uit de UTF-8-bytes van de tekenreeks, en je "gecodeerde" uitvoer is de Base64 van de letterbytes van de tekenreeks, wat bijna nooit gewenst was. Wees in beide richtingen expliciet. - De padding-mismatch: JWTs en de meeste token-standaarden willen base64url zonder padding, MIME wil klassieke Base64 mét padding, en de twee zijn makkelijk te verwisselen. Een opgepadde
=binnen een JWT-deel breekt strenge verificatoren; een ontbrekende padding waar de lengte onbekend is, breekt lui decoders. Match de standaard, niet je gewoonte. - De niet-canonieke staart: RFC 4648 vereist dat de ongebruikte pad-bits van de laatste groep nul zijn. De ingebouwde encoders produceren allemaal canonieke uitvoer, maar een handgemaakte encoder die bits handmatig verschuift kan rommel in die bits achterlaten, en een strenge decoder wijst dan je payload af zonder merkbare reden. Als je je eigen encoder schrijft, test dan tegen de testvectoren van RFC 4648, niet alleen tegen je eigen data.
- De vergeten wikkeling: MIME wil regels van 76 tekens en PEM wil 64, en strenge consumenten (mail gateways, Java key-tooling) wijzen een one-line-blob af. Het omgekeerde is zeldzamer maar écht: sommige parsers zijn regel-georiënteerd, en een ontbrekend CRLF aan het einde van een PEM-bestand heeft meer builds gebroken dan enige bug in de Base64 zelf.
- De veiligheidsillusie: Base64 in een omgevingsvariabele, een
.env-bestand of een Kubernetes-secret is geen versleuteling. Het decodeert met één regel code, in elke taal, door iedereen die het bestand of het cluster kan lezen. Behandel het als een transportvermomming en laat de échte controles (rechten, TLS, key-rotatie) het beschermen. - De JSON-bloat: Base64 binnen JSON kost 33 procent plus escaping, en een upload van 5 MB wordt een tekenreeks van 6,7 MB die je JSON-parser naar het geheugen moet kopiëren. Voor alles wat bestandsformaat is over HTTP, is
multipart/form-dataof een ruwe binaire body het betere transport, en Base64 is voor wanneer het kanaal puur tekstueel is. - De heap-rekening: een gecodeerde tekenreeks is UTF-16 in de JavaScript-heap, twee bytes per teken, en de gedecodeerde of bron-Buffer is een tweede kopie van de data. Een bestand van 100 MB betekent kortstondig zo'n 270 MB tekenreeks plus 100 MB Buffer. Stream de grote, en houd de gecodeerde vorm zo kort mogelijk aangesproken.
- De legacy-globals in Node: de eigen documentatie van Node markeert
btoa()enatob()als Stability 3, Legacy, en zegt je om in plaats daarvanBufferte gebruiken. In een browser isbtoa()een prima gereedschap voor ASCII-tekst; in Node.js, grijp naar de Buffer en laat de globals aan de polyfill-achtige code die ze nodig heeft.
Hoe JavaScript leerde bytes te verpakken
De browserszijde is een lang, stil verloop. btoa() werd in het begin van 2011 gespecificeerd in het HTML5-ontwerp, en zit sinds het midden van de 2000s in elke grote browser, in gedrag ongewijzigd, met zijn contract van één byte per teken en zijn uitvoer die altijd padding draagt. Dat contract is ouder dan typed arrays - binaire tekenreeksen waren de enige manier om bytes te vervoeren vóór 2009 - en daarom denkt btoa() nog steeds in "binaire tekenreeksen". Het moderne deel van het verhaal is heel recent: het TC39-voorstel dat native Base64 aan typed arrays toevoegde (samen met hex) werd gestandaardiseerd als onderdeel van ES2026, en het landde in Firefox 133 en Safari 18.2 in 2024, in Chrome 140 op 2 september 2025, en werd daarna verklaard als Baseline Newly available. Bun leverde dezelfde methoden in versie 1.1.22 in augustus 2024.
Node.js verpakte bytes op een andere klok. De Buffer-klasse werd een global in versie 0.1.103, in de zomer van 2010, bijna vijf jaar vóór Node 1.0, en toString('base64') was de encoder naar keuze al meer dan een decennium, met de alfabet-excentriciteiten van dat tijdperk (hij accepteerde al de URL-veilige tekens bij het decoderen, een tweetalige gewoonte die de specificatie nooit vroeg om). Versie 15.7.0 in januari 2021 voegde de 'base64url'-mode toe als eersteklas coderingsnaam, Node 16 in datzelfde jaar voegde de browser-globals btoa()/atob() toe (onmiddellijk gemarkeerd als Legacy), en Node 22 in 2024 leverde verdere V8- en base64-prestatiewerk uit. Toen kwam Node 25, uitgebracht op 15 oktober 2025, die V8 verhoogde naar 14.1 en de ES2026-methoden, toBase64() met zijn omitPadding-optie en setFromBase64() voor de andere richting, in de runtime bracht. Voor runtimes die niet bij kunnen houden, levert core-js polyfills (features/typed-array/to-base64 / from-base64), en het kleine base64-js-pakket (drie functies, nul dependencies) heeft het ecosysteem jarenlang stilletjes gedragen als transitive dependency.
Het formaat dat ze bedienen is ouder dan het hele. Het alfabet werd voor het eerst gestandaardiseerd voor Privacy-Enhanced Mail in 1987 (RFC 989), de herziening van 1993 (RFC 1421) behield het, en MIME nam het enkele maanden later datzelfde jaar op met zijn wikkeling van 76 tekens; RFC 3548 voegde de Base-N-familie in 2003 samen en voegde de URL-veilige variant toe, die RFC 4648 in 2006 opnieuw uitbracht. Een decennium later maakten RFC 7515 en 7519 van padding-vrije base64url de ruggengraat van elke JWT, en RFC 7636 stopte het in de PKCE-flow van OAuth. De encoders in dit artikel zijn de laatste mijl van een formaat dat dertig jaar oud is en nog steeds passagiers verzamelt.
De moeite waard om te weten op een feestje
btoa('GIF89a')geeft"R0lGODlh"terug, de hele magische header van een GIF in acht tekens. Het is de kleinste "hallo" die een binair bestand in Base64 kan zeggen, en het is niet toevallig het eerste Web-API-voorbeeld in het Wikipedia-artikel.toBase64()heeft eenomitPadding-optie diebtoa()nooit kon hebben, want het Web-API-contract padt onvoorwaardelijk. Twee decennia hetzelfde alfabet, en de nieuwere API kan één ding doen dat de oudere nooit mocht.- Eén alfabet, twee officiële regelelengtes: MIME wikkelt af bij 76, PEM bij 64. Dezelfde 64 tekens, dezelfde padding, twee verschillende 30-jarige meningen over hoe breed een regel tekst mag zijn.
- Het getal 33 procent is exact: vier tekens per drie bytes is een 4/3-verhouding, en RFC-tijd e-mail voegde ruwweg nog eens 3,5 procent toe voor de regeleinden. Je "kleine" config-tekenreeks is zonder reden 37 procent voller.
- Het kleine
base64-js-pakket trekt ruim 100 miljoen downloads per week op npm, bijna allemaal verstopt in de dependency-bomen van andere pakketten. Base64 is het meest gesmokkelde code in het JavaScript-ecosysteem. - Kleine Buffers worden niet één voor één gereserveerd: Node snijdt ze uit een gedeelde pool van 65536 bytes (
Buffer.poolSize), en dat is waarom het aanmaken van Buffers snel is, en waarom de "unsafe"-allocatievarianten bestaan voor de gevallen waarin de data van de vorige bewoner niet uitmaakt. - De RFC die data URLs in 1998 definieerde waarschuwt dat ze "alleen nuttig zijn voor korte waarden", met vermelding van een HTML-attributelimiet van 1024 tekens. Moderne browsers embedden afbeeldingen van megabyte-grootte als data URLs in dezelfde attributen, wat óf vooruitgang óf grootspraak is, afhankelijk van je hero-afbeelding.
- Unix-wachtwoordhashes gebruiken hun eigen Base64-achtige alfabetten, zonder padding, en tot verwarring verschilt de volgorde per scheme: klassieke
crypt(3)-hashes gebruiken./0-9A-Za-z, terwijl de$2b$-bcrypt-strings die JavaScript-projecten voor gebruikerswachtwoorden bewaren dezelfde 64 tekens in plaats daarvan in./A-Za-z0-9schudden. Het is een goede herinnering dat "Base64" in een veiligheidscontext een familie is, niet een enkel formaat. - De decoder van Node accepteert
-,_,+en/in zowel'base64'- als'base64url'-mode, vier tekens, één tabel. De encoder spreekt uiteraard alleen het dialect dat je vroeg.
De helft van een ronde reis
Base64 coderen in JavaScript en Node.js komt neer op drie beslissingen: welke bytes heb je in handen (een tekenreeks heeft een charset nodig, een Buffer niet), welk alfabet eist de bestemming (klassiek voor MIME en data URLs, base64url voor tokens en URLs, padding optioneel per context), en welke regeleisen dwingt het formaat nog af (76 voor e-mail, 64 voor PEM, geen enkele voor JSON). Beantwoord die en de ingebouwden doen de rest: Buffer.toString() in Node, btoa() plus de UTF-8-brug in de browser, en Uint8Array.toBase64() in de moderne runtimes die er eindelijk één kregen.
En elk pakket dat je hier verzegelt, zal iemand anders ooit openen. De decodersijde heeft zijn eigen set valkuilen: de vergevingsgezinde decoder die rommel opslorpt zonder geluid, de binary-string-vermomming die atob() je aanreikt, de charset-beslissingen die aan de lezerskant van de muur gebeuren, en de streaminglogica die het carry-patroon spiegelt dat je zojuist leerde. Dat verhaal, met codevoorbeelden voor elke stap, wordt in diepte behandeld in het gerelateerde Base64-decoderingsartikel op onze zustersite. Lees het als volgende, want de valkuilen aan die kant van het alfabet zijn stiller, en stiller is precies hoe ze winnen.
Laatst bijgewerkt: 2026-10-06
Gerelateerd artikel: Base64-decodering in JavaScript/Node.js: een complete gids