encoding.base64
Bytes to base64 and base64url text and back (RFC 4648), with strict decoding, identically in every language.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 46 tests, run in TypeScript, Python and Rust.base64Encode 12 · base64Decode 14 · base64UrlEncode 9 · base64UrlDecode 11
What it does
Base64 from RFC 4648: `base64Encode` / `base64Decode` use the standard alphabet (`+`, `/`) with `=` padding, and `base64UrlEncode` / `base64UrlDecode` use the URL- and filename-safe alphabet (`-`, `_`) of section 5, without padding, which is the form JWTs (RFC 7515 section 2) and most URL tokens use. The four ship as one group because the two alphabets share one codec; install only what you call with `only=`.
Bytes are lists of integers 0 to 255, as everywhere in the registry (see `encoding.hex`). Python callers can pass a `bytes` value directly.
The functions
A group: 4 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- base64Encode (bytes: int[]) -> string
- base64Decode (text: string) -> int[]
- base64UrlEncode (bytes: int[]) -> string
- base64UrlDecode (text: string) -> int[]
Once installed, your code imports each one from the group's module.
base64Encode throws on bad input 12 tests
export function base64Encode(bytes: readonly number[]): string
| bytes | int[] | each an integer from 0 to 255 |
| returns | string | standard alphabet (+ and /), padded with = to a multiple of 4 |
For example
base64Encode()→ RFC 4648 section 10: the empty stringbase64Encode(102)→ Zg== RFC 4648 section 10: "f", two padding charactersbase64Encode(102, 111)→ Zm8= RFC 4648 section 10: "fo", one padding character
import { base64Encode } from "#fune/encoding.base64@^1";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/**
* Bytes as standard base64 (RFC 4648 section 4), padded with "=".
*
* Written out rather than using btoa, which takes a "binary string" and
* throws on anything above 0xff, and Buffer, which is not in the browser.
*/
export function base64Encode(bytes: readonly number[]): string {
if (!Array.isArray(bytes) && !(bytes instanceof Uint8Array)) {
throw new TypeError("bytes must be a list of integers from 0 to 255");
}
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (typeof b !== "number" || !Number.isInteger(b) || b < 0 || b > 255) {
throw new RangeError("bytes must be a list of integers from 0 to 255");
}
}
let out = "";
let i = 0;
for (; i + 3 <= bytes.length; i += 3) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
}
const rest = bytes.length - i;
if (rest === 1) {
const n = bytes[i] << 16;
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + "==";
} else if (rest === 2) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + "=";
}
return out;
}base64Decode throws on bad input 14 tests
export function base64Decode(text: string): readonly number[]
| text | string | standard alphabet, padded; no whitespace or line breaks |
| returns | int[] |
For example
base64Decode()→ the empty stringbase64Decode(Zg==)→ 102 RFC 4648 section 10: "Zg==" is "f"base64Decode(Zm8=)→ 102, 111 RFC 4648 section 10: "Zm8=" is "fo"
import { base64Decode } from "#fune/encoding.base64@^1";
function sextet(code: number): number {
if (code >= 65 && code <= 90) return code - 65; // A-Z
if (code >= 97 && code <= 122) return code - 71; // a-z
if (code >= 48 && code <= 57) return code + 4; // 0-9
if (code === 43) return 62; // +
if (code === 47) return 63; // /
return -1;
}
/**
* Standard, padded base64 back to bytes, strictly: no whitespace, no
* characters outside the alphabet, padding to a multiple of four, and zero
* bits after the last byte, so every byte string has exactly one spelling.
*/
export function base64Decode(text: string): readonly number[] {
if (typeof text !== "string") {
throw new TypeError("base64 text must be a string");
}
const values: number[] = [];
let padding = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code === 61) {
padding++;
continue;
}
const v = sextet(code);
if (v < 0 || padding > 0) {
throw new RangeError("base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end");
}
values.push(v);
}
if (text.length % 4 !== 0) {
throw new RangeError(`base64 text must be a multiple of 4 characters long, received ${text.length}`);
}
if (padding > 2) {
throw new RangeError("base64 text has too much = padding");
}
const out: number[] = [];
let i = 0;
for (; i + 4 <= values.length; i += 4) {
const n = (values[i] << 18) | (values[i + 1] << 12) | (values[i + 2] << 6) | values[i + 3];
out.push((n >> 16) & 255, (n >> 8) & 255, n & 255);
}
const rest = values.length - i;
if (rest === 2) {
if ((values[i + 1] & 15) !== 0) throw new RangeError("base64 text has non-zero bits after its last byte");
out.push(((values[i] << 2) | (values[i + 1] >> 4)) & 255);
} else if (rest === 3) {
if ((values[i + 2] & 3) !== 0) throw new RangeError("base64 text has non-zero bits after its last byte");
const n = (values[i] << 18) | (values[i + 1] << 12) | (values[i + 2] << 6);
out.push((n >> 16) & 255, (n >> 8) & 255);
}
return out;
}base64UrlEncode throws on bad input 9 tests
export function base64UrlEncode(bytes: readonly number[]): string
| bytes | int[] | each an integer from 0 to 255 |
| returns | string | URL-safe alphabet (- and _), no padding, as JWTs use |
For example
base64UrlEncode()→ the empty stringbase64UrlEncode(102)→ Zg "f" without its paddingbase64UrlEncode(102, 111)→ Zm8 "fo" without its padding
import { base64UrlEncode } from "#fune/encoding.base64@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { base64Encode } from "./encoding_base64_base64_encode.ts"; ← base64Encode, another function of this group · built into the same file, even by a slim install
/**
* Bytes as base64url (RFC 4648 section 5) without padding: the alphabet with
* "-" and "_" in place of "+" and "/", as JWTs and URL tokens use it.
*/
export function base64UrlEncode(bytes: readonly number[]): string {
const standard = base64Encode(bytes);
let out = "";
for (let i = 0; i < standard.length; i++) {
const ch = standard[i];
if (ch === "=") break;
out += ch === "+" ? "-" : ch === "/" ? "_" : ch;
}
return out;
}base64UrlDecode throws on bad input 11 tests
export function base64UrlDecode(text: string): readonly number[]
| text | string | URL-safe alphabet, with or without correct = padding |
| returns | int[] |
For example
base64UrlDecode()→ the empty stringbase64UrlDecode(Zg)→ 102 unpadded, as a JWT writes itbase64UrlDecode(Zg==)→ 102 correct padding is accepted too
import { base64UrlDecode } from "#fune/encoding.base64@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { base64Decode } from "./encoding_base64_base64_decode.ts"; ← base64Decode, another function of this group · built into the same file, even by a slim install
/**
* base64url text back to bytes. Padding is optional, since JWTs leave it
* off and some producers keep it, but padding that is present must be right.
*/
export function base64UrlDecode(text: string): readonly number[] {
if (typeof text !== "string") {
throw new TypeError("base64url text must be a string");
}
let body = "";
let padding = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "=") {
padding++;
continue;
}
const code = text.charCodeAt(i);
const ok = (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || ch === "-" || ch === "_";
if (!ok || padding > 0) {
throw new RangeError("base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end");
}
body += ch === "-" ? "+" : ch === "_" ? "/" : ch;
}
if (body.length % 4 === 1) {
throw new RangeError(`base64url text cannot be ${body.length} characters long; no number of bytes encodes to that`);
}
const needed = (4 - (body.length % 4)) % 4;
if (padding !== 0 && padding !== needed) {
throw new RangeError("base64url text has the wrong amount of = padding");
}
return base64Decode(body + "=".repeat(needed));
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add encoding.base64
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add encoding.base64 --only base64Encode
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./encoding.base64-1.0.0-typescript.fune, or fetch it from a terminal with fune pull encoding.base64@1.0.0:typescript.
The whole function, every language, is one file too: encoding.base64-1.0.0.fune, 31,225 bytes, sha256 9dc42678ee52da6f5471c4c64d22da5ce75f681e734cea0ad5dd0ce6a188c403. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before encoding.base64.base64Encode
// fune: before encoding.base64.base64Decode
// fune: before encoding.base64.base64UrlEncode
// fune: before encoding.base64.base64UrlDecode
after — your function gets the result and the arguments, and returns the final result.
// fune: after encoding.base64.base64Encode
// fune: after encoding.base64.base64Decode
// fune: after encoding.base64.base64UrlEncode
// fune: after encoding.base64.base64UrlDecode
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show encoding.base64 --steps.
// fune: step encoding.base64.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
base64Encode 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| RFC 4648 section 10: the empty string | → | ||
| RFC 4648 section 10: "f", two padding characters | 102 | → | Zg== |
| RFC 4648 section 10: "fo", one padding character | 102, 111 | → | Zm8= |
| RFC 4648 section 10: "foo", no padding | 102, 111, 111 | → | Zm9v |
| RFC 4648 section 10: "foob" | 102, 111, 111, 98 | → | Zm9vYg== |
| RFC 4648 section 10: "fooba" | 102, 111, 111, 98, 97 | → | Zm9vYmE= |
| RFC 4648 section 10: "foobar" | 102, 111, 111, 98, 97, 114 | → | Zm9vYmFy |
| high bytes use the + and / characters of the standard alphabet | 251, 255, 191 | → | +/+/ |
| zero bytes are A, not dropped | 0, 0, 0, 0 | → | AAAAAA== |
| 256 is not a byte | 256 | → | error: bytes must be a list of integers from 0 to 255 |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a fraction is not a byte | 1.5 | → | error: bytes must be a list of integers from 0 to 255 |
| a string inside the list is not a byte | f | → | error: bytes must be a list of integers from 0 to 255 |
base64Decode 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the empty string | → | ||
| RFC 4648 section 10: "Zg==" is "f" | Zg== | → | 102 |
| RFC 4648 section 10: "Zm8=" is "fo" | Zm8= | → | 102, 111 |
| RFC 4648 section 10: "Zm9vYg==" is "foob" | Zm9vYg== | → | 102, 111, 111, 98 |
| RFC 4648 section 10: "Zm9vYmFy" is "foobar" | Zm9vYmFy | → | 102, 111, 111, 98, 97, 114 |
| the + and / characters | +/+/ | → | 251, 255, 191 |
| missing padding is refused in standard base64 | Zg | → | error: base64 text must be a multiple of 4 characters long, received 2 |
| non-zero bits after the last byte, which a lenient decoder reads as "f" | Zh== | → | error: base64 text has non-zero bits after its last byte |
| non-zero bits after the last of two bytes | Zm9= | → | error: base64 text has non-zero bits after its last byte |
| three padding characters | Z=== | → | error: base64 text has too much = padding |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| padding in the middle | Zg==Zg== | → | error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end |
| a trailing newline is not skipped | Zm9v | → | error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end |
| base64url characters in standard text | -_-_ | → | error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end |
| a non-ASCII letter | Zm9é | → | error: base64 text may only contain A-Z, a-z, 0-9, + and /, with = padding at the end |
base64UrlEncode 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the empty string | → | ||
| "f" without its padding | 102 | → | Zg |
| "fo" without its padding | 102, 111 | → | Zm8 |
| "foobar" needs no padding in either alphabet | 102, 111, 111, 98, 97, 114 | → | Zm9vYmFy |
| - and _ replace + and / | 251, 255, 191 | → | -_-_ |
| - and _ with padding removed | 251, 255 | → | -_8 |
| RFC 7515 appendix A.1: the example JWS header | 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125 | → | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9 |
| 300 is not a byte | 300 | → | error: bytes must be a list of integers from 0 to 255 |
| a negative value is not a byte | -5 | → | error: bytes must be a list of integers from 0 to 255 |
base64UrlDecode 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the empty string | → | ||
| unpadded, as a JWT writes it | Zg | → | 102 |
| correct padding is accepted too | Zg== | → | 102 |
| - and _ | -_-_ | → | 251, 255, 191 |
| - and _ in an unpadded tail | -_8 | → | 251, 255 |
| RFC 7515 appendix A.1: the example JWS header | eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9 | → | 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125 |
| standard-alphabet characters are refused | +/+/ | → | error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end |
| one character past a multiple of four encodes nothing | Zm9vY | → | error: base64url text cannot be 5 characters long; no number of bytes encodes to that |
| padding that is present must be complete | Zg= | → | error: base64url text has the wrong amount of = padding |
| non-zero bits after the last byte | Zh | → | error: base64 text has non-zero bits after its last byte |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| a space inside the text | Zm9v YmFy | → | error: base64url text may only contain A-Z, a-z, 0-9, - and _, with optional = padding at the end |
More from the author
**Decoding is strict.** Line breaks, spaces and characters outside the alphabet are errors, not skipped (RFC 4648 section 3.3 says to reject them unless a specification says otherwise). Standard base64 must be padded to a multiple of four characters. The unused bits in the last character must be zero: `"Zh=="` is refused even though a lenient decoder reads it as `"f"`, because accepting it would give one byte string several spellings, which matters when the text is compared or signed (section 3.5). The URL-safe decoder accepts text with or without padding, but padding that is present has to be right, and a length that no byte count produces (one character past a multiple of four) is an error.
Mixing alphabets is an error in both directions: `+` or `/` in base64url text, and `-` or `_` in standard base64 text. That is almost always a token pasted into the wrong field.
Source: RFC 4648, The Base16, Base32, and Base64 Data Encodings, sections 4, 5, 3.3, 3.5, and the test vectors in section 10 (https://www.rfc-editor.org/rfc/rfc4648). The base64url example of a JWS header is from RFC 7515 appendix A.1.