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
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:
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).
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.
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):
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.
Alphabet: A-Za-z0-9+/
Padding: =
Overhead: 33.3%
Base64URL (RFC 4648 §5)
URL and filename safe variant:
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⁴):
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:
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):
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:
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:
-----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:
// 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
// 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:
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:
// 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:
Header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ
Signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The decoder must add back padding before decoding, or use a padding-agnostic decoder:
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
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
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
// 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
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
# 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
# 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