Base64 Decoding in JavaScript/Node.js: A Complete Guide
Your application receives a Base64 string. It might be the Authorization header of an incoming request, a field inside a JSON payload, an image hiding in a data URL, or a certificate pasted into a config file. All of them are the same thing: raw bytes wearing an ASCII costume. This article is about taking that costume off in JavaScript and Node.js, and about doing it without losing a single byte along the way.
A quick word on the format itself: Base64 is a text encoding that maps every three input bytes onto four printable characters. The home page of this site explains the alphabet, the math and the padding in full detail, so here it stays one sentence long. One consequence is worth keeping in your pocket: encoded data is about 33 percent bigger than the bytes it carries, which means decoding is a shrinking operation, and nothing in this article adds or removes any secrecy. You are unpacking, not unsealing.
The good news: you install nothing. Browsers have shipped atob() for two decades, Node.js has the Buffer class with a built-in base64 mode, and modern runtimes now ship Uint8Array.fromBase64(), a strict and configurable newcomer from the ES2027 specification. The craft is in picking the right tool for the job, and in knowing exactly what each tool forgives, because on a server you decode data from strangers, and forgiveness is where things go sideways.
Choosing A Decoder
Three APIs cover the great majority of decoding work. They differ in temperament, and that difference is the whole story:
| Decoder | Available in | Temperament |
|---|---|---|
Buffer.from(string, 'base64') |
Node.js (every version that matters) | Lenient: skips unknown characters, stops at the first =, never throws |
atob(string) |
All browsers, Node.js 16 and up | Strict: throws InvalidCharacterError on bad input, skips ASCII whitespace, forgives missing padding |
Uint8Array.fromBase64(string) |
Chrome 140+, Firefox 133+, Safari 18.2+, Node.js 25+ | Configurable: you pick the alphabet and how strict the final chunk must be |
All three open the same classic payload the same way:
// The Node.js workhorse
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// The legacy pair (every browser, Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world", as a binary string
// The modern ES2027 method (Chrome 140+, Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"
One warning before you lean on atob(): it returns a string, but a binary string, a string where each character carries one raw byte as a code point from 0 to 255. Printing one is fine. Storing it in JSON, a database or a cookie ships every invisible null byte along for the ride, so convert it into real bytes or real text immediately after decoding.
The Lenient Decoder And What It Swallows
Node's Buffer is a forgiving reader, and that is a double-edged sword. It is wonderful for data that traveled rough roads: MIME email with its line breaks, strings copied by hand, log output with stray spaces. It is dangerous for data you did not produce yourself, because it never complains. Here is what actually happens:
| Input | What Buffer.from(input, 'base64') does |
|---|---|
'!!!' |
Returns an empty Buffer. All garbage is skipped, nothing decodes, no error. |
'aGVsbG8== garbage' |
Returns "hello". The first = ends decoding; the rest is ignored. |
'aG!VsbG8' |
Returns "hello". The bang is skipped, not an error. |
'aGVs=bG8' |
Returns "hel". A mid-string = stops the show early. |
'aGVsbG8====' |
Returns "hello". Extra trailing padding is ignored. |
'=aGVsbG8' |
Returns an empty Buffer. Padding before the data means nothing. |
The fix for untrusted input is a validator, and Base64's grammar is small enough to fit in one regular expression:
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"
}
The regex checks the shape: groups of four with correct padding. One rule it cannot check is the canonical-encoding rule from RFC 4648, which says the unused pad bits of the final group must be zero. The strict mode of Uint8Array.fromBase64() does check it, so on Node.js 25 or any modern browser you can skip the regex entirely and let the platform do the audit:
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello", loose mode forgives missing padding
try {
Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError", the pad bits are not zero
}
That strict mode has three settings worth knowing. "loose" (the default) skips whitespace, accepts missing padding and ignores leftover pad bits. "strict" demands a complete padded final group with all pad bits set to zero. And "stop-before-partial" decodes only complete four-character groups and leaves the trailing fragment for you to carry over, which is the piece that makes streaming decoding pleasant, as you will see later in this article.
From Bytes To Text: The Charset Decision
Decoding Base64 hands you bytes. Bytes become text only when you choose a charset, and that choice is yours to make, usually based on what the sender promised. Node's default is the one you want most of the time:
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // the two bytes C3 A9
console.log(bytes.toString('utf8')); // "é", the two bytes join into one character
console.log(bytes.toString('latin1')); // "é", the same bytes read one character at a time
There is one wrinkle with UTF-8: when a byte sequence is not valid UTF-8, Node does not throw. It substitutes the Unicode replacement character (U+FFFD, the diamond with a question mark) and moves on, which means a corrupted payload can sail through your pipeline into your database. The platform's real text decoder, TextDecoder (a global in Node.js and every browser), has a fatal option that turns corruption into a TypeError you can catch:
const stray = new Uint8Array([0xe9]); // one lone byte, not valid UTF-8
console.log(new TextDecoder().decode(stray)); // the replacement character, no error
try {
new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
console.log(error.name); // "TypeError"
}
Legacy systems never die, and TextDecoder still knows how to read them. It accepts the full label table of the WHATWG Encoding Standard, so a Base64 payload from a 1990s Windows app, a Japanese mainframe or an old FTP mirror can still be decoded with labels like 'windows-1250', 'shift_jis', 'euc-kr' or 'gb18030', all case-insensitive. One label deserves a warning, because it has cost real debugging time: the spec aliases 'iso-8859-1', 'latin1' and even 'us-ascii' to the Windows-1252 decoder. Byte 0x80, a control character in true Latin-1, comes out as the euro sign:
console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€", not the Latin-1 you asked for
// For a true byte-for-byte Latin-1 read, use the Buffer side:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // the raw 0x80 control character
If you truly need that raw mapping, Buffer's 'latin1' encoding (whose legacy alias 'binary' is, in the words of the Node documentation, a very misleading name) maps byte N to code point N without the Windows detour. For everything modern, UTF-8 plus fatal: true is the safe pair.
Cracking Open A JWT
The single most common Base64 payload a JavaScript service decodes is a JSON Web Token, the xxxxx.yyyyy.zzzzz string riding in the Authorization header of half the web. Per RFC 7515, a compact JWS is three dot-separated parts, and the first two are JSON objects encoded with base64url without padding. Reading them in Node.js takes no ceremony, because the base64url mode is a first-class encoding:
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' }
Said often enough, but worth repeating: decoding is not verification. The header and payload are dressed up, not encrypted, and anyone holding the token can read both. The part you must check is the third one, the signature. For a classic HMAC-SHA256 token the whole check is a few lines of the built-in crypto module, and the only subtle part is comparing with timingSafeEqual so an attacker cannot time your byte-by-byte comparison:
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
In a real service you will usually not hand-roll this. The jose package (zero dependencies, runs in Node.js, browsers and edge runtimes) and the long-standing jsonwebtoken package (Node.js) wrap the dance, handle the RSA and ECDSA algorithm families, and enforce the exp, aud and iss claims. Whatever library you pick, the Base64 plumbing underneath is the same two calls you just saw.
HTTP: Headers, Query Strings And Cookies
Three corners of the wire are full of Base64. The oldest is HTTP Basic authentication, defined in RFC 7617: the client sends Authorization: Basic plus the Base64 of user-id:password. On the server it is one slice and one decode, with the small protocol detail that only the first colon separates the username from the password, so a password may legally contain more colons while a username may not:
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"
And remember what Basic authentication actually is: obfuscation, not security. The credentials cross the wire in a costume, which is why the scheme is only acceptable over HTTPS. The second corner is the query string, and it hides the nastiest landmine in Base64 land:
const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=", the plus became a space
Your Base64 did not corrupt itself. The URL layer did it politely on behalf of the form-encoding rules, which treat + as a space. That is precisely why tokens that live in query strings use the URL-safe alphabet, covered in the next section. The third corner is the cookie: cookies are ASCII-only, so any non-ASCII value stored in one is almost certainly Base64, and the old pattern of Base64-ing a JSON blob into a cookie is alive in a surprising number of production systems. The decode is the same one you already know; just validate the shape first, because a cookie is the kind of place where a user, or a browser extension, can hand you garbage.
Files, Images And Data URLs
Node's filesystem speaks Base64 directly, so a whole file can cross a JSON boundary in one line:
const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // the file, about 33 percent heavier
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);
The other file-shaped payload is the data URL, the data:image/png;base64,... string that front ends love for inline images. The recipe in any runtime is the same: cut at the first comma, parse the metadata before it, decode the rest. Here is a real one-pixel PNG coming back to life:
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", the PNG signature
Checking the signature is a cheap habit. The first eight bytes of a PNG are always 89 50 4E 47 0D 0A 1A 0A, and a JPEG starts with FF D8 FF. If a "base64 image" from a client does not start with the magic bytes it promised, you now know before you do anything expensive with it.
URL-Safe Base64: The Alphabet For Tokens
Classic Base64 uses + and / as its two special characters (RFC 4648, section 4), and both of them are trouble in URLs: + turns into a space during form decoding, and / is a path separator. The URL and filename safe variant from section 5, which everyone calls base64url, swaps them for - and _, and may drop the trailing = padding entirely when the length is known from context. That is exactly the combination JWTs, OAuth tokens and deep links need, so base64url is the alphabet you will meet most often in the wild.
Node's Buffer makes the whole thing a non-event. Both the 'base64' and the 'base64url' decoding modes accept all four special characters and map them to the same values, so a JWT part, an OAuth token and a classic Base64 blob all decode without any character-swapping ceremony:
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", the very same six bytes
The ES2027 API is pickier on purpose, and it gives you the same flexibility with an explicit dial. The alphabet option selects between "base64" (the default, + and /) and "base64url" (- and _), and feeding a character from the wrong alphabet is a SyntaxError, not a silent cross-alphabet decode:
console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
Uint8Array.fromBase64('aGVs-bG8'); // the default alphabet is the classic one
} catch (error) {
console.log(error.name); // "SyntaxError", the dash is not a classic character
}
In a browser that does not have the new methods yet, the detour is a small swap before handing the string to atob(), which only knows the classic alphabet. You also have to restore the padding if the sender dropped it, which is the norm for token-style payloads:
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"
Base64 In The Wild: Where Payloads Hide
Base64 is the postal service for bytes in the JavaScript world. A tour of where it shows up, with the decode recipe for each stop:
- JSON API fields, the most common carrier by far: avatars, thumbnails, generated documents and uploads arrive as Base64 strings inside ordinary JSON, because JSON has no word for "these are bytes". Decode the field before you do anything else with it.
- Environment variables and config files: several secret managers, CI systems and the npm CLI itself hand you Base64 blobs (the npm registry token in
.npmrcis the Base64 ofuser:token). Decode once at startup, and keep the plaintext in memory only as long as you need it. - Kubernetes and cluster tooling: k8s secrets are famously Base64-encoded in the API and in
etcd, and the official docs keep repeating that it is encoding, not encryption. Your decode code should treat the result as a secret, not as proof of safety. - Databases: anything binary stored in a JSON column (Postgres
jsonb, MongoDB documents, Redis) is frequently a Base64 string. Decode it on the read path into a Buffer or a Uint8Array, and let the database stay text-only. - Email: MIME Base64 with its 76-character line wrap is how attachments and binary headers cross SMTP, a protocol that was originally 7-bit only. Node's decoder skips the line breaks for you, so the whole body decodes in one call with no cleanup.
- CI and CD pipelines: build systems and secret injectors pass tokens as Base64 environment values; decode in the pipeline script, and never echo the decoded value into a log.
- Directory and SAML data: LDIF files store binary attributes (think: certificates) as Base64, and SAML responses are often deflated and then Base64-encoded before crossing an HTTP boundary.
- Worker threads and edge runtimes: Base64 strings cross the
worker_threadsboundary as plain structured-cloneable strings, so a heavy decode can live on a worker while the main thread's event loop stays free.
Two of those stops deserve a closer look, because they show up in both interviews and production:
const { Buffer } = require('node:buffer');
// Environment variable: the secret arrives Base64-encoded
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// JSON API field: unpack before doing anything else
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", the PNG signature again
// MIME email: the line breaks are skipped, no cleanup needed
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"
The anti-pattern to spot on this tour is the same everywhere: Base64 in a place where raw bytes were already allowed. A WebSocket frame, a file stream, a Postgres bytea column, all of them carry bytes natively, so a Base64 round-trip there is pure overhead, the 33 percent size tax with no benefit to show for it. When a native binary path exists, take it.
Decoding In Pieces: Streams And Big Data
Base64 groups of four characters encode three bytes, so a stream of chunks can split a group in half. The naive approach, decode every chunk and pray, corrupts the output at random boundaries. The ES2027 API was designed for exactly this: setFromBase64() writes into a pre-allocated array and reports how many input characters it consumed, and the "stop-before-partial" mode makes it stop at the last complete group, leaving the fragment for the next chunk. The pattern mirrors the TextDecoder stream API:
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"
On runtimes without the new methods (and Node's LTS line did not have them for a while), the same loop works with a small userland decoder that tracks a partial group, or you simply buffer the incoming chunks until you can split on group boundaries. The important idea is the carry: never decode a fragment on its own.
Big payloads bring two more limits to your attention. First, the string itself: Node's buffer.constants.MAX_STRING_LENGTH is 536870888 characters, roughly 512 MiB of text, which decodes to about 400 MB of bytes. A "base64 file" bigger than that needs a streaming approach, not a single readFileSync. Second, memory: the encoded string lives in the JavaScript heap as UTF-16, two bytes per character, and the decoded Buffer is a second copy of the data. For large payloads you briefly hold both, so keep the encoded form alive for as short a time as the code allows, and prefer streams for anything file-sized.
From The Terminal
Node doubles as a perfectly decent command-line Base64 decoder, which is handy when you are debugging a request or inspecting a config value:
# Decode a classic Base64 string passed as an argument
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# The URL-safe variant, padding optional
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# Decode from stdin, what pipes are for
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")))'
All three print hello world. If you also have the classic base64 command from coreutils on the machine, the same one-liner works with base64 -d, but the Node versions know about base64url, which the traditional tool does not.
Pitfalls With A JavaScript Accent
Every one of these has been someone's lost afternoon in JavaScript or Node.js:
- The silent decoder:
Buffer.from('!!!', 'base64')returns an empty Buffer, not an error. Half-corrupted input decodes to half-corrupted data with no warning. Validate untrusted input with the strict regex (or the strictfromBase64mode), and treat an empty Buffer from a non-empty string as a red flag. - The missing encoding argument:
Buffer.from('aGVsbG8=')with no second argument does not decode anything. It builds a Buffer from the UTF-8 bytes of those letters, and your "decoded" data is a string of hex-looking garbage. The'base64'argument is the whole trick. - The binary-string costume: the output of
atob()is not text until you say it is. Stuffing it into a JSON response, a cookie or a log line "works", and it also preserves every null byte, which surprises log shippers and serializers in equal measure. Convert withcharCodeAt()into a Uint8Array, or into UTF-8 text, right away. - The plus in the query string: a
+in a form-decoded query value is a space by the timeURLSearchParamshands it to you. Prefer base64url for anything that lives in a URL, and never paste a classic Base64 token into a query string unescaped. - The replacement character: invalid UTF-8 becomes a silent diamond-question-mark in Buffer's UTF-8 mode instead of an error, so a corrupted payload can pass your pipeline and land in a database. Turn on
fatal: truewithTextDecoderwhere corruption should be a loud failure. - The Windows detour: asking
TextDecoderfor'iso-8859-1'or'latin1'gives you the Windows-1252 decoder, where byte 0x80 becomes the euro sign. For true byte-for-byte Latin-1, read the Buffer withtoString('latin1')instead. And remember that'binary'is just a misleading alias for the same Latin-1 mapping. - The size ceilings:
buffer.constants.MAX_LENGTHis 9007199254740991 bytes (2 to the 53, minus one) on 64-bit systems, but the string that carries the Base64 cannot grow pastMAX_STRING_LENGTHof 536870888 characters. A single string can therefore carry only a little over 400 MB of decoded data; beyond that, stream it. - The memory bill: a Base64 string costs two heap bytes per character (UTF-16), and the decoded Buffer is a full second copy. A 100 MB file becomes roughly 133 MB of string plus 100 MB of Buffer, briefly, in your process. Shrink the window in which the encoded form stays referenced.
- The strict-remote, lenient-local mismatch: your Node decoder forgives what a strict decoder elsewhere rejects (a Python script, a Go service, a mobile app). If one side of your system is strict and the other forgiving, the bug only appears on certain payload lengths, which is the worst kind of bug. Agree on strictness at the protocol level, not in your head.
How JavaScript Grew Its Decoders
The browser side has a long, boring, dependable history. atob() and btoa() were specified in the HTML5 draft back in the late 2000s, and they have sat in every major browser ever since, unchanged in behavior for over a decade. They predate typed arrays by a decade, which is why they speak in "binary strings" instead of bytes.
Node.js grew its decoder on a different timeline. The Buffer class became a global in version 0.1.103, in the summer of 2010, a full five years before Node 1.0, and it carried the 'base64' mode from the start. For most of Node's life that was the only decoder in town. Then the web-standard wave arrived: Node 16 in 2021 added atob() and btoa() as globals so that code written for the browser would run on the server without a polyfill, and marked both as Legacy from day one. Node 25, released on October 15, 2025, upgraded V8 to 14.1 and brought the ES2027 methods, Uint8Array.fromBase64(), setFromBase64() and their hex siblings, into the runtime. Along the way, the old new Buffer() constructor was deprecated (Node 10 started the warnings in 2018) in favor of Buffer.from(), alloc() and allocUnsafe(), partly because an uninitialized allocation could leak whatever memory sat there before.
In the browsers the same wave landed slightly earlier: Firefox 133 and Safari 18.2 shipped the new methods in 2024, and Chrome 140 (stable on September 2, 2025) completed the set, at which point the feature was declared Baseline Newly available by the web platform community. Bun, the all-in-one JavaScript runtime, got them in version 1.2 in January 2025. And if you cannot require a recent runtime, the core-js and es-shims packages ship polyfills for all of it, which is also the route most frameworks take internally.
The format they serve has an even older genealogy. The alphabet was first standardized for Privacy-Enhanced Mail in 1993, MIME picked it up a year later with its 76-character line wrap (RFC 2045), RFC 3548 in 2003 consolidated base16, base32 and base64 into one document, and RFC 4648 in 2006 reissued it with the URL-safe alphabet that would, a decade later, end up in every JWT. The URL-safe variant is a nice piece of trivia: it was proposed in a 2001 mailing-list post about peer-to-peer identifiers before it ever met a token.
Fun Facts For Your Next Standup
- The WebSocket RFC's example key,
dGhlIHNhbXBsZSBub25jZQ==, decodes to the words "the sample nonce". The standards committee hid a wink inside its own example, and Node'satob()opens the joke in one call. Buffer.from('!!!', 'base64')returns a Buffer of length zero. A real allocation with nothing inside it. Nothing. It is the closest Node comes to a shrug.- Node's Base64 decoders are bilingual in a way the spec never asked for:
+,-,/and_are all welcome in both'base64'and'base64url'mode, each pair mapping to the same value. - The Node documentation for
atob()contains the phrase "Use Buffer.from(data, 'base64') instead". A runtime telling you to stop using one of its own globals, complete with an official codemod (npx codemod@latest @nodejs/buffer-atob-btoa) to do the migration for you. - Small Buffers are carved out of a shared slab:
Buffer.poolSizeis 65536 bytes, and every small allocation reuses chunks of that pool. It is why Buffer creation is fast, and why "unsafe" allocation is a phrase you should know the meaning of. - The little
base64-jspackage, three functions and zero dependencies, pulls in well over 100 million downloads a week on npm, almost all of it as a hidden dependency inside other packages. Base64 is the most smuggled code in the ecosystem. Uint8Array.fromBase64()has a mode called"stop-before-partial"that exists purely so you can decode a stream without ever splitting a four-character group. A mode named after the thing it refuses to do is a rare piece of API poetry.- The Unix password world uses its own Base64-flavored alphabet,
./0-9A-Za-z, with no padding. You will meet it in the$2b$bcrypt hashes that many JavaScript projects store for user passwords, and it is the reason "base64" in a security context can mean two different alphabets.
One Direction Left
Decoding Base64 in JavaScript and Node.js is a stack of three honest tools: Buffer.from(string, 'base64'), the forgiving workhorse that accepts both alphabets and skips every stray character, best guarded by a strict regex; TextDecoder, for real text in any charset the old web ever invented, with fatal mode when corruption should hurt; and the new Uint8Array.fromBase64(), for byte-first code that wants strict alphabets, strict pad bits and streaming without acrobatics. Pin down the charset, validate what strangers send you, compare signatures with timingSafeEqual, and the format stops being a mystery on both sides of the browser/server divide.
And when you are done opening packages, remember that someone had to seal them. The encoding side has its own traps: the Unicode wall that stops btoa() mid-sentence, MIME line wrapping, base64url padding rules, and the new Uint8Array.toBase64() with its omitPadding option. That story, with code examples for every step, is covered in depth in the related Base64 encoding article on our sister site. Read it next, because the traps are different and funnier on that side of the alphabet.
Last updated: 2026-08-29
Related article: Base64 Encoding in JavaScript/Node.js: A Complete Guide