Base64 and Base64URL encode the same bytes with different alphabets. Standard Base64 uses + and /; Base64URL uses - and _. The difference looks small until a token goes into a URL or a JWT and one character changes meaning. This guide shows the exact mapping, when padding is optional, and how to decode both forms without guessing.
The examples were checked with Node.js 22.23.4. The browser helpers use btoa and atob, which are available in current browsers.
The same bytes, two alphabets
These three bytes are not valid UTF-8 text, so they are a useful test:
const bytes = Uint8Array.from([0xfb, 0xff, 0xbf]);
Buffer.from(bytes).toString("base64"); // "+/+/"
Buffer.from(bytes).toString("base64url"); // "-_-_"Only two characters change:
| Value | Standard Base64 | Base64URL |
|---|---|---|
| 62 | + | - |
| 63 | / | _ |
| Padding | = | Often omitted |
Why URLs need the other alphabet
In a URL, + can mean a space in form-encoded query data, and / separates path segments. A standard Base64 value can therefore change when it is copied into a query string or a path. Base64URL avoids both characters.
JWT uses Base64URL for its header, payload and signature segments. That is why a JWT looks like eyJhbGciOi...eyJzdWIiOi...signature with dots and no = padding. See JWT decoding vs signature verification for what those segments do and do not prove.
Padding: required, optional, or rejected
Base64 groups every three bytes into four characters. When the input length is not a multiple of three, the encoder adds = characters so the output length is a multiple of four. The padding carries no data; it only marks the missing bytes.
Buffer.from("Ada").toString("base64"); // "QWRh"
Buffer.from("Ad").toString("base64"); // "QWQ="
Buffer.from("A").toString("base64"); // "QQ=="Some decoders require padding; others add it for you. Node's base64url decoder accepts both forms. A strict decoder in another language may reject the unpadded value, so add = before decoding when the API expects standard Base64.
Decoding in Node.js
const fromUrl = Buffer.from("-_-_", "base64url");
console.log([...fromUrl]); // [251, 255, 191]
const fromStandard = Buffer.from("+/+/", "base64");
console.log([...fromStandard]); // [251, 255, 191]Use the matching decoder. Decoding a Base64URL value as standard Base64 usually fails on the - or _, or returns the wrong bytes.
Decoding in the browser
atob and btoa only understand the standard alphabet. Convert before calling them:
function base64UrlToBytes(value) {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function bytesToBase64Url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}The loop avoids the argument limit of String.fromCharCode(...bytes) for large inputs. For text, encode to UTF-8 first with TextEncoder; btoa works on bytes, not Unicode characters.
Common mistakes
- Treating Base64 as encryption. Anyone can decode it. It does not protect passwords, tokens or private data.
- Putting standard Base64 in a URL. Use Base64URL, or percent-encode the value. Do not assume a decoder will accept both.
- Forgetting padding rules. Some decoders require
=; JWT and many URL-safe formats omit it. - Encoding text as bytes without UTF-8. Non-ASCII text changes length and bytes. Encode with
TextEncoderin the browser orBuffer.from(text, "utf8")in Node. - Decoding twice. A Base64URL string that still looks encoded after one decode was probably encoded twice. Fix the producer rather than stacking decoders.
Try it on your own value
The Base64 encoder and decoder handles standard Base64 and Base64URL in your browser, including UTF-8 text and files. To inspect a JWT's Base64URL segments without sending the token anywhere, use the JWT decoder. To see how the characters behave inside a query string, use the URL encoder.