Why 64? The Mathematical Basis

The choice of 64 is not arbitrary. It is the largest power of two whose corresponding character count fits within the printable ASCII range (95 characters, codes 32–126):

Base Bits per char Characters needed Overhead Fits printable ASCII?
16 4 16 100% Yes
32 5 32 60% Yes
64 6 64 33.3% Yes
128 7 128 14.3% No (only 95 available)

Base64 achieves the optimal trade-off: maximum information density while staying within universally safe printable characters. Base85 (Ascii85) pushes further by using 85 characters to represent 4 bytes in 5 characters (25% overhead), but it requires characters that are unsafe in many protocols.

RFC 4648: The Canonical Specification

RFC 4648 (2006) supersedes RFC 3548 and defines the canonical Base16, Base32, and Base64 encodings. Key normative requirements:

The Standard Alphabet

code
Value  Char    Value  Char    Value  Char    Value  Char
  0     A       16     Q       32     g       48     w
  1     B       17     R       33     h       49     x
  2     C       18     S       34     i       50     y
  3     D       19     T       35     j       51     z
  4     E       20     U       36     k       52     0
  5     F       21     V       37     l       53     1
  6     G       22     W       38     m       54     2
  7     H       23     X       39     n       55     3
  8     I       24     Y       40     o       56     4
  9     J       25     Z       41     p       57     5
 10     K       26     a       42     q       58     6
 11     L       27     b       43     r       59     7
 12     M       28     c       44     s       60     8
 13     N       29     d       45     t       61     9
 14     O       30     e       46     u       62     +
 15     P       31     f       47     v       63     /

Padding: =

Encoding Algorithm

The encoder processes input in 3-byte (24-bit) groups:

code
Input bytes:   [byte₀]     [byte₁]     [byte₂]
Bit layout:    XXXXXXXX    YYYYYYYY    ZZZZZZZZ

Split into 6-bit groups:
  char₀ = XXXXXX        (byte₀ >> 2)
  char₁ = XXYYYY        ((byte₀ & 0x03) << 4) | (byte₁ >> 4)
  char₂ = YYYYZZ        ((byte₁ & 0x0F) << 2) | (byte₂ >> 6)
  char₃ = ZZZZZZ        (byte₂ & 0x3F)

When the input length is not a multiple of 3:

Remaining bytes Output chars Padding
0 0 none
1 2 + == 2 bits unused in char₁
2 3 + = 4 bits unused in char₂

Padding Semantics

Padding is not mere decoration. It carries information: it tells the decoder whether the last quantum was 1 or 2 bytes. RFC 4648 §3.2 states that padding MUST be added for standard Base64 but notes that in contexts where the input length is known, padding can be omitted (as Base64URL commonly does).

python
import base64

# Standard: always padded
base64.b64encode(b"A")    # b'QQ=='  (1 byte → 2 chars + ==)
base64.b64encode(b"AB")   # b'QUI='  (2 bytes → 3 chars + =)
base64.b64encode(b"ABC")  # b'QUJD'  (3 bytes → 4 chars, no padding)

# URL-safe without padding (common in JWTs):
base64.urlsafe_b64encode(b"A").rstrip(b'=')  # b'QQ'

The BaseN Encoding Family

Base16 (Hex)

Two hex characters per byte. Simple but 100% overhead.

code
Input:  0xDE 0xAD 0xBE 0xEF
Output: "DEADBEEF"

Used in: hex dumps, color codes, cryptographic hashes, MAC addresses.

Base32 (RFC 4648 §6)

Five characters per 5 bytes (40 bits ÷ 5 bits/char = 8 chars per 5 bytes). Uses A–Z and 2–7 (avoids 0/O/1/I confusion):

code
Alphabet: ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
Overhead: 60%

Used in: TOTP secret keys (Google Authenticator), onion addresses (Tor v3 uses Base32-encoded ed25519 keys), file systems requiring case-insensitive names.

Base64 (RFC 4648 §4)

Four characters per 3 bytes. The workhorse.

code
Alphabet: A-Za-z0-9+/
Padding:  =
Overhead: 33.3%

Base64URL (RFC 4648 §5)

URL and filename safe variant:

code
Alphabet: A-Za-z0-9-_
Padding:  optional (usually omitted)

Used in: JWTs, URL query parameters, filenames.

Base85 / Ascii85

Five characters per 4 bytes (32 bits, since 85⁵ > 2³² > 85⁴):

code
Overhead: 25%
Alphabet: 33–117 in ASCII (! through u)
Variants: btoa (original), Adobe Ascii85, Z85 (ZeroMQ), RFC 1924 (IPv6)

Used in: PostScript/PDF streams, Git binary patches, ZeroMQ.

Comparison

Encoding Bits/char Overhead Alphabet safety
Base16 4 100% Hex-safe everywhere
Base32 5 60% Case-insensitive safe
Base64 6 33.3% Needs escaping in URLs
Base64URL 6 33.3% URL/filename safe
Base85 ~6.4 25% Contains shell metacharacters

Streaming Encoder and Decoder

The State Machine Problem

Base64 encoding processes 3 bytes at a time, but input arrives in arbitrary chunks (network buffers, file reads). A streaming encoder must maintain state between calls:

python
class StreamingBase64Encoder:
    ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    
    def __init__(self):
        self._buffer = bytearray()
    
    def update(self, data: bytes) -> str:
        self._buffer.extend(data)
        output = []
        
        while len(self._buffer) >= 3:
            b0, b1, b2 = self._buffer[0], self._buffer[1], self._buffer[2]
            del self._buffer[:3]
            
            output.append(self.ALPHABET[b0 >> 2])
            output.append(self.ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)])
            output.append(self.ALPHABET[((b1 & 0x0F) << 2) | (b2 >> 6)])
            output.append(self.ALPHABET[b2 & 0x3F])
        
        return ''.join(output)
    
    def finalize(self) -> str:
        if not self._buffer:
            return ''
        
        b0 = self._buffer[0]
        output = [self.ALPHABET[b0 >> 2]]
        
        if len(self._buffer) == 1:
            output.append(self.ALPHABET[(b0 & 0x03) << 4])
            output.append('==')
        else:
            b1 = self._buffer[1]
            output.append(self.ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)])
            output.append(self.ALPHABET[(b1 & 0x0F) << 2])
            output.append('=')
        
        self._buffer.clear()
        return ''.join(output)

Streaming Decoder

The decoder processes 4 characters at a time and must handle whitespace (MIME allows line breaks within encoded data):

python
class StreamingBase64Decoder:
    DECODE_TABLE = {c: i for i, c in enumerate(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    )}
    
    def __init__(self):
        self._buffer = []
    
    def update(self, text: str) -> bytes:
        output = bytearray()
        
        for c in text:
            if c in (' ', '\n', '\r', '\t'):
                continue
            if c == '=':
                self._buffer.append(-1)
            elif c in self.DECODE_TABLE:
                self._buffer.append(self.DECODE_TABLE[c])
            else:
                raise ValueError(f"Invalid Base64 character: {c!r}")
            
            if len(self._buffer) == 4:
                v0, v1, v2, v3 = self._buffer
                self._buffer.clear()
                
                output.append((v0 << 2) | (v1 >> 4))
                if v2 != -1:
                    output.append(((v1 & 0x0F) << 4) | (v2 >> 2))
                if v3 != -1:
                    output.append(((v2 & 0x03) << 6) | v3)
        
        return bytes(output)

MIME and PEM: Line-Wrapped Base64

MIME (RFC 2045)

Email attachments use Base64 with mandatory line wrapping at 76 characters:

code
Content-Transfer-Encoding: base64

SGVsbG8sIFdvcmxkIQ0KVGhpcyBpcyBhIHRlc3QgbWVzc2FnZSB0aGF0IGlz
IGxvbmcgZW5vdWdoIHRvIHdyYXAgYWNyb3NzIG11bHRpcGxlIGxpbmVzLg==

The 76-character limit plus CRLF adds approximately 2.6% overhead on top of the 33.3% encoding overhead.

PEM (RFC 7468)

Cryptographic keys and certificates use PEM format: Base64 with 64-character lines, wrapped in labeled boundaries:

code
-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJALx0SqICBVLGMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMM
BnRlc3RDQTAEFW0yNTA3MTkwMDAwMDBaFw0yNjA3MTkwMDAwMDBaMBExDzAN
BgNVBAMMBnRlc3RDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABC...
-----END CERTIFICATE-----

The parser must strip headers, join lines, then decode the concatenated Base64.

Constant-Time Decoding for Security

The Timing Attack Vector

Naive Base64 decoders use lookup tables or branching:

c
// VULNERABLE to timing side-channels:
int decode_char(char c) {
    if (c >= 'A' && c <= 'Z') return c - 'A';       // branch 1
    if (c >= 'a' && c <= 'z') return c - 'a' + 26;  // branch 2
    if (c >= '0' && c <= '9') return c - '0' + 52;  // branch 3
    if (c == '+') return 62;                          // branch 4
    if (c == '/') return 63;                          // branch 5
    return -1;
}

Branch prediction timing can leak which character is being decoded, potentially revealing plaintext when decoding encrypted-then-Base64-encoded data.

Constant-Time Implementation

c
// Constant-time Base64 decode (no data-dependent branches):
static int ct_decode_char(unsigned char c) {
    unsigned int val = 0;
    
    // Each comparison runs unconditionally
    val |= ((unsigned int)(('A' - 1 - c) & (c - ('Z' + 1))) >> 8) & (c - 'A');
    val |= ((unsigned int)(('a' - 1 - c) & (c - ('z' + 1))) >> 8) & (c - 'a' + 26);
    val |= ((unsigned int)(('0' - 1 - c) & (c - ('9' + 1))) >> 8) & (c - '0' + 52);
    val |= ((unsigned int)(('+' - 1 - c) & (c - ('+' + 1))) >> 8) & 62;
    val |= ((unsigned int)(('/' - 1 - c) & (c - ('/' + 1))) >> 8) & 63;
    
    return (int)val;
}

Libraries that implement constant-time Base64: libsodium (sodium_bin2base64), OpenSSL (EVP_EncodeBlock), and Go's encoding/base64 (uses a constant-time lookup table since Go 1.12).

Performance: SIMD Acceleration

Lookup Table vs Arithmetic

Traditional implementations use a 256-byte lookup table. SIMD implementations (SSE2, AVX2, NEON) process 12–48 bytes per instruction:

code
Approach               | Throughput (x86-64) | Technique
-----------------------|--------------------|-----------
Scalar lookup table    | ~800 MB/s          | 256-byte LUT
SSE2 vectorized        | ~3.5 GB/s          | Parallel 6-bit extraction
AVX2 vectorized        | ~7 GB/s            | 32-byte lanes

The key insight: the Base64 alphabet mapping can be decomposed into arithmetic operations on 6-bit values that vectorize well:

code
// Pseudo-SIMD: encode 12 bytes → 16 chars simultaneously
for each 6-bit value v in parallel:
  if v < 26:  char = v + 'A'
  elif v < 52: char = v - 26 + 'a'
  elif v < 62: char = v - 52 + '0'
  elif v == 62: char = '+'
  else: char = '/'

Libraries: Turbo-Base64 (C), base64-simd (Rust), Node.js Buffer (uses SIMD internally since v16).

Production Boundaries

JWT: Base64URL Without Padding

JWTs use Base64URL encoding without padding for all three segments:

code
Header:    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload:   eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ
Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The decoder must add back padding before decoding, or use a padding-agnostic decoder:

python
import base64

def base64url_decode(s: str) -> bytes:
    # Add padding: Base64 output length is always multiple of 4
    padding = 4 - len(s) % 4
    if padding != 4:
        s += '=' * padding
    return base64.urlsafe_b64decode(s)

Data URLs: Size vs Request Trade-off

code
data:[<mediatype>][;base64],<data>

The break-even point depends on HTTP/2 multiplexing and compression:

  • HTTP/1.1: inline benefits for assets < ~1 KB (avoids connection overhead)
  • HTTP/2+: separate requests are nearly free; inline only benefits sub-200-byte assets
  • Critical render path: inline above-the-fold CSS/SVG regardless of size to eliminate render-blocking requests

HTTP Basic Auth: Not a Security Boundary

code
Authorization: Basic dXNlcjpwYXNzd29yZA==
             ↓ decode
             user:password

Base64 here is purely formatting—it ensures the colon-separated credentials survive HTTP header transport. Security comes entirely from TLS. Without TLS, the credentials are plaintext to any network observer.

Common Implementation Pitfalls

1. Unicode → Base64 Without Explicit Encoding

javascript
// BUG: btoa cannot handle code points > 255
btoa("Hello 🌍")  // Throws: InvalidCharacterError

// CORRECT: explicitly encode to UTF-8 first
function toBase64(str) {
  const bytes = new TextEncoder().encode(str);
  const binary = Array.from(bytes, b => String.fromCharCode(b)).join('');
  return btoa(binary);
}

2. Conflating Base64 with Base64URL

python
import base64

token = "eyJhbGciOi..."  # From a JWT

# BUG: standard decoder rejects '-' and '_'
base64.b64decode(token)  # binascii.Error

# CORRECT: use URL-safe decoder
base64.urlsafe_b64decode(token + '==')

3. Ignoring Non-Alphabet Characters

python
# DANGEROUS: silently ignores garbage
base64.b64decode("SGVs\x00bG8=")  # Succeeds on some implementations

# SAFE: strict validation
base64.b64decode("SGVsbG8=", validate=True)  # Rejects non-alphabet bytes

4. Memory Exhaustion on Large Inputs

python
# DANGEROUS for untrusted input:
decoded = base64.b64decode(user_provided_string)  # Could be gigabytes

# SAFE: enforce size limits before decoding
MAX_ENCODED_SIZE = 10 * 1024 * 1024  # 10 MB encoded ≈ 7.5 MB decoded
if len(user_provided_string) > MAX_ENCODED_SIZE:
    raise ValueError("Input exceeds maximum allowed size")

References

  • RFC 4648 — The Base16, Base32, and Base64 Data Encodings (Josefsson, 2006)
  • RFC 2045 — MIME Part One: Format of Internet Message Bodies (§6.8 Base64 Content-Transfer-Encoding)
  • RFC 7468 — Textual Encodings of PKIX, PKCS, and CMS Structures (PEM format)
  • RFC 7515 — JSON Web Signature (JWS) — defines Base64URL usage in JWTs
  • Langley, A. (2019). "Constant-Time Character Classification" — timing-safe implementations
  • Muła, W. & Lemire, D. (2018). "Faster Base64 Encoding and Decoding Using AVX2 Instructions" — SIMD techniques