Base64 vs Base64URL: Differences, Padding and Code
Quick answer
Base64URL is Base64 with two characters swapped: + becomes - and / becomes _ (RFC 4648 §5). Everything else, the 6-bit grouping, the first 62 characters, the 3-bytes-to-4-chars ratio, is identical. The = padding is required in standard Base64 but is commonly dropped in Base64URL when the length is known, which is why JWT segments never end in =. Converting between the two is a character replacement plus padding repair; the bytes never change.
Side-by-side reference
| Property | Base64 (RFC 4648 §4) | Base64URL (RFC 4648 §5) |
|---|---|---|
| Characters 0–61 | A–Z, a–z, 0–9 | A–Z, a–z, 0–9 (same) |
| Character 62 | + | - (minus) |
| Character 63 | / | _ (underscore) |
| Padding | = required (§3.2) unless the referencing spec says otherwise | = “can be avoided by skipping the padding” when the length is known implicitly |
| Safe in URL / query string | No, +, /, = need percent-encoding | Yes, when unpadded |
| Safe as a filename | No, / is a path separator | Yes |
| Typical uses | MIME email, data: URLs, Authorization: Basic, PEM certificates | JWT / JWS / JWK, OAuth PKCE code_challenge, WebAuthn IDs, URL tokens |
| Size overhead | 4 output chars per 3 input bytes (~33%) | Same, minus up to 2 padding chars |
RFC 4648 is explicit that the URL-safe variant “should not be regarded as the same as the ‘base64’ encoding and should not be referred to as only ‘base64’.” If an API says “base64” and rejects your input, the alphabet is the first thing to check.
Node.js: Buffer supports both encodings natively
The bytes FB FF BF are a handy test input because their Base64 form is exactly +/+/. The Node docs state that 'base64url'“will omit padding” when encoding, and that both decoders accept the other alphabet:
const bytes = Buffer.from([0xfb, 0xff, 0xbf]);
console.log(bytes.toString('base64')); // +/+/
console.log(bytes.toString('base64url')); // -_-_
const s = Buffer.from('hi');
console.log(s.toString('base64'), '|', s.toString('base64url')); // aGk= | aGk
// Decoding is lenient in both directions
console.log(Buffer.from('-_-_', 'base64').toString('hex')); // fbffbf
console.log(Buffer.from('+/+/', 'base64url').toString('hex')); // fbffbf
console.log(Buffer.from('aGk', 'base64url').toString()); // hi (unpadded is fine)Because Node is lenient, code that “works” there can fail on stricter decoders elsewhere. Always emit the variant the consumer documents.
Browser: btoa/atob only speak standard Base64
btoa() produces the standard alphabet with padding, and atob() expects it. Convert with two replacements and a padding step ((4 - len % 4) % 4 equals signs):
const toBase64Url = (b64) =>
b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const fromBase64Url = (u) => {
const b = u.replace(/-/g, '+').replace(/_/g, '/');
return b + '='.repeat((4 - (b.length % 4)) % 4);
};
const std = Buffer.from([0xfb, 0xff, 0xbf, 0xff]).toString('base64');
console.log(std); // +/+//w==
console.log(toBase64Url(std)); // -_-__w
console.log(fromBase64Url('-_-__w')); // +/+//w==
console.log(atob(fromBase64Url('aGk'))); // hibtoa() also throws on characters above U+00FF; encode text with TextEncoder first, as explained in What Is Base64 Encoding?.
Python: urlsafe_b64encode keeps the padding
The Python documentation for base64.urlsafe_b64encode says the result “can still contain =”, and urlsafe_b64decode raises on missing padding. Strip on the way out, restore on the way in:
import base64
raw = bytes([0xFB, 0xFF, 0xBF])
print(base64.b64encode(raw)) # b'+/+/'
print(base64.urlsafe_b64encode(raw)) # b'-_-_'
print(base64.urlsafe_b64encode(b"hi")) # b'aGk=' <- padding is kept
def b64url_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
print(b64url_encode(b"hi"), b64url_decode("aGk")) # aGk b'hi'
base64.urlsafe_b64decode("aGk")
# binascii.Error: Incorrect paddingOne more Python trap: b64decode(s) with the default validate=False silently discards characters outside its alphabet. Feeding it a Base64URL string does not convert - and _, it deletes them and returns wrong bytes. Use urlsafe_b64decode, or pass altchars=b"-_", or set validate=True to fail loudly.
Frequently Asked Questions
What is the difference between Base64 and Base64URL?
Only two alphabet characters and the padding convention differ. Standard Base64 (RFC 4648 section 4) uses + at position 62 and / at position 63; Base64URL (section 5) uses - and _ instead, so the output is safe inside URLs and filenames without percent-encoding. RFC 4648 requires = padding unless the referencing specification says otherwise; Base64URL is usually used without padding when the data length is known, which is what JWTs do.
Can I decode a Base64URL string with a standard Base64 decoder?
Not portably. Some decoders are lenient: Node.js Buffer.from(str, "base64") accepts the URL-safe alphabet, and Python's b64decode discards non-alphabet characters by default (which silently corrupts data rather than converting it). The safe approach is to convert explicitly: replace - with + and _ with /, then append = until the length is a multiple of 4.
Why do JWTs use Base64URL instead of Base64?
JWTs travel in Authorization headers, query strings and cookies. Standard Base64 output can contain +, / and =, all of which have special meaning in URLs and would need percent-encoding. Base64URL without padding produces only A-Z, a-z, 0-9, - and _, so the three token segments can be split on . and pasted anywhere unchanged.
Does Python's urlsafe_b64encode remove the padding?
No. The Python docs state that the result "can still contain =". If you need the unpadded form (for example when building a JWT), strip it yourself with .rstrip(b"="), and restore it before decoding with s + "=" * (-len(s) % 4).
Try it in the browser
The Base64 Encoder / Decoder encodes and decodes text and files locally. To see Base64URL in the wild, paste any token into the JWT Decoder, the header and payload segments are unpadded Base64URL.