Base64 shows up everywhere once you start looking — JWT tokens, data URIs, email
attachments, API payloads carrying binary files. The encoding itself is defined in
RFC 4648
and is dead simple in concept: take arbitrary bytes, represent them using only 64 printable
ASCII characters. What trips people up is the implementation in JavaScript — different
APIs in browser vs Node.js, the Unicode gotcha that makes btoa() throw, and the
URL-safe variant that JWTs depend on. This guide covers all of it with working code.
btoa() and atob() in the Browser
The browser has had
btoa()
and
atob()
for a long time. The names are confusing (binary to ASCII and back), but the usage is
straightforward for simple strings:
// Encode a plain ASCII string
const encoded = btoa('hello world');
console.log(encoded); // "aGVsbG8gd29ybGQ="
// Decode it back
const decoded = atob('aGVsbG8gd29ybGQ=');
console.log(decoded); // "hello world"
// A more realistic example — encoding a simple auth token
const credentials = 'apiuser:s3cr3tkey';
const basicAuth = 'Basic ' + btoa(credentials);
// "Basic YXBpdXNlcjpzM2NyM3RrZXk="
// This is exactly what HTTP Basic Authentication usesbtoa() only handles strings where
every character has a code point ≤ 255 (the Latin-1 range). Pass it a string containing
any emoji or non-Latin character and it throws InvalidCharacterError immediately.
This is one of the most common Base64 bugs in browser code.// ❌ This throws — emoji is outside Latin-1
btoa('Hello 🌍');
// Uncaught DOMException: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
// ❌ This also throws — any non-ASCII character will do it
btoa('café');
// Uncaught DOMException: ...Handling Unicode Safely in the Browser
The fix is to first encode the string to UTF-8 bytes, then Base64-encode those bytes.
The classic approach uses encodeURIComponent and a percent-decode trick. The modern
approach uses TextEncoder, which is
available in all modern browsers
and Node.js 11+:
// ✅ Unicode-safe encode using TextEncoder
function encodeBase64(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 byte array
const binString = Array.from(bytes, byte =>
String.fromCodePoint(byte)
).join('');
return btoa(binString);
}
// ✅ Unicode-safe decode using TextDecoder
function decodeBase64(base64Str) {
const binString = atob(base64Str);
const bytes = Uint8Array.from(binString, char =>
char.codePointAt(0)
);
return new TextDecoder().decode(bytes);
}
// Now emojis and international text work fine
console.log(encodeBase64('Hello 🌍')); // "SGVsbG8g8J+MjQ=="
console.log(decodeBase64('SGVsbG8g8J+MjQ==')); // "Hello 🌍"
console.log(encodeBase64('Héllo café')); // "SMOpbGxvIGNhZsOp"
console.log(decodeBase64('SMOpbGxvIGNhZsOp')); // "Héllo café"Keep these two utility functions somewhere in your codebase and forget that bare
btoa() exists. The TextEncoder/TextDecoder pair is the
right tool for anything beyond pure ASCII. You can try it right now with the
Base64 Encoder tool.
Buffer.from() in Node.js
Node.js has its own API for this via the Buffer class, which handles encoding/decoding more cleanly. There's no Unicode gotcha here because you explicitly specify the input encoding:
// Encode string → Base64
const encoded = Buffer.from('Hello 🌍', 'utf8').toString('base64');
console.log(encoded); // "SGVsbG8g8J+MjQ=="
// Decode Base64 → string
const decoded = Buffer.from('SGVsbG8g8J+MjQ==', 'base64').toString('utf8');
console.log(decoded); // "Hello 🌍"
// Practical example — encoding a JSON payload to embed in a config file
const config = {
apiKey: 'sk-prod-abc123',
projectId: 'proj_x9f2k',
region: 'us-east-1'
};
const encodedConfig = Buffer.from(JSON.stringify(config), 'utf8').toString('base64');
// eyJhcGlLZXkiOiJzay1wcm9kLWFiYzEyMyIsInByb2plY3RJZCI6InByb2pfeDlmMmsiLCJyZWdpb24iOiJ1cy1lYXN0LTEifQ==
// Decode and parse it back
const decodedConfig = JSON.parse(
Buffer.from(encodedConfig, 'base64').toString('utf8')
);
console.log(decodedConfig.region); // "us-east-1"Note that btoa() and atob() are also available in Node.js 16+
as globals (for browser compatibility), but the Buffer API is more idiomatic in
Node.js and has been there since
Node.js v0.1.
For JSON-specific encoding, the JSON to Base64 tool is handy
for quick manual conversions.
URL-Safe Base64 — What JWTs Actually Use
Standard Base64 uses + and / in its alphabet. Both of those
characters are special in URLs — + means a space in query strings, and /
is a path separator. When you need Base64 in a URL or as a JWT segment, you use the URL-safe
variant: replace + with - and / with _,
then strip the = padding. This is standardised in
RFC 4648 §5
and is what every JWT library uses internally:
// Convert standard Base64 to URL-safe Base64
function toBase64Url(base64Str) {
return base64Str
.replace(/+/g, '-')
.replace(///g, '_')
.replace(/=+$/, ''); // strip padding
}
// Convert URL-safe Base64 back to standard Base64
function fromBase64Url(base64UrlStr) {
// Restore padding — length must be a multiple of 4
const padded = base64UrlStr + '==='.slice((base64UrlStr.length + 3) % 4);
return padded
.replace(/-/g, '+')
.replace(/_/g, '/');
}
// Encode a string to URL-safe Base64
function encodeBase64Url(str) {
return toBase64Url(btoa(str));
}
// Decode URL-safe Base64 to a string
function decodeBase64Url(str) {
return atob(fromBase64Url(str));
}
// Example: manually inspect a JWT payload
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MTM0MDAwMDB9.signature';
const [header, payload] = jwt.split('.');
console.log(decodeBase64Url(header));
// {"alg":"HS256","typ":"JWT"}
console.log(decodeBase64Url(payload));
// {"userId":42,"role":"admin","iat":1713400000}This is why you'll see Base64 strings like
eyJhbGciOiJIUzI1NiJ9 in JWTs — no padding, dashes instead of plus signs.
When sending encoded data as a URL query parameter, always use the URL-safe variant to
avoid broken URLs. The Base64 Decoder tool handles both
standard and URL-safe Base64 automatically.
Encoding a File with the FileReader API
A common browser task: the user picks an image or document, and you need to send it
to an API as Base64. The
FileReader API
has readAsDataURL() for exactly this — it gives you a complete data URI with the
MIME type included:
// Wrap FileReader in a Promise for easier async usage
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// result is "data:image/png;base64,iVBORw0KGgo..."
// Strip the data URI prefix to get just the Base64 string
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// Hook it up to a file input
const fileInput = document.getElementById('avatarUpload');
fileInput.addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
try {
const base64 = await fileToBase64(file);
console.log(`File size: ${file.size} bytes`);
console.log(`Base64 length: ${base64.length} chars`);
// Send to your API
await fetch('/api/users/42/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: base64, mimeType: file.type })
});
} catch (err) {
console.error('Upload failed:', err.message);
}
});If you need the full data URI (including the MIME type prefix) rather than just the
raw Base64, skip the .split(',')[1] and use reader.result directly.
For bulk file conversion, the Image to Base64 tool handles
images without writing any code.
Encoding Binary Data and Uint8Arrays
Sometimes you're not starting from a string or a File — you've got raw bytes from a
WebCrypto operation, a canvas export, or a WebAssembly module. Here's how to go from a
Uint8Array to Base64 and back in both environments:
// --- Browser ---
// Uint8Array → Base64 (browser)
function uint8ToBase64(bytes) {
const binString = Array.from(bytes, byte =>
String.fromCodePoint(byte)
).join('');
return btoa(binString);
}
// Base64 → Uint8Array (browser)
function base64ToUint8(base64Str) {
const binString = atob(base64Str);
return Uint8Array.from(binString, char => char.codePointAt(0));
}
// Example: export a canvas as raw PNG bytes → Base64
const canvas = document.getElementById('myCanvas');
canvas.toBlob(blob => {
blob.arrayBuffer().then(buffer => {
const bytes = new Uint8Array(buffer);
const encoded = uint8ToBase64(bytes);
console.log('PNG as Base64:', encoded.slice(0, 40) + '...');
});
}, 'image/png');
// --- Node.js ---
// Uint8Array / Buffer → Base64 (Node.js)
function uint8ToBase64Node(bytes) {
return Buffer.from(bytes).toString('base64');
}
// Base64 → Buffer (Node.js)
function base64ToBufferNode(base64Str) {
return Buffer.from(base64Str, 'base64');
}
// Example: hash a password and encode the result
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update('mySecretPassword').digest();
// hash is a Buffer (which extends Uint8Array)
console.log(hash.toString('base64'));
// "XohImNooBHFR0OVvjcYpJ3NgxxxxxxxxxxxxxA=="Embedding Images as Data URIs
One of the most practical uses of Base64 in web development is embedding images directly into HTML or CSS, eliminating an HTTP request. You've probably seen data URIs in inline SVGs or email templates. Here's the pattern:
<!-- Inline image in HTML — no separate network request -->
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
alt="1x1 transparent pixel"
width="1"
height="1"
/>/* Inline background image in CSS — commonly used for small icons and loading spinners */
.spinner {
width: 32px;
height: 32px;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJhMTAgMTAgMCAxIDAgMCAyMCAxMCAxMCAwIDAgMCAwLTIweiIvPjwvc3ZnPg==");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}// Generate a data URI from a fetched image (Node.js)
const fs = require('fs');
const path = require('path');
function imageFileToDataUri(filePath) {
const ext = path.extname(filePath).slice(1).toLowerCase();
const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp' };
const mimeType = mimeMap[ext] ?? 'application/octet-stream';
const fileData = fs.readFileSync(filePath);
const base64 = fileData.toString('base64');
return `data:${mimeType};base64,${base64}`;
}
const dataUri = imageFileToDataUri('./logo.png');
// "data:image/png;base64,iVBORw0KGgo..."
// Drop this into an <img src> or CSS background-imageA Compact Utility Module for Both Environments
Rather than scattering btoa() calls around your codebase, it's worth
having a single utility module that covers Unicode, URL-safe variants, and works in both
browser and Node.js. Here's one that does all of that:
// base64.js — drop into any project
const isNode = typeof process !== 'undefined' && process.versions?.node;
export function encode(str) {
if (isNode) {
return Buffer.from(str, 'utf8').toString('base64');
}
// Browser: encode to UTF-8 bytes first, then Base64
const bytes = new TextEncoder().encode(str);
const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
return btoa(binString);
}
export function decode(base64Str) {
if (isNode) {
return Buffer.from(base64Str, 'base64').toString('utf8');
}
// Browser: Base64 → bytes → UTF-8 string
const binString = atob(base64Str);
const bytes = Uint8Array.from(binString, c => c.codePointAt(0));
return new TextDecoder().decode(bytes);
}
export function encodeUrlSafe(str) {
return encode(str)
.replace(/+/g, '-')
.replace(///g, '_')
.replace(/=+$/, '');
}
export function decodeUrlSafe(str) {
const padded = str + '==='.slice((str.length + 3) % 4);
return decode(padded.replace(/-/g, '+').replace(/_/g, '/'));
}
export function encodeBytes(bytes) {
if (isNode) return Buffer.from(bytes).toString('base64');
const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
return btoa(binString);
}
export function decodeToBytes(base64Str) {
if (isNode) return Buffer.from(base64Str, 'base64');
const binString = atob(base64Str);
return Uint8Array.from(binString, c => c.codePointAt(0));
}// Usage examples
import { encode, decode, encodeUrlSafe, decodeUrlSafe } from './base64.js';
encode('Hello 🌍'); // "SGVsbG8g8J+MjQ=="
decode('SGVsbG8g8J+MjQ=='); // "Hello 🌍"
encodeUrlSafe('[email protected]'); // "dXNlckBleGFtcGxlLmNvbQ" (no +, /, or =)
decodeUrlSafe('dXNlckBleGFtcGxlLmNvbQ'); // "[email protected]"Common Gotchas to Watch For
- btoa() throws on non-Latin characters — any character above code point 255 causes
InvalidCharacterError. Always use theTextEncoderapproach orBuffer.from(str, 'utf8')in Node.js. - Padding matters for decoding — Base64 strings must have a length that's a multiple of 4. Missing
=padding causesatob()to silently return garbage or throw, depending on the browser. Always restore padding before decoding URL-safe strings. - Buffer vs string encoding in Node.js —
Buffer.from(str)defaults to UTF-8, butBuffer.from(str, 'binary')treats the string as Latin-1 bytes. Using the wrong encoding when decoding produces garbled output that can be hard to debug. - Data URI MIME type —
data:;base64,...(no MIME type) will work in some browsers but not others. Always include the MIME type:data:image/png;base64,.... - Line breaks in MIME Base64 — RFC 4648 allows implementations to insert line breaks every 76 characters (as email encoders do).
atob()andBuffer.from()both handle this, but if you're generating Base64 yourself, don't add line breaks unless the target system expects them.
Wrapping Up
Base64 in JavaScript is one of those topics that looks trivial until it bites you.
The short version: never use bare btoa() for anything user-generated — wrap it
with TextEncoder to handle Unicode properly. In Node.js, Buffer.from(str,
'utf8').toString('base64') is the right idiom. When the encoded string ends up in a
URL or JWT, swap to the URL-safe variant. For quick experiments or one-off conversions, the
Base64 Encoder, Base64 Decoder,
and JSON to Base64 tools save time. The
MDN Base64 glossary page
also has a solid browser-focused reference if you need a second opinion on any of this.