Base64 vs Base64url: The Two Characters That Differ, Padding, and Converting Between Them
Base64url is standard Base64 with two characters swapped and, usually, the padding removed. That small difference is enough to make a JWT fail verification, a download link return 404, or a decoder throw "invalid character". Knowing exactly what changes, and which format each system expects, turns those errors into a one-line fix.
The difference in one table
RFC 4648 defines both. They share the first 62 characters: A–Z, a–z and 0–9. They differ only in the last two characters of the alphabet and in padding.
Base64 (section 4) Base64url (section 5)
value 62 + -
value 63 / _
padding = required = usually omitted
safe in URLs no (+ / = are special) yes
safe in names no (/ is a separator) yes
The same bytes therefore encode to almost the same string, differing only where + or / would appear, and at the end.
bytes : FB FF BF
base64 : +/+/
base64url : -_-_
text : subjects?_d
base64 : c3ViamVjdHM/X2Q=
base64url : c3ViamVjdHM_X2Q
Why Base64url exists
In a URL, + in a query string is decoded as a space by form decoders, / separates path segments, and = separates keys from values. A standard Base64 value placed in a URL without percent-encoding is corrupted silently: a+b arrives as a b, and the decoder then fails, or worse, decodes different bytes.
Percent-encoding would fix it (+ becomes %2B, / becomes %2F), but it makes values longer and easy to double-encode. Base64url avoids the problem by using two characters that never need escaping. Dropping the padding saves characters and avoids = in query strings; the length of the string tells the decoder how much padding there would have been.
The same reasoning applies to filenames and object keys: / is a directory separator, so standard Base64 cannot safely be used in a filename or an S3 key.
Where each one is used
Base64url, no padding: JWT header, payload and signature (RFC 7515 requires it); PKCE code_verifier and code_challenge in OAuth; WebAuthn credential IDs and challenges; JSON Web Keys (n, e, x, y); many URL-shortener IDs and signed download tokens.
Standard Base64 with padding: data URIs (data:image/png;base64,…); MIME email attachments, which also wrap at 76 characters; HTTP Basic authentication; PEM certificates and keys between their BEGIN/END lines; Kubernetes Secret values; most JSON APIs that carry binary fields.
When a specification does not say which it uses, look at a real example. Any - or _ means Base64url, and any + or / means standard Base64. If a string has neither, both decoders give the same result.
Converting between them
Conversion is mechanical: swap the two characters, then remove or restore padding. Padding is restored by adding = until the length is a multiple of 4.
const toBase64url = (b64) =>
b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const toBase64 = (b64url) => {
const s = b64url.replace(/-/g, '+').replace(/_/g, '/');
return s + '='.repeat((4 - (s.length % 4)) % 4);
};
// Node.js understands base64url directly
Buffer.from('hello?', 'utf8').toString('base64url'); // 'aGVsbG8_'
Buffer.from('aGVsbG8_', 'base64url').toString('utf8'); // 'hello?'
import base64
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
def b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('ascii')
# Shell: decode a JWT payload (Base64url, no padding)
payload=$(printf '%s' "$JWT" | cut -d. -f2 | tr '_-' '/+')
while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done
printf '%s' "$payload" | base64 -d
A remainder of 1 is impossible: no valid Base64 string has a length of 4n + 1. If you hit that case, characters were lost, usually from truncation or from a copy that dropped the last character.
Bugs caused by mixing them
Decoding Base64url with a strict standard decoder fails on the first - or _, or, in lenient decoders such as PHP's default base64_decode, silently skips those characters and returns the wrong bytes. That second case is worse, because nothing reports an error.
Putting standard Base64 in a query string without encoding it turns each + into a space. A server that then decodes a b may fail, or accept a value that differs from what was signed. If you must use standard Base64 in a URL, encode it with encodeURIComponent, or switch to Base64url.
Comparing encoded values as strings breaks when one side keeps padding and the other does not: YQ== and YQ are the same byte. Decode both sides and compare the bytes, or normalise to one format first.
Verifying a JWT signature by recomputing it with standard Base64 produces a string that differs from the token in +, / and =, so a correct signature looks wrong. JWS always uses Base64url without padding.
Generating URL-safe random tokens
Base64url is the usual way to turn random bytes into a compact token for reset links, API keys, invite codes or CSRF values. Generate the bytes with a cryptographically secure source, then encode. 32 random bytes give 256 bits of entropy in 43 characters.
// Node.js
require('node:crypto').randomBytes(32).toString('base64url');
# Python
import secrets
secrets.token_urlsafe(32) # 32 random bytes, Base64url, no padding
# Shell
openssl rand 32 | base64 | tr '+/' '-_' | tr -d '='
Do not build tokens from Math.random() or timestamps, however they are encoded. The encoding only changes how the value looks; the security comes from the randomness of the bytes underneath.
Other variants you may meet
MIME Base64 (RFC 2045) is standard Base64 with a line break every 76 characters. Strip whitespace before decoding it elsewhere. Some systems use . or ~ as replacement characters instead of - and _; these are non-standard and need their own mapping. Base32 and Base58 are different encodings entirely: Base32 is case-insensitive and used for TOTP secrets, and Base58 omits look-alike characters for Bitcoin addresses.
Sources and further reading
RFC 4648 defines Base64 (section 4) and the URL- and filename-safe alphabet (section 5), including when padding may be omitted. RFC 7515 requires Base64url without padding in JWS, and RFC 7636 (PKCE) uses it for code challenges. RFC 2045 defines MIME Base64 line wrapping. The Node.js Buffer documentation describes the base64url encoding.
Frequently asked questions
What is the difference between Base64 and Base64url?
Base64url replaces `+` with `-` and `/` with `_`, and usually omits the `=` padding. Everything else is identical, so conversion is a character swap plus adding or removing padding.
Why do JWTs use Base64url?
Tokens are often passed in URLs, headers and cookies, where `+`, `/` and `=` would need escaping or be misread. RFC 7515 requires unpadded Base64url for all three parts of a JWS.
How do I decode Base64url?
Replace `-` with `+` and `_` with `/`, add `=` until the length is a multiple of 4, then use a normal Base64 decoder. Node.js can decode it directly with `Buffer.from(s, 'base64url')`.
Is Base64url safe to use in URLs and filenames?
Yes. Its alphabet is letters, digits, `-` and `_`, none of which need percent-encoding or act as separators. Without padding it also contains no `=`.
Why does my Base64 string break in a query parameter?
A `+` in a query string is decoded as a space, and `/` or `=` can be misread. Percent-encode the value with `encodeURIComponent`, or use Base64url instead.
How can I tell which variant a string uses?
Look for the distinguishing characters: `-` or `_` mean Base64url, and `+` or `/` mean standard Base64. Padding with `=` suggests standard Base64, but its absence alone proves nothing.