Base64 vs base64url
Two alphabets that differ in exactly two characters, and the handful of places where that difference silently corrupts a token.
Updated 2026-09-05
Two alphabets, two characters apart
RFC 4648 defines both encodings. Section 4 is "Base 64 Encoding", the one everyone means by base64. Section 5 is "Base 64 Encoding with URL and Filename Safe Alphabet", usually written base64url.
The first sixty-two symbols are identical in both: A through Z for values 0 to 25, a through z for 26 to 51, and 0 through 9 for 52 to 61. Only the last two differ. Standard base64 uses plus for 62 and forward slash for 63. base64url uses hyphen-minus for 62 and underscore for 63. Nothing else changes. The bit packing, the group size, the expansion ratio and the decoding logic are the same.
Because only two of sixty-four symbols differ, a value encoded in one alphabet has roughly a 3.1 percent chance per character of containing a symbol that means something different in the other. Over a 43-character token that works out to a better than even chance of containing at least one. This is why the bug is intermittent, and why it reaches production.
| Value | Symbols | Section 4 | Section 5 |
|---|---|---|---|
| 0 to 25 | A to Z | Same | Same |
| 26 to 51 | a to z | Same | Same |
| 52 to 61 | 0 to 9 | Same | Same |
| 62 | - | + | - |
| 63 | - | / | _ |
| Padding | - | = (required) | = (usually omitted) |
Padding, and when to drop it
Base64 works on three-byte groups, each producing four characters. When the input length is not a multiple of three, the final group is short and the encoder emits one or two equals signs to bring the output to a multiple of four. One leftover byte gives two padding characters, two leftover bytes give one, and a full group gives none.
Padding exists so that concatenated base64 streams can be split again. In a URL or a JWT there is no concatenation to disambiguate, and the equals sign is itself a reserved character in a query string, so base64url output is conventionally emitted without it. Go names this explicitly: RawURLEncoding is the unpadded variant and URLEncoding is the padded one.
Decoders vary in how forgiving they are. Node accepts unpadded input in base64url mode. Python's urlsafe_b64decode does not, and raises on incorrect padding, so you have to append the right number of equals signs before calling it. When you write a decoder wrapper, restore the padding rather than hoping.
JWT is unambiguous on this point: every segment is unpadded base64url. A padded segment is not a valid JWT segment, and a segment containing a plus or a slash is not either.
Why base64url exists
Three specific places where the standard alphabet fails.
A query string is the sharpest of them. In application/x-www-form-urlencoded, a plus sign decodes to a space. A token containing a plus, dropped unencoded into a query parameter, comes out the other end as a different string, with no error anywhere in the chain. The value simply stops matching, intermittently, for the fraction of tokens that happened to contain a plus. This is the most common encoding-related token failure there is.
A path segment is the second. The forward slash is the path separator, so a token containing one either has to be percent-encoded or it splits the route. Percent-encoding works but survives poorly through proxies, redirects and copy-paste.
A filename is the third, and it is the reason the RFC section title mentions filenames at all. A slash cannot appear in a filename on any POSIX system, and a plus is legal but awkward in shell contexts. Using base64url for anything that might become a filename removes both problems.
The general rule: if the value will ever be typed, pasted, routed or stored under its own name, use section 5. Reserve section 4 for byte streams inside a container that already handles arbitrary characters, such as a MIME body or a JSON string.
Size: the 4/3 expansion
Base64 turns three bytes into four characters, so the output is 4/3 the size of the input, about 33 percent overhead. With padding the exact unpadded length is the ceiling of four times the byte count divided by three; padded output is always rounded up to a multiple of four.
That is substantially better than hex, which produces two characters per byte for a flat 100 percent overhead. The tradeoff is that hex gives exactly four bits per character and is trivially readable, while base64 gives exactly six and is not.
Both are exact powers of two per character, which has a practical consequence for random generation: 16 and 64 are the alphabet sizes where rejection sampling costs literally nothing, because a random draw maps onto them with no remainder. Generating a random base64url string by masking off six bits at a time is perfectly uniform with zero rejections.
| Random bytes | Bits | Hex chars | base64url chars, unpadded | base64 chars, padded |
|---|---|---|---|---|
| 8 | 64 | 16 | 11 | 12 |
| 16 | 128 | 32 | 22 | 24 |
| 24 | 192 | 48 | 32 | 32 |
| 32 | 256 | 64 | 43 | 44 |
| 64 | 512 | 128 | 86 | 88 |
The RFC test vectors
RFC 4648 section 10 gives a test vector set that any implementation should reproduce exactly. It is worth pasting into your test suite, because it exercises all three padding cases in six lines.
Note something about these vectors: none of them produces the symbol at index 62 or 63, so the base64 and base64url encodings of every one of them are identical. That is precisely why they do not catch the alphabet bug. To test that, you need an input that reaches the top of the table: the three bytes 0xFB 0xFF 0xBF encode to "+/+/" in section 4 and "-_-_" in section 5.
| Input | Output | Padding |
|---|---|---|
| "" | "" | none |
| "f" | Zg== | two |
| "fo" | Zm8= | one |
| "foo" | Zm9v | none |
| "foob" | Zm9vYg== | two |
| "fooba" | Zm9vYmE= | one |
| "foobar" | Zm9vYmFy | none |
JavaScript, where this gets awkward
The browser has btoa and atob, and neither of them handles text. btoa takes what the specification calls a binary string, meaning a string in which every code unit is in the range 0 to 255. Pass it anything above U+00FF and it throws InvalidCharacterError. The word "cafe" with an acute accent is enough to break it.
The workaround is to encode to UTF-8 bytes first with TextEncoder, map those bytes to a binary string, and then call btoa. Decoding reverses it: atob, then Uint8Array.from over the code units, then TextDecoder. It is four lines each way and it is the correct answer; a great deal of code in the wild uses unescape and encodeURIComponent instead, which happens to work and relies on a deprecated function.
Node avoids all of this. Buffer.from(value).toString("base64url") gives unpadded base64url directly, and Buffer.from(token, "base64url") decodes it, accepting both alphabets and tolerating missing padding. If you are writing code that runs in both places, the browser path is the one that needs the care.
One more: if you are generating random base64url rather than encoding bytes, do not encode. Draw characters uniformly from the 64-symbol alphabet. That gives exactly six bits per character with no rejection and no padding-bit artefact, and a 22-character value carries 132 bits rather than the 128 you would get from encoding 16 bytes.
Encoded random bytes and random base64url characters are different objects. Base64 of any 16-byte value always ends in one of A, Q, g or w; base64 of any 32-byte value always ends in one of sixteen specific characters. A uniformly random 22-character base64url string is not canonical base64, and a strict decoder will reject it.
Doing this in your own code
JavaScript, browser
// btoa takes a "binary string": every code unit must be 0-255.
// Anything above U+00FF throws InvalidCharacterError.
btoa("hello"); // "aGVsbG8="
btoa("caf\u00e9"); // "Y2Fm6Q==" - does NOT throw, because U+00E9 fits in
// a byte, but those are Latin-1 bytes, not the UTF-8
// every other part of your stack is expecting
btoa("\u20ac"); // throws InvalidCharacterError - the euro sign is U+20AC
// Encode text as UTF-8 first, then map bytes to code units:
function b64url(str) {
const bytes = new TextEncoder().encode(str);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// And back:
function fromB64url(s) {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
} atob and btoa are byte codecs wearing a string interface. Every non-ASCII bug in this area comes from forgetting that.
Node.js
// Node has a real base64url codec, so none of the replace() dance
// is necessary.
Buffer.from(s, "utf8").toString("base64url");
Buffer.from(token, "base64url").toString("utf8");
// The decoder is lenient: it accepts both alphabets and tolerates
// missing padding, so it will happily decode a standard-alphabet
// string you passed in by mistake.
Buffer.from("Zm9vYmFy", "base64url").toString(); // "foobar" Python
import base64
base64.b64encode(b"foobar") # b'Zm9vYmFy' RFC 4648 section 4
base64.urlsafe_b64encode(b"\xfb\xff\xbf") # b'-_-_' section 5
# urlsafe_b64decode requires correct padding. Restore it before decoding:
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) Python keeps the padding on urlsafe_b64encode output. Strip it yourself before putting the value in a URL.
Go
import "encoding/base64"
base64.StdEncoding.EncodeToString(b) // section 4, padded
base64.RawStdEncoding.EncodeToString(b) // section 4, no padding
base64.URLEncoding.EncodeToString(b) // section 5, padded
base64.RawURLEncoding.EncodeToString(b) // section 5, no padding - use this
// JWT segments are RawURLEncoding. A padded segment is invalid. Questions
Can I just strip the padding from standard base64 to get base64url?
No. Padding is one of two differences. You also have to translate plus to hyphen and slash to underscore. Stripping padding alone leaves you with a value that breaks in a query string roughly two thirds of the time at token lengths, which is worse than failing every time because it will pass your tests.
Is base64 encryption?
No, and this is worth saying plainly because it appears in real incident reports. Base64 is a reversible encoding with no key. Anything encoded in it is readable by anyone who recognises the format, which is everyone. It changes the character set a value occupies and nothing else.
Why does my JWT fail to verify after a round trip through a URL?
Almost always because a segment was re-encoded or a plus was turned into a space. JWT segments are unpadded base64url and must be transported byte for byte. If the token passed through a form-encoded parameter without percent-encoding, any plus in it is now a space and the signature check fails. The fact that JWT specifies base64url exists to prevent exactly this, so a failure here usually means something in the chain re-encoded the token into the standard alphabet.
How many characters do I need for 128 bits?
Twenty-two base64url characters drawn uniformly gives 132 bits. Encoding 16 random bytes gives 22 characters carrying 128 bits, because the last character is constrained to four of the sixty-four values. Either is above 128 bits; they are just not the same number, and quoting 132 for encoded bytes is the common error.
Should I use base64url or hex for a token?
base64url if length matters, hex if readability does. 128 bits is 22 base64url characters or 32 hex characters. Hex has the advantage that four bits per character makes the arithmetic trivial and there is only one alphabet, so there is no equivalent of the plus-to-space bug. For values a human will read aloud or retype, neither is a good choice; use an unambiguous alphabet instead.