In Node.js, Buffer.from(text).toString('base64url') encodes a string as base64url, and Buffer.from(encoded, 'base64url').toString() decodes it. Both read and write the text as UTF-8.
const text = '{"user":"Zoë","admin":true}';
const encoded = Buffer.from(text).toString('base64url');
// 'eyJ1c2VyIjoiWm_DqyIsImFkbWluIjp0cnVlfQ'
Buffer.from(encoded, 'base64url').toString();
// '{"user":"Zoë","admin":true}'
Base64url is RFC 4648 section 5: the same encoding as Base64, with - and _ in place of + and /. The = padding is usually left off as well, and JWTs (RFC 7515) must leave it off. The same JSON in standard Base64 is eyJ1c2VyIjoiWm/DqyIsImFkbWluIjp0cnVlfQ==. The tool below shows the base64url form, from the URL-safe (- _) alphabet.
In the browser
There is no base64url mode in btoa, so encode as standard Base64 and swap the characters. atob doesn’t need the padding back, so decoding only swaps them back:
function toBase64Url(text) {
const bytes = new TextEncoder().encode(text);
const base64 = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(encoded) {
const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
toBase64Url('{"user":"Zoë","admin":true}');
// 'eyJ1c2VyIjoiWm_DqyIsImFkbWluIjp0cnVlfQ'
Keep the g flag, or use replaceAll: replace('+', '-') with a string swaps only the first one. TextEncoder and TextDecoder are what keep ë intact; btoa alone would write it as a single Latin-1 byte.
Why not standard Base64 in a URL
+, / and = all mean something in a URL. In a query string, + is read as a space:
new URLSearchParams('t=ab+cd/ef==').get('t');
// 'ab cd/ef=='
encodeURIComponent keeps it safe by escaping, at the cost of length: eyJ1c2VyIjoiWm%2FDqyIsImFkbWluIjp0cnVlfQ%3D%3D. Base64url needs no escaping in a path, a query or a file name.
Gotchas
atobrejects base64url as it is:atob('eyJ1c2VyIjoiWm_DqyIsImFkbWluIjp0cnVlfQ')throwsInvalidCharacterErrorbecause of the_.Buffer.from(s, 'base64')and'base64url'each read both alphabets, so decoding works either way, but neither ever throws on bad input.- Some decoders, Python’s
base64.urlsafe_b64decodeamong them, need the padding restored first: add'='until the length divides by 4.
Recent browsers can skip the swapping: new TextEncoder().encode(text).toBase64({ alphabet: 'base64url', omitPadding: true }) encodes, and Uint8Array.fromBase64(encoded, { alphabet: 'base64url' }) gives the bytes back. Node 22 has neither.