Ever pasted a Base64 string into atob() and gotten this?
Uncaught DOMException: Failed to execute 'atob' on 'Window':
The string to be decoded is not correctly encoded.
…yet the string looks like perfectly valid Base64. Nine times out of ten the culprit is that it isn't standard Base64 at all — it's Base64URL, and the two are not interchangeable. Here's the difference and how to decode either one safely.
Two alphabets, one name
Standard Base64 (RFC 4648 §4) uses these 64 characters:
A-Z a-z 0-9 + /
and pads the end with = so the length is always a multiple of 4.
The problem: +, /, and = all have special meaning in URLs and filenames. + becomes a space when form-decoded, / is a path separator, = is a query-string delimiter. So RFC 4648 §5 defines a URL-safe variant, Base64URL:
+ → -
/ → _
= → (dropped entirely)
This is what you find inside JWTs, in OAuth state params, in signed URLs, and in a lot of API tokens. It looks like Base64, so people feed it to atob() — which only understands the standard alphabet — and it breaks.
// A JWT payload segment (Base64URL)
const seg = "eyJ1c2VyIjoiZGFuIiwicm9sZSI6ImFkbWluIn0";
atob(seg);
// 💥 DOMException on the missing padding / '-' / '_'
The fix: normalize before you decode
Convert Base64URL back to standard Base64 first — swap the alphabet and restore the padding:
function fromBase64Url(input) {
// 1. restore the standard alphabet
let b64 = input.replace(/-/g, "+").replace(/_/g, "/");
// 2. re-pad to a multiple of 4
const pad = b64.length % 4;
if (pad) b64 += "=".repeat(4 - pad);
return atob(b64);
}
fromBase64Url("eyJ1c2VyIjoiZGFuIiwicm9sZSI6ImFkbWluIn0");
// '{"user":"dan","role":"admin"}' ✅
The padding math is the part people forget. Base64 encodes 3 bytes into 4 characters, so a valid string length is always 0 mod 4. Base64URL strips the =, leaving lengths of 2 mod 4 or 3 mod 4. atob() wants them back. (A remainder of 1 is never valid Base64 — if you see that, the string is genuinely corrupt.)
Encoding to Base64URL
Going the other way, encode as normal, then strip and swap:
function toBase64Url(bytes) {
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""); // drop trailing padding
}
toBase64Url(new TextEncoder().encode('{"user":"dan"}'));
// "eyJ1c2VyIjoiZGFuIn0"
Notice the TextEncoder step — if your input might contain non-ASCII text, you need to go through UTF-8 first or btoa will throw on anything above code point 255. That's a separate sharp edge worth knowing about, but the alphabet swap above is what makes it URL-safe.
Node.js gets this for free
If you're on the server, the Buffer API has a native base64url encoding — no manual padding required:
Buffer.from("eyJ1c2VyIjoiZGFuIn0", "base64url").toString(); // decode
Buffer.from('{"user":"dan"}').toString("base64url"); // encode
Worth remembering that this only exists in Node (14.18+), not in the browser — hence the manual helper above for front-end code.
Three things to remember
-
atob/btoaonly speak standard Base64. JWTs, OAuth params and signed URLs are usually Base64*URL* — normalize-→+,_→/, and re-pad before decoding. -
The padding is length-dependent: re-add
=until the length is a multiple of 4. A remainder of 1 means the data is actually broken. - Base64 (either flavour) is encoding, not encryption. A JWT payload is trivially readable — never put secrets in one and assume they're hidden.
I kept mixing up the two alphabets when eyeballing tokens, so I keep a small free Base64 encoder/decoder around that auto-detects the URL-safe variant and handles the padding for me.
Top comments (0)