Developer
Base64, URL encoding and hex: what each encoding is for
Base64, hex and percent-encoding all turn data into text, and they get swapped for one another constantly. This guide settles which one fits which job, what each adds in size, and why the same text can encode two different ways depending on the byte step you skipped. It is for developers who have hit a garbled accent, a 400 error from a stray plus sign, or a token that decodes in one library and fails in another.
By the MaffeiTools team · Updated · 9 min read
An encoding changes the alphabet, not the secrecy
An encoding is a reversible mapping from bytes to a restricted set of characters, chosen so the data survives a channel that only tolerates those characters: an email body, a URL, a JSON string, a filename. Nothing in the mapping involves a key, so anyone who recognizes the format can undo it in one step.
RFC 4648, the specification for Base64, Base32 and Base16, says this directly in its security section: base encoding visually hides recognizable information such as passwords but provides no computational confidentiality, and it notes that this has caused real incidents when people pasted an encoded credential into a bug report believing it was obscured. The practical rule is to treat an encoded value exactly as sensitive as the value inside it.
The family splits into two questions. Base64, Base32 and hex answer how to write arbitrary bytes using safe characters. Percent-encoding answers how to write characters inside a URL without colliding with the URL's own punctuation. They solve different problems, which is why one cannot stand in for the other.
How Base64 packs three bytes into four characters
Base64 reads the input three bytes (24 bits) at a time and splits those bits into four 6-bit groups. Each group is a number from 0 to 63 that indexes the alphabet: A to Z, a to z, 0 to 9, then plus and slash. When the input length is not a multiple of three, the last group is filled out and the output is padded with one or two equals signs so its length is a multiple of four. Output length is therefore four times the input length divided by three, rounded up, a 33 percent increase that padding rounds up slightly.
Base32 works the same way with 5-bit groups: five bytes become eight characters from the alphabet A to Z and 2 to 7, which is a 60 percent increase, and padding goes to a multiple of eight. RFC 4648 describes it as designed for cases where the text must be case insensitive but need not be human readable. Hex doubles the size, since every byte becomes two characters, and needs no padding.
The padding matters less than it looks. RFC 4648 requires encoders to include it unless the referring specification says otherwise, and lenient decoders accept its absence, but a strict decoder will reject a value whose length is not a multiple of four. If a Base64 string fails in one library and works in another, padding and alphabet are the first two things to compare.
input base64 base32 hex f Zg== MY====== 66 fo Zm8= MZXQ==== 666f foo Zm9v MZXW6=== 666f6f foob Zm9vYg== MZXW6YQ= 666f6f62 fooba Zm9vYmE= MZXW6YTB 666f6f6261 foobar Zm9vYmFy MZXW6YTBOI====== 666f6f626172 3000 bytes -> 4000 Base64 characters 3001 bytes -> 4004 Base64 characters
Base64url: the same bits with a different last two characters
Standard Base64 uses plus and slash as its 63rd and 64th characters. Both are trouble in a URL: slash is a path delimiter, and plus is read as a space in query strings. RFC 4648 section 5 defines base64url, which replaces them with minus and underscore, and notes that the padding equals sign is usually percent-encoded in URIs, so it can be dropped when the length is known from context. JSON Web Tokens are the best-known user of this variant.
The two variants are not interchangeable, and the failure is loud in some runtimes and silent in others. In a browser, atob throws an InvalidCharacterError on a minus or underscore, while it accepts a standard string with its padding stripped. So a token pasted straight into a decoder built on atob works only after minus is mapped back to plus, underscore to slash, and the length is restored.
The MaffeiTools Base64 encoder has a URL-safe switch that produces base64url, and a separate padding switch that starts off for base64url, since dropping the equals signs is the usual convention, and on for standard output. The decoder accepts both alphabets, with or without padding, and says which one it detected. It rejects a string that mixes plus or slash with minus or underscore, because no encoder produces that, and it names the offending character for anything outside either alphabet.
bytes: 0xfb 0xff 0xfe
standard: +//+
base64url: -__-
atob("-_-_") -> throws InvalidCharacterError
atob("+/+/") -> decodes
tool encode of "߿߿?>" standard -> 37/fvz8+
tool encode of "߿߿?>" URL-safe, no padding -> 37_fvz8-
tool encode of "😀" standard -> 8J+YgA==
tool encode of "😀" URL-safe, no padding -> 8J-YgA
tool decode of "37_fvz8-" and of "37/fvz8+" -> ߿߿?> (alphabet detected: url, standard)
tool decode of "Pz4_Pz4/" -> error: mixes both alphabetsWhy btoa fails on the euro sign and quietly mangles an accent
Base64 encodes bytes, but a JavaScript string is a sequence of UTF-16 code units. The browser function btoa bridges the two by treating each character as one byte, which is only valid for code points up to 255. MDN documents the consequence: a character that does not fit in one byte raises an InvalidCharacterError.
The dangerous case is the range between accented Latin letters and everything else. The letter e with an acute accent is code point 233, which fits in a byte, so btoa succeeds, but it encodes the Latin-1 byte 0xe9, not the UTF-8 pair 0xc3 0xa9 that almost every other system uses. The result is valid Base64 that decodes to different bytes than a Python or Java encoder would produce for the same word. The euro sign, code point 8364, does not fit and throws.
The fix is to convert the text to UTF-8 bytes first and encode those. The MaffeiTools Base64 tool does this with TextEncoder on encode and a strict TextDecoder on decode, so accented and non-Latin text round-trips and bytes that are not valid UTF-8 are reported instead of being turned into replacement characters. The older encodeURIComponent-then-unescape idiom achieves the same for encoding but is deprecated, so TextEncoder is the clearer choice in your own code. The agreement that matters is between the two ends: both must decide on UTF-8 before Base64 is applied.
btoa("café") -> Y2Fm6Q== (Latin-1: 63 61 66 e9)
btoa("€") -> throws InvalidCharacterError
tool encode of "café" -> Y2Fmw6k= (UTF-8: 63 61 66 c3 a9)
tool encode of "€5" -> 4oKsNQ== (UTF-8: e2 82 ac 35)
tool encode of "héllo wörld" -> aMOpbGxvIHfDtnJsZA==Worth knowing. If decoded text shows a capital A with a tilde followed by a symbol where an accented letter should be, the bytes were UTF-8 but were read as Latin-1. The encoded value is fine; the reader picked the wrong character set.
Percent-encoding: reserved characters, unreserved characters and the two meanings of the plus sign
RFC 3986 divides URL characters into three groups. Unreserved characters, the letters, digits, hyphen, period, underscore and tilde, never need encoding. Reserved characters have a delimiting job: the general delimiters are colon, slash, question mark, number sign, square brackets and at sign, and the sub-delimiters include ampersand, equals, plus, comma, semicolon, dollar, parentheses, apostrophe, asterisk and exclamation mark. Everything else, including space and every non-ASCII character, must be percent-encoded as a percent sign followed by the two hex digits of each UTF-8 byte.
Encoding depends on where the text is going. A reserved character has to be encoded only when it appears as data where it would otherwise be read as a delimiter: an ampersand inside a query value, a slash inside a single path segment. RFC 3986 states that encoding or decoding a reserved character changes how the URL is interpreted, so applying one blanket encode to a whole URL, or decoding one twice, breaks it. That is the difference between JavaScript's encodeURI, which leaves delimiters alone and is meant for a complete URL, and encodeURIComponent, which encodes them and is meant for one piece. The MaffeiTools URL tools use encodeURIComponent, and the WHATWG URL Standard states that its component set with UTF-8 gives results identical to it.
Space is where the plus sign confuses people. In a URL, a space is percent-encoded as %20, and a plus is an ordinary sub-delimiter. Only in the application/x-www-form-urlencoded format, the one HTML forms use and URLSearchParams produces, is a space written as a plus, and the WHATWG parsing algorithm replaces each plus with a space before percent-decoding. So the same query string decodes differently depending on which parser reads it, and decodeURIComponent never converts a plus at all.
input name=Jürgen & Co/100% (a+b) ~*'!
encodeURIComponent output name%3DJ%C3%BCrgen%20%26%20Co%2F100%25%20(a%2Bb)%20~*'!
encodeURI output name=J%C3%BCrgen%20&%20Co/100%25%20(a+b)%20~*'!
encodeURIComponent("€") %E2%82%AC
encodeURIComponent("😀") %F0%9F%98%80
encodeURIComponent applied twice to "100% sure": 100%2525%2520sure
decodeURIComponent("a+b%20c") -> a+b c
new URLSearchParams("q=a+b%20c").get("q") -> a b cWorth knowing. Decoding a lone percent sign, as in the string 100%, throws a URIError in decodeURIComponent because the percent is not followed by two hex digits. That error nearly always means the text was never encoded, or was decoded once too often.
Hex converters and which bytes they show
Hex is the most transparent encoding: each byte is two digits, so a hex dump is easy to compare against a spec or a hash. The catch, again, is which bytes. A hex representation of text is only meaningful together with the character set that produced the bytes, and for interoperable systems that is nearly always UTF-8.
The MaffeiTools hex converter encodes the text as UTF-8 first and prints every byte as two digits, and on decode it reads the digits back as bytes and decodes them as UTF-8. For ASCII that is one byte per character. The acute e prints as c3 a9, the euro sign as e2 82 ac and an emoji as four bytes, which is what other UTF-8 systems produce, and pasting c3a9 into its decode side returns one accented letter. Spaces, commas, line breaks and 0x prefixes are accepted as separators.
It reports an error instead of guessing when the input cannot be bytes: an odd number of digits in a group, a character outside 0-9 and a-f, or bytes that are not valid UTF-8 text, such as the pair ff fe. A value written as UTF-16 code units, like 20ac for the euro sign, therefore fails on decode rather than producing garbage. The Base32 tool takes the same route: it UTF-8-encodes text before encoding and rejects decoded bytes that are not valid UTF-8.
text hex converter UTF-16 code units (naive) Hi 0x48 0x69 0x48 0x69 é 0xc3 0xa9 0xe9 € 0xe2 0x82 0xac 0x20ac 😀 0xf0 0x9f 0x98 0x80 0xd83d 0xde00 decode "c3a9" -> é decode "f09f9880" -> 😀 decode "486" -> error: "486" has an odd number of digits (3 digits) decode "48 zz" -> error: "z" is not a hexadecimal digit decode "ff fe" -> error: not valid UTF-8 text decode "20ac" -> error: not valid UTF-8 text
Data URIs and which encoding to pick for which channel
A data URI embeds content in a URL. RFC 2397 defines the form data, an optional media type, an optional base64 marker, a comma, then the data. Base64 is used for binary content, and without the marker the payload is percent-encoded text. Inlining a small image this way saves a request but grows it by a third and cannot be cached separately from the page that contains it, so it suits tiny assets rather than photographs. The Image to Base64 tool returns the raw Base64 without the data prefix; you add the media type yourself.
The choice usually follows from the channel the data has to cross rather than from taste, and this list covers the common cases.
- Bytes into JSON, XML or an email body: standard Base64 with padding, after encoding text as UTF-8.
- Bytes in a URL path, query value, cookie or filename: base64url, usually without padding. Percent-encoding standard Base64 also works, but turns each plus, slash and equals sign into three characters.
- Values typed by hand, read aloud or passed through case-insensitive systems: Base32, whose alphabet leaves out 0, 1, 8 and 9 and has no lowercase form.
- Comparing or debugging bytes, hashes and keys: hex, since two digits map to exactly one byte.
- Any text placed inside a URL component: encodeURIComponent, never Base64.
- Building a query string from an object: URLSearchParams, which handles the form-encoded plus for you.
Common failures and how to check for them
Double encoding shows up as percent signs followed by 25 in a URL, or as Base64 that decodes to more Base64. Decode once and look at the result: if it still looks encoded, some layer encoded twice. The opposite bug, decoding a value that a framework already decoded, breaks any value containing a literal percent sign or plus.
A Base64 payload that decodes to unreadable bytes may be binary rather than text, such as a compressed or encrypted blob. Decoding it to a string in the Base64 tool reports an error for input that is not valid UTF-8, which is a hint about the content rather than about the encoding. Line breaks inside long Base64, added by some mail and PEM formats, need removing before strict decoders will accept it, and RFC 4648 recommends rejecting non-alphabet characters rather than skipping them, so do not assume every library ignores whitespace.
When two systems disagree, compare bytes rather than strings. Encode the same word with each side's tool, convert the results to hex, and look for the first differing byte. A c3 a9 against e9 mismatch identifies the character-set step at once, and a plus against a space identifies the form-encoding step.