🍱 Lunchbox Hands

encoding

Base64 vs Base32 vs Base58: Choosing an Alphabet Humans Can Retype

Three encodings of the same bytes, three different design goals. Why base32 is case-insensitive and 60% larger, which four characters Bitcoin deleted from base64 and why, why your TOTP secret is always 32 uppercase characters, and where each decoder is deliberately more permissive than RFC 4648 allows.

Base64 is the reflex. You have bytes, you need text, you reach for base64 and move on. But the authenticator app on your phone shows a secret in base32, a Bitcoin address is base58, and a ULID is base32 with four letters removed. These are not arbitrary variations — each alphabet answers a different question about who, or what, is going to read the string afterward. Get the question right and the choice is obvious.

Here are the same twelve bytes, Hello World!, in all three, produced by the tools on this site:

EncodingOutputLengthBits/charExpansion
base64SGVsbG8gV29ybGQh1661.33×
base582NEpo7TZRRrLZSi2U17~5.86~1.37×
base32JBSWY3DPEBLW64TMMQQQ====2451.60×

Base64 is the most compact and the hardest to handle by hand. Base32 is the bulkiest and the easiest to read over a phone. Base58 sits between them, and exists for a reason base64 never considered.

Base64: dense, and full of characters that mean something elsewhere

Six bits per character, four output characters per three input bytes, = padding to fill the last quantum. RFC 4648 is clear that the padding is not optional by default: “Implementations MUST include appropriate pad characters at the end of encoded data unless the specification referring to this document explicitly states otherwise.”

The problem with base64 is not the math, it is the alphabet. Positions 62 and 63 are + and /, and both are load-bearing characters elsewhere: / is a path separator, + means a space in application/x-www-form-urlencoded bodies, and = separates a query-string key from its value. Drop a raw base64 string into a URL and you get corruption that survives long enough to reach production.

RFC 4648 defines the fix in the same document: base64url, identical except that positions 62 and 63 become - and _. That is the variant inside JWTs, and it is why a JWT is three dot-separated chunks with no +, /, or = in sight.

Our Base64 encoder emits the standard alphabet, not base64url — so if the output is headed for a URL or a filename, either translate those two characters yourself or percent-encode the result. Which one you want is not a coin flip: percent-encoding inflates the string further, translating to base64url does not.

RFC 4648 also says something most decoders quietly disobey: “Implementations MUST reject the encoded data if it contains characters outside the base alphabet when interpreting base-encoded data.” Real decoders skip whitespace, tolerate missing padding, and shrug at trailing junk. That leniency is exactly why two systems can disagree about whether the same token is valid.

Base32: five bits, uppercase, designed to be dictated

RFC 4648 §6 states the design goal outright: “The Base 32 encoding is designed to represent arbitrary sequences of octets in a form that needs to be case insensitive but that need not be human readable.”

Case insensitivity is the whole point. The alphabet is AZ then 27 — no lowercase, and no 0, 1, or 8, so the digit shapes that collide with letters are simply absent. You pay 60% expansion instead of 33%, and you get a string that survives being typed by a person, spoken over a phone, or read off a screen through a camera.

That is why your TOTP secret is base32. The 20-byte shared secret behind a six-digit authenticator code encodes to exactly 32 base32 characters, which is what our TOTP generator produces and what the QR code carries. For what happens to those bytes afterward, how TOTP 2FA works walks the HMAC.

Padding runs up to six = characters, because five input bytes become eight output characters and the partial quanta are lopsided:

foo     ->  MZXW6===
foobar  ->  MZXW6YTBOI======

Honest note about our decoder. Our base32 tool uppercases the input and strips trailing = before decoding, so nbswy3dp, NBSWY3DP, and the same string with any amount of padding all decode to hello. RFC 4648’s default is stricter than that. We chose the permissive behavior deliberately, because the strings people paste into a base32 decoder are overwhelmingly TOTP secrets copied out of a provider’s UI, where lowercase and stripped padding are both common. Just know the consequence the RFC’s security section spells out: when base32 is “handled case insensitively, alteration of case can be used to leak information or make string equality comparisons fail.” Normalize before you compare, and never treat a base32 string as a byte-for-byte identity.

Crockford’s Base32: base32 with four letters deleted

Douglas Crockford’s Base 32 is a different 32-symbol set with a different goal: “expressing numbers in a form that can be conveniently and accurately transmitted between humans and computer systems.” It uses the ten digits plus 22 letters, excluding four:

ExcludedReason (verbatim)
I”Can be confused with 1”
L”Can be confused with 1”
O”Can be confused with 0”
U”Accidental obscenity”

The last one is not a joke — dropping U means a randomly generated identifier cannot spell the short English words you would least like printed on an invoice.

Crockford’s decoder aliases rather than merely folding case: i and l decode as 1, o decodes as 0, hyphens may be inserted anywhere for readability and are ignored on decode, and encoders emit uppercase only. This is the alphabet behind ULIDs, whose spec says it plainly: “Crockford’s Base32 is used as shown. This alphabet excludes the letters I, L, O, and U to avoid confusion and abuse” — giving 0123456789ABCDEFGHJKMNPQRSTVWXYZ and a “26 character string, as opposed to the 36 character UUID.” Our ULID generator emits exactly that, and UUID vs ULID covers why the timestamp prefix matters more than the alphabet does.

Note that Crockford Base32 and RFC 4648 base32 are not interchangeable. Same name, same five bits per character, different symbol sets. Feeding a ULID to an RFC 4648 decoder is a bug, not a conversion.

Base58: what Bitcoin removed, and why

Base58 has no RFC. Its rationale is a comment at the top of base58.h in Bitcoin Core, essentially unchanged since 2009 and still the clearest statement of the design:

Why base-58 instead of standard base-64 encoding?

  • Don’t want 0OIl characters that look the same in some fonts and could be used to create visually identical looking data.
  • A string with non-alphanumeric characters is not as easily accepted as input.
  • E-mail usually won’t line-break if there’s no punctuation to break at.
  • Double-clicking selects the whole string as one word if it’s all alphanumeric.

Four requirements, and every one of them is about a human moving a string between two windows. Sixty-four, minus 0, O, I, l, minus + and /, is 58. The alphabet is 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz, and there is no padding character because there are no fixed-size quanta.

That last point is the real mechanical difference. Base64 and base32 chop the input into bit groups; base58 treats the whole input as one big integer and repeatedly divides by 58. So base58 is slower on long inputs — quadratic, not linear — and encoding cannot be streamed. It also creates a problem the bit-grouping encodings do not have: base conversion destroys leading zero bytes, because the integer 0x000061 and the integer 0x61 are the same number. Implementations, ours included, count the leading zero bytes and prefix that many literal 1 characters (1 is the symbol for value 0). That is precisely why Bitcoin addresses carrying a 0x00 version byte start with 1.

Base58Check is the part people forget

Raw base58 has no error detection. What Bitcoin actually uses for addresses is Base58Check, and its definition in base58.cpp is two lines of intent:

// add 4-byte hash check to the end
std::vector<unsigned char> vch(input.begin(), input.end());
uint256 hash = Hash(vch);
vch.insert(vch.end(), hash.data(), hash.data() + 4);
return EncodeBase58(vch);

Four bytes of a double SHA-256 over the payload, appended before encoding. On decode you recompute and memcmp; a mismatch is a hard failure. That checksum is what turns a mistyped address into an error message instead of an irreversible transfer.

Honest note about our tool. Our base58 encoder implements raw base58 with the Bitcoin alphabet. It does not add or verify a Base58Check checksum. It does reject the four excluded characters, which is the useful half of the design:

base58Decode("0oIl")  ->  Error: Invalid base58 character "0"

Both of our base-N tools are text codecs

This limitation deserves its own heading, because it is the one most likely to waste your time. Our base32 and base58 tools take text in and give text out — UTF-8 in both directions. They round-trip anything textual, emoji included. But a real Bitcoin address does not decode to text, and the tool will not tell you so politely. Decoding one gives you a string of U+FFFD replacement characters interleaved with the few bytes that happen to be printable ASCII:

base58Decode("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")
  ->  "�w��\f`�\"��3P��\n]�N���vk"

The bytes were decoded correctly; they simply are not text, and UTF-8 decoding replaced every invalid sequence. If you need the version byte, the hash160, and the checksum out of an address, you need a Base58Check decoder, not a text converter. We would rather say that here than have you trust the mojibake.

Picking one

If you need…UseWhy
The smallest text form of arbitrary bytesbase646 bits/char, implemented everywhere
The same, inside a URL, filename, or JWTbase64url- and _ instead of + and /
A string someone will type, dictate, or read off a screenbase32 (RFC 4648)Case-insensitive, no 0/1/8
A user-visible ID that must not be misread or spell wordsCrockford base32Also drops I, L, O, U; decoder aliases lookalikes
An ID that must survive double-clicking and emailbase58All alphanumeric, no lookalikes, no punctuation
Any of the above, with typo detectionAdd a checksum, e.g. Base58CheckNo base encoding detects errors on its own

And one rule that outranks all of them: none of these is encryption. A base-N encoding is a change of representation, reversible by anyone, with no key involved. If the bytes are secret they need encryption before they need an alphabet, and AES-GCM in the browser covers what that actually requires. If you are converting between numeric bases rather than encoding bytes, the number base converter is the tool for that job.