Learn · Technologies we use
How Base64 works, byte by byte
Three bytes become four characters. The explorer, the padding, why + and / break URLs, and the twenty lines of code we use.
Base64 writes any bytes as text using 64 ordinary characters, so binary data can travel through places that carry only text: email, URLs, JSON, HTML. It isn't encryption, it isn't compression and it isn't a secret. Anyone can reverse it, and it makes the data about a third bigger. Base64url is the same thing with two characters swapped and the padding dropped, so the result is safe to put in a web address. cipher.ink writes everything that has to cross a text boundary in base64url: the secret link's key, every salt and IV, and the encrypted message itself. Here's how it works one byte at a time, and why the variant exists.
How it works
The input is read three bytes (24 bits) at a time. Those 24 bits are cut into four groups of 6 bits, and each group, a number from 0 to 63, is looked up in a table of 64 characters: A to Z are 0 to 25, a to z are 26 to 51, 0 to 9 are 52 to 61, then + is 62 and / is 63. Three bytes become four characters, which is the whole reason for the one-third overhead (4 characters for every 3 bytes, 33%).
The classic example is the word "Man". Its bytes are 4d 61 6e, in bits 01001101 01100001 01101110. Regrouped in sixes that's 010011 010110 000101 101110, or 19, 22, 5, 46, which the table writes as T, W, F, u. So "Man" is TWFu. Try it, and change the input:
Nothing you type here leaves your browser.
Padding
When the input isn't a multiple of three bytes, the last group comes up short, and the output is padded with = to a multiple of four characters. One leftover byte becomes two characters and two = (M is TQ==). Two leftover bytes become three characters and one = (Ma is TWE=). The missing bits are taken as zeros. The padding carries no information, since the decoder can work out the length from the characters, and that's why base64url, which is used where the length is known or every character counts, omits it.
Here are the sizes of the byte strings cipher.ink actually handles, from scripts/examples/base64-examples.mjs:
| What | Bytes | Base64 (with padding) | Base64url (as written here) |
|---|---|---|---|
| AES-GCM IV | 12 | 16 characters, no padding | 16 characters |
| Passphrase salt | 16 | 24 characters, 2 padding | 22 characters |
| Secret-link key | 32 | 44 characters, 1 padding | 43 characters |
A 12-byte IV is a clean multiple of 3, so it needs no padding either way. Here's one (made by hashing a counter, so you can repeat it): the bytes 48 43 cd a7 fc fe 55 51 95 93 df 1c are SEPNp/z+VVGVk98c in plain base64 and SEPNp_z-VVGVk98c in base64url. The length is the same, and the / and + in the middle became _ and -.
Why base64url exists
The ordinary alphabet has three characters that mean something to a web address, and base64url (RFC 4648, section 5, "URL and filename safe alphabet") fixes each one:
+in a query string stands for a space in form encoding, so a+in a base64 value gets read back as a space unless it's percent-encoded as%2B. Base64url uses-./separates the parts of a path, so it can split a value in two, and it isn't a legal character in a file name. Base64url uses_.=separates a key from a value in a query string. Base64url leaves the padding off.
Values 62 and 63 are the two that change, and the other 62 characters are identical, so converting is two replacements and trimming the =. The bytes fb ef be are all 62s and come out as ++++, and ff ff ff are all 63s and come out as ////. In base64url they're ---- and ____. JSON web tokens (RFC 7515) use base64url without padding for the same reason, and so does cipher.ink.
Base64 next to its relatives
Base64 is one of several ways to write bytes as text, and they trade size against convenience:
| Encoding | Characters per input bytes | Overhead | Used for |
|---|---|---|---|
| Hex (base16) | 2 per 1 | 100% | Hashes, keys in logs, anything people compare by eye |
| Base32 | 8 per 5 | 60% | Authenticator-app secrets, case-insensitive systems |
| Ascii85 | 5 per 4 | 25% | PDF and PostScript |
| Base64 | 4 per 3 | 33% | Email attachments, data URIs, keys, tokens |
Hex is the easiest to read and compare, base32 survives being typed in capitals or read aloud, and base64 is the densest of the common ones that stays on a normal keyboard. cipher.ink uses hex where a person compares values (a hash in a tool) and base64url where a machine carries them (a link).
What cipher.ink does
This is the whole conversion in crypto.js, a few lines with no library:
Base64url, built on the browser's btoa and atob. See it in context at /code#b64u.
function b64u(bytes) {
var s = '', b = new Uint8Array(bytes);
for (var i = 0; i < b.length; i += 0x8000) s += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function unb64u(str) {
str = String(str).replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
var s = atob(str), b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b;
}The browser's own btoa and atob do the base64, and the two replace lines turn it into base64url and back. Three details are easy to get wrong:
btoatakes a "binary string", not bytes. It accepts only characters up to 255 and throws on anything else, so text has to become UTF-8 bytes first, and bytes have to become a string one byte per character, as the loop does.String.fromCharCode.applyhas an argument limit. Pass a large file's bytes in one call and the stack overflows, which is why the loop works in chunks of 32,768.- Padding has to be restored to decode.
atobwants a length that's a multiple of four, so the decoder puts the=back.
What base64 isn't
- Not encryption. A string of base64 text is plain data in another costume.
SGVsbG8=is "Hello", and anyone can see it. If you find a password or a token "protected" only by base64, it isn't protected. See AES-GCM for what encryption looks like. - Not compression. It makes data a third larger than the bytes already were. cipher.ink's limit of 700 KB per message counts the encoded form for that reason.
- Not one format. Email's MIME wraps base64 lines at 76 characters and ignores whitespace. PEM files (certificates, keys) wrap at 64 and add header lines. Some decoders reject missing padding or stray characters and others ignore them. When you decode something someone else wrote, work out which variant you're dealing with.
- Not a text encoding for letters. Base64 of text means encoding the text as bytes first (UTF-8), then base64ing the bytes, and reversing both in the other direction (the Unicode article shows why the byte step matters).
How cipher.ink uses this
A sealed message is a small JSON object whose salt (for passphrases), IV and ciphertext fields are base64url strings. The secret link's key, 32 random bytes, is written as 43 base64url characters after the #, which a web server never receives (why the part after # never reaches the server), and the receipt token is written the same way. Base64url means the link needs no percent-encoding, copies cleanly out of a chat, and can't be broken by a + turning into a space on the way. You can read the functions in the code.