Skip to content
Control Plane Labs

Base64 Encoding: Padding, URLs, and UTF-8

Learn how Base64 turns bytes into text, when padding matters, why base64url differs, and how to avoid common JavaScript UTF-8 decoding mistakes.

Control Plane Labs Staff

Published August 29, 2026

The Base64 encoder and decoder is useful when a payload crosses a text-only boundary: an HTTP header, JSON field, data URL, or configuration file. The result is not secret; choose encryption or an authenticated protocol when confidentiality matters.

How Base64 turns bytes into characters

RFC 4648 defines Base64 as a binary-to-text encoding. Its alphabet has 64 data characters: A-Z, a-z, 0-9, +, and /. A separate = character marks padding; it is not one of the 64 values. The encoder takes 24 bits at a time, splits them into four six-bit numbers, and maps each number to one alphabet character.

That 24-bit block explains the familiar size increase. Three bytes become four characters, before any line wrapping or transport overhead. If the input ends with one byte, the encoder emits two data characters and ==. If it ends with two bytes, it emits three data characters and =. An input whose byte length is divisible by three needs no padding. The standard also requires unused pad bits to be zero, which keeps a valid encoding canonical rather than allowing multiple spellings of the same bytes.

Padding is a format rule, not data. RFC 4648 says encoders include it unless the protocol explicitly permits omission. A decoder should not silently treat arbitrary characters as harmless; the protocol must define whether whitespace or other non-alphabet characters are allowed.

Standard Base64 versus base64url

Standard Base64 is a poor fit for a URL path or filename because its alphabet contains + and /. The RFC 4648 URL- and filename-safe variant replaces + with - and / with _. The encoded bytes do not change; only the characters used to spell the six-bit values change.

Padding still depends on the protocol. A URL-safe format may keep = and percent-encode it, or it may omit padding when the receiving side knows how to restore it. Do not remove padding just because a decoder you tried accepted the short form. Record the variant and padding rule at the interface boundary.

JSON Web Signatures use the unpadded form. RFC 7515 specifies Base64url with all trailing = characters omitted, with no line breaks or extra whitespace. When converting an unpadded Base64url value for a strict standard decoder, first change - to + and _ to /, then add padding until the length is a multiple of four. A length remainder of one is malformed; a remainder of two needs ==, and a remainder of three needs =.

Variant Alphabet ending Padding Typical boundary
Standard Base64 + and / Usually required JSON fields, data URLs, text-oriented protocols
Base64url - and _ Defined by the protocol; often omitted URL values, filenames, JWS and JWT segments

The JavaScript byte boundary

The browser APIs are easy to call and easy to misuse. The WHATWG definition of btoa() and atob() works with strings whose characters represent one byte. btoa() rejects a character above U+00FF, while atob() returns a string in which each character represents one decoded byte. MDN’s btoa() documentation therefore recommends converting arbitrary text to UTF-8 bytes first.

That distinction matters for café, emoji, and every other non-Latin-1 string. JavaScript strings are not already a sequence of UTF-8 bytes. Use TextEncoder before encoding and TextDecoder after decoding:

function bytesToBase64(bytes) {
  let binary = "";
  for (const byte of bytes) binary += String.fromCodePoint(byte);
  return btoa(binary);
}

function base64ToBytes(value) {
  const binary = atob(value);
  return Uint8Array.from(binary, (character) => character.codePointAt(0));
}

const encoded = bytesToBase64(new TextEncoder().encode("café"));
const text = new TextDecoder().decode(base64ToBytes(encoded));

Passing Unicode straight to btoa() either throws or produces bytes different from the UTF-8 sequence a server expects. Distinguish text output from arbitrary bytes: offer a decoded PNG or archive as a file, not forced into text.

Runtime APIs and strictness differ

The same spelling does not guarantee the same decoder behavior in every language. Node’s Buffer documentation supports both base64 and base64url; its decoder accepts the URL-safe alphabet and ignores whitespace, while base64url encoding omits padding. That leniency is convenient for inspection, not validation of a protocol field.

Python’s base64 module exposes standard and URL-safe functions. Its b64decode() discards non-alphabet characters by default before checking padding; pass validate=True when a strictly alphabetic input is required. urlsafe_b64encode() swaps the two alphabet characters but can still return = padding, so the function name alone does not tell you the complete wire format.

Go’s encoding/base64 package makes the choice explicit with StdEncoding, URLEncoding, RawStdEncoding, and RawURLEncoding. Its Strict() form checks that trailing padding bits are zero, and its streaming encoder must be closed so a partial final block is flushed. These differences are why a cross-language test vector should include the input bytes, alphabet, padding policy, and expected decoded bytes.

A five-minute Base64 debugging routine

  1. Name the bytes. Decide whether the input is UTF-8 text, file bytes, a serialized object, or an already-encoded value. Include or exclude trailing newlines deliberately.
  2. Name the variant. Standard Base64 and Base64url are not interchangeable when the value contains the two changed alphabet characters. For JWT or JWS, use the protocol’s unpadded Base64url rule.
  3. Check the length. Standard output normally has a character count divisible by four. For an unpadded URL-safe value, a length remainder of one cannot be repaired by adding padding.
  4. Compare bytes, not appearances. Decode in two runtimes and compare the byte sequence or a hexadecimal dump. A different newline, Unicode conversion, or file mode can change the bytes before the encoding step.
  5. Separate decoding from trust. A successful decode says only that the bytes matched the selected syntax. It does not decrypt the value, validate a JWT signature, or authenticate the sender.

For a quick local check, use the Base64 encoder and decoder and then compare the result with the runtime that will consume it. Use the URL encoder/decoder only for a separate percent-encoding layer; URL encoding is not a substitute for choosing Base64url.

FAQ

Frequently asked questions

Is Base64 encryption?+
No. Base64 is a reversible representation of bytes. It provides no confidentiality or authentication; use encryption or a signed and authenticated protocol when the data needs protection.
Why does Base64 end with one or two equals signs?+
Base64 maps three input bytes to four characters. When the final input group has one or two bytes, equals signs fill the unused character positions so the standard output length is a multiple of four.
What is the difference between Base64 and Base64url?+
Base64url replaces + with - and / with _. Its padding rule is defined by the protocol and is often unpadded in URL-oriented formats such as JWS and JWT segments.
Why does btoa() fail on emoji?+
btoa() accepts a binary string whose characters represent single-byte values. Convert the text to UTF-8 with TextEncoder before turning the bytes into a binary string for btoa().
Can I decode a JWT payload to verify it?+
No. Decoding reveals the encoded bytes, but it does not verify the signature, issuer, audience, or expiry. Use a JWT library and a trusted key when verification is required.
Should a Base64 decoder ignore whitespace?+
Only when the format permits it. Node's decoder accepts whitespace and Python's default b64decode behavior is lenient, while a protocol validator may need strict alphabet and padding checks.

Tags: #base64, #base64url, #utf-8, #javascript, #web-development