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:

js
// 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 uses
The Unicode trap: btoa() 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.
js
// ❌ 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+:

js
// ✅ 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:

js
// 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:

js
// 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:

js
// 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:

js
// --- 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:

html
<!-- Inline image in HTML — no separate network request -->
<img
  src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
  alt="1x1 transparent pixel"
  width="1"
  height="1"
/>
css
/* 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;
}
js
// 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-image
Size warning: Base64 encoding inflates file size by roughly 33%. A 100 KB image becomes ~133 KB of Base64 text. Data URIs are best for small assets (icons, SVGs, tiny sprites) — not for photos or large images. For those, HTTP/2 multiplexing makes separate requests faster than inlining.

A 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:

js
// 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));
}
js
// 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 the TextEncoder approach or Buffer.from(str, 'utf8') in Node.js.
  • Padding matters for decoding — Base64 strings must have a length that's a multiple of 4. Missing = padding causes atob() to silently return garbage or throw, depending on the browser. Always restore padding before decoding URL-safe strings.
  • Buffer vs string encoding in Node.jsBuffer.from(str) defaults to UTF-8, but Buffer.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 typedata:;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() and Buffer.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.