Most Base64 bugs I have debugged were not crypto bugs. They were text-encoding bugs wearing a Base64 costume — or decoders being far more permissive than anyone assumed. Here are the five that keep showing up, each one measured rather than remembered.
1. atob() does not return text
Base64 encodes bytes, not characters. atob() returns a "binary string": one character per byte, char codes 0–255. Non-ASCII text (i.e. anything UTF-8) comes back as mojibake:
atob('5paH5Lu2'); // "æä»¶" ← not what you meant
The fix is one extra step, and it is always the same step:
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const text = new TextDecoder('utf-8').decode(bytes); // "文件"
Newer engines are getting a native helper (Uint8Array.fromBase64()), but the split is real: Chrome (v153) has it and Node 24 does not — I checked both on this machine, side by side. Until every runtime you target agrees, TextDecoder is the form that works everywhere.
2. Padding is structure, not payload
Base64 packs 3 bytes into 4 characters. When the byte count is not divisible by 3, = fills out the last group. Two useful consequences:
- Browsers accept missing padding:
atob('aGVsbG8')returns'hello'without complaint. -
length % 4 === 1is impossible. If you compute that, the string was truncated somewhere upstream — not "recovered" by adding=.atob('a')throwsInvalidCharacterError, which is the correct behaviour being politely demonstrated.
3. URL-safe Base64 is a different alphabet
JWT segments, S3 presigned URLs, anything passed through a query string: + becomes -, / becomes _, and the = padding is usually stripped. atob() rejects those two characters outright:
atob('a-b_'); // InvalidCharacterError
So convert the alphabet before decoding, and re-add the padding:
function b64urlToText(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return new TextDecoder('utf-8')
.decode(Uint8Array.from(atob(s), c => c.charCodeAt(0)));
}
4. Node's decoder never complains, and that is the trap
Buffer.from(s, 'base64') is deliberately lenient. It ignores characters outside the alphabet, it ignores truncation, and it never throws. Measured on Node 24:
Buffer.from('aGVs*bG8=', 'base64').toString(); // 'hello' ← the * was silently dropped
Buffer.from('aGVsb', 'base64').toString(); // 'hel' ← truncated input, no error
Buffer.from('a-b_', 'base64'); // 'k\ufffd\ufffd' ← URL-safe chars, decoded as garbage
Which means "it decoded fine in Node" proves nothing at all: you can be handed a mangled string, get a plausible-looking result, and only find out much later. If the input can come from anywhere you do not control, validate before decoding:
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s) || s.length % 4 === 1) {
throw new Error('not valid Base64');
}
5. Whitespace is a non-issue (PEM, MIME, emails)
Both browser atob() and Node strip ASCII whitespace, so line-wrapped PEM bodies and MIME blobs decode as-is: atob('aGVs\n bG8=') → 'hello'. The classic failure is never whitespace — it is everything in section 4.
Where the "free online decoder" pattern actually leaks
Look at what gets pasted into a Base64 decoder. It is rarely a toy string: it is a session cookie, a signed token, an auth header copied out of a log line. If it is a JWT, the payload is a live credential until it expires, and it has just been POSTed to someone's server. That is also why token decoders make an attractive target — their request logs are full of credentials that still work.
Browser devtools will decode for you, but the fiddly cases (URL-safe input, invalid bytes, known-truncation, a hex view of the decoded bytes) are what made me keep a decoder open in a browser tab. Mine is at 23232322.xyz/base64-decode — client-side only, nothing uploaded, and it explains why an input is rejected instead of just failing. The encoder pair is at base64-encode, and if you are pasting actual tokens, the one that verifies signatures (HS256/384/512 secrets and RS/ES256–512 public keys) is the JWT decoder & debugger.
TL;DR
| Symptom | Cause | Fix |
|---|---|---|
æä»¶ instead of real text |
atob() returns bytes-as-chars |
new TextDecoder('utf-8').decode(Uint8Array.from(atob(s), c => c.charCodeAt(0))) |
InvalidCharacterError on - / _
|
URL-safe alphabet | swap -_ → +/, re-pad |
| Throws on a 5-char segment | length % 4 === 1 |
input is truncated; fix upstream |
| Node decodes garbage without error |
Buffer.from silently drops junk |
regex-validate first |
| Line-wrapped PEM block | — | nothing to do, whitespace is ignored |
Top comments (0)