Encoding

Base64 Explained: What It Is, What It Isn't, and When to Use It

· 3 min read

Base64 is everywhere you look in web development — JWTs, data URLs, Basic auth headers, email attachments, PEM certificates — and yet it is one of the most misunderstood pieces of plumbing in the stack. Half the confusion is about what it does (encoding, not compression); the other half is about what it protects (nothing).

The core problem Base64 solves is old and simple: many channels were built for text and mangle raw bytes. Email adds line breaks, JSON cannot contain arbitrary bytes in strings, URLs reserve characters, old protocols strip the 8th bit. If you want binary data — an image, a key, a compressed blob — to survive a text channel, you re-express the bytes using characters everyone agrees are safe.

The mechanics: 3 bytes in, 4 characters out

Base64 (RFC 4648) takes input bytes three at a time — 24 bits — and slices them into four 6-bit groups. Each 6-bit value (0–63) indexes into a 64-character alphabet: A–Z, a–z, 0–9, +, /. Four safe characters out for every three bytes in; that ratio is why Base64 output is always one-third larger than the input.

When the input is not a multiple of 3 bytes, the final block is short, and padding fills the gap: one leftover byte yields two characters plus '==', two leftover bytes yield three characters plus '='. Padding carries no data — it is a length marker that lets decoders reconstruct the exact byte count. That is also why valid Base64 always has a length divisible by four.

"Hi!"  →  01001000 01101001 00100001   (3 bytes, 24 bits)
       →  010010 000110 100100 100001   (4 × 6 bits)
       →   S      G      k      h       →  "SGkh"

base64url: the two-character fork that breaks decoders

Standard Base64 contains + and /, both of which mean something in URLs: + decodes to a space in query strings, / separates path segments. RFC 4648 §5 defines base64url for exactly this reason: - replaces +, _ replaces /, and padding is typically dropped. JWTs, OAuth state parameters, and most modern API tokens use base64url.

Nearly every 'invalid character' decode failure in the wild is a variant mismatch — a base64url string fed to a standard decoder or vice versa. The fix is mechanical: swap the two characters, restore padding to a multiple of four, decode. Recognizing the symptom saves an hour of confusion the first time and thirty seconds every time after.

What Base64 is not

  • Not encryption. The mapping is fixed and public; decoding requires no key. A password 'hidden' in Base64 is plaintext with extra steps — and yes, Basic auth without TLS is exactly that.
  • Not compression. Output is 33% larger by construction. If size matters, compress first (gzip/brotli), then encode the compressed bytes.
  • Not integrity protection. Flip a character and it still decodes — to different bytes. Integrity needs a hash or signature over the data.
  • Not obfuscation worth having. Security scanners, browser devtools, and attackers decode Base64 reflexively; it hides nothing from anyone who matters.

The Unicode trap in JavaScript

The browser's built-in btoa() predates emoji-era JavaScript: it converts strings where every character must fit in one byte (Latin-1). Feed it 'สวัสดี' or an emoji and it throws InvalidCharacterError — probably the single most-hit Base64 error in web development. The fix is to define the bytes first: encode the string as UTF-8, then Base64 those bytes.

This ordering — characters → bytes → Base64 — matters in every language. Mismatched assumptions about the byte encoding (UTF-8 vs UTF-16 vs Latin-1) are why 'the same string' produces different Base64 in different systems, and why decoded text sometimes arrives as mojibake. When output disagrees across platforms, compare the bytes, not the strings.

// Throws on non-Latin-1:
btoa("สวัสดี")  // InvalidCharacterError

// Correct: UTF-8 bytes first
const bytes = new TextEncoder().encode("สวัสดี");
btoa(String.fromCharCode(...bytes))  // "4Liq4Lin4Lix4Liq4LiU4Li1"

Sensible uses and their limits

Good uses: small binary values inside JSON APIs (keys, signatures, thumbnails), data: URLs for tiny inline assets, anywhere a spec demands it (JWT, MIME, PEM). Questionable uses: inlining large images (the 33% tax plus loss of HTTP caching usually outweighs the saved request) and passing large blobs through systems that would rather stream bytes.

A useful habit when debugging: treat every Base64 string you meet as transparent. Decode it and look — config values, cookies, webhook payloads, JWT segments. A surprising amount of system behavior is documented inside values someone assumed nobody would read.

← All guides