What you'll learn
Quick Answer
Base64 encoding represents arbitrary bytes using 64 printable ASCII characters (A to Z, a to z, 0 to 9, plus and slash), with = for padding. It exists so binary data can pass through systems built for text, such as JSON, URLs, and email headers. It is fully reversible by anyone, adds roughly 33% to the size, and provides no confidentiality at all.
How it works
Base64 reads the input as a stream of bits and regroups it. Three bytes are 24 bits; split into four groups of 6 bits, each group is a number from 0 to 63 that indexes into the alphabet A-Za-z0-9+/. So every 3 input bytes become exactly 4 output characters.
When the input length is not a multiple of 3, the final group is zero-padded and one or two = characters are appended so the output length stays a multiple of 4:
'a' -> 'YQ==' (1 byte, 2 chars + 2 padding)
'ab' -> 'YWI=' (2 bytes, 3 chars + 1 padding)
'abc' -> 'YWJj' (3 bytes, 4 chars, no padding)Decoding reverses it exactly. There is no key, no dictionary, no randomness. The same bytes always produce the same string, and anyone can turn that string back into the original bytes.
Running it
Node has it on Buffer, plus the browser-compatible btoa and atob:
> Buffer.from('hello', 'utf8').toString('base64')
'aGVsbG8='
> Buffer.from('aGVsbG8=', 'base64').toString('utf8')
'hello'
> btoa('hello')
'aGVsbG8='
> atob('aGVsbG8=')
'hello'Python uses the base64 module, which works on bytes, not strings:
>>> import base64
>>> base64.b64encode(b'hello')
b'aGVsbG8='
>>> base64.b64decode(b'aGVsbG8=').decode('utf-8')
'hello'The names are historical: btoa means "binary to ASCII" and atob is the reverse. Despite the name, atob does not give you decoded text; it returns a binary string in which each character code is one raw byte from 0 to 255. That is why any non-ASCII content needs the separate UTF-8 step covered below.
Note the full round trip: to get text back you decode Base64 to bytes, then decode those bytes with a character encoding such as UTF-8. Base64 knows nothing about text; it only moves bytes. The Buffer and Python base64 decoders are also lenient about missing = padding, so Buffer.from('aGVsbG8', 'base64') still yields hello, though strict decoders reject it.
The non-ASCII trap
btoa does not accept arbitrary text. It only handles characters with code points 0 to 255 (the Latin-1 range), and treats each as one byte. Verified in Node:
> btoa('café')
'Y2Fm6Q==' // encodes the Latin-1 byte 0xE9
> btoa('€')
Uncaught InvalidCharacterError: Invalid character
> Buffer.from('café', 'utf8').toString('base64')
'Y2Fmw6k=' // the correct UTF-8 encodingTwo separate problems. The euro sign throws outright because it is above code point 255 (browsers word it as "characters outside of the Latin1 range"). And café does not throw, which is worse: it silently produces Y2Fm6Q==, the Latin-1 encoding, not what a UTF-8 decoder on the other end expects. It comes back as mojibake.
The fix is to convert to UTF-8 bytes first. In Node, use Buffer.from(str, 'utf8'). In the browser, run the string through TextEncoder to get a Uint8Array and Base64 those bytes; the legacy one-liner btoa(unescape(encodeURIComponent(str))) also yields the correct Y2Fmw6k=.
URL-safe Base64
Standard Base64 uses +, /, and =. All three are unsafe in a URL: + decodes to a space in query strings, / is a path separator, and = is a key-value delimiter. The URL-safe variant swaps + for - and / for _, and usually drops the padding:
standard : Pj4+Pz8/fn5+ZmY=
base64url: Pj4-Pz8_fn5-ZmYNode: buf.toString('base64url'). Python: base64.urlsafe_b64encode(data), which keeps the = padding, so strip it yourself if the consumer expects none. This is the encoding used for each segment of a JWT, for URL tokens, and for values that end up in a filename. Decoding is symmetric: map - and _ back to + and /, re-add padding to a multiple of 4, then decode normally.
The 33% size cost
Four output characters for every three input bytes is a fixed 4:3 ratio, so Base64 output is about 133% of the input, before any line breaks. Measured on 3000 bytes:
3000 bytes -> 4000 characters (ratio 1.333)This matters most for data URIs. Inlining a 30 KB image as data:image/png;base64,... adds roughly 40 KB of text to your HTML or CSS file. That text is not cached separately, cannot be lazy-loaded, and blocks parsing of the file it sits in. Inlining is worth it only for very small assets, an icon or a 1x1 pixel, where saving an HTTP request outweighs the bloat. For anything bigger, link to the real file.
Base64 in a JSON API response carries the same tax: a binary blob costs a third more bandwidth than sending it as raw bytes over a binary channel would.
What Base64 is not
Not encryption. There is no key. atob or any online tool reveals the contents instantly. Base64-ing a password, an API key, or a token hides nothing; it only stops the value being obviously readable at a glance. Treat a Base64 string in a config file or URL as fully public.
Not compression. It makes data larger, not smaller. If you need both, compress first (gzip, Brotli) and Base64 the compressed bytes.
Not hashing. It is reversible and not fixed-length, so it cannot verify integrity or store passwords. You will sometimes see a hash presented in Base64 (bcrypt output, HMAC signatures), but the hashing and the encoding are separate steps.
Its one job is safe transport of bytes through text-only channels. Used for that, it is perfect. Used as a security or size optimisation, it is a mistake.
