Encode and decode
Write bytes as hex, base64 or base32 text, and read text back into bytes, refusing every spelling but the canonical one.
Bytes can't travel in a URL, a cookie, a header or a JSON field as they are, so they get written out as text first. Hex, base64 and base32 are the three common ways to spell them, and unsecure/utils has a stringify and a parse for each. Parsing is strict by default: if the text isn't exactly what stringify would write for some bytes, it throws instead of guessing. Encodings are not security explains why one spelling per value matters.
#Before you start
- Any runtime works. Import from
unsecure/utils, or fromunsecure. - An encoding is not encryption. Anyone who sees the text can decode it.
stringifytakes a string, which it encodes as UTF-8 first, or raw bytes: anArrayBuffer, aDataViewor any typed array.parsetakes the encoded text, as a string or as its bytes in aUint8Array.
#Put bytes in a cookie or a URL
Write the id with the URL-safe base64 alphabet and read it back strictly, so a client can only send back the exact text you issued:
import { UnsecureError } from "unsecure/errors";
import { base32Parse, base32Stringify, base64Parse, base64Stringify } from "unsecure/utils";
/** 32 random bytes as cookie-safe text: URL-safe alphabet, no padding. */
export function encodeSessionId(id: Uint8Array): string {
return base64Stringify(id, { alphabet: "base64url" });
}
/** The bytes back, or `null` for anything but that exact spelling. */
export function decodeSessionId(cookie: string | undefined): Uint8Array | null {
if (cookie === undefined) return null;
try {
const id = base64Parse(cookie, { alphabet: "base64url", padding: false, returnAs: "bytes" });
return id.length === 32 ? id : null;
} catch (error) {
if (error instanceof UnsecureError && error.code === "MALFORMED") return null;
throw error;
}
}- URL-safe alphabet:
base64urluses-and_where standard base64 uses+and/, and drops the=padding, so the text goes into a cookie or a query string without escaping. - One spelling:
padding: falseturns a trailing=into an error, so an edited cookie can never decode to the same id as the original. A cookie that isn't canonical throwsMALFORMED, and Handle errors explains what to do with it. - Length check: a clean decode only proves the text is well formed. It doesn't prove there are 32 bytes in it.
#Read a value a person typed
For text a person typed, spaces and dashes and all, { loose: true } drops every character outside the alphabet, ignores base32 case, and never throws on shape:
/**
* A 15-byte license key, printed as 24 Crockford base32 characters in
* groups, that a person types back in any case, with spaces or dashes.
* Loose decoding drops what it cannot read, so check the length, then keep
* only the canonical spelling.
*/
export function normalizeLicenseKey(typed: string): string | null {
const key = base32Parse(typed, { alphabet: "crockford", loose: true, returnAs: "bytes" });
if (key.length !== 15) return null;
return base32Stringify(key, { alphabet: "crockford" });
}Crockford base32 suits values people type: its alphabet leaves out I, L, O and U, and decoding reads O as 0 and I or L as 1.
Warning
Loose decoding accepts many spellings of the same bytes: 7ZQ4-M2KD, 7zq4 m2kd and 7ZQ4M2KD all decode alike. Use it to read what a person typed, then store and compare the canonical text stringify gives you. Keep strict decoding for anything used as an identifier or compared as text.
#Get text or bytes back
parse hands back the same kind of value you gave it, unless you pass returnAs. Ask for "bytes" whenever the payload isn't text, like a key or an id:
import { base64Parse, hexStringify } from "unsecure/utils";
hexStringify("hello"); // "68656c6c6f"
base64Parse("aGVsbG8="); // "hello": a string in gives a string out
base64Parse("aGVsbG8=", { returnAs: "bytes" }); // Uint8Array(5)A string result is decoded as UTF-8, and a Uint8Array input gives a Uint8Array out.
#When it fails
| Situation | What happens |
|---|---|
| Text holds a character outside the alphabet, whitespace included | Strict parse throws MALFORMED, naming the character and its index. Loose drops it. |
Wrong = padding, a length that cannot hold whole bytes, or bits set past the last byte | Strict throws MALFORMED. Loose drops what it cannot use. |
Lowercase text for base32 or base32hex | Strict throws MALFORMED. Loose reads it. |
returnAs is "string" and the bytes are not valid UTF-8 | Strict throws MALFORMED. Loose puts U+FFFD in place of each bad sequence. |
Input is null, undefined, a number or another non-text value | Throws INVALID_TYPE in both modes. |
A custom base32 alphabet is not 32 distinct ASCII characters, or contains = or whitespace | Throws OUT_OF_RANGE, because the alphabet is your configuration, not input. |
#Options
| Option | Default | Notes |
|---|---|---|
returnAs (parse) | mirrors the input | "string", "bytes" or "uint8array", the last two being the same. |
loose (parse) | false | Drops what it cannot read instead of throwing. Only for text a person typed. |
alphabet (base64) | "base64" | "base64url" for URLs and cookies. |
alphabet (base32) | "base32" | "base32hex", "crockford", or a custom 32-character string. |
padding (stringify) | true, but false for base64url and crockford | Whether to write the trailing =. |
padding (parse) | either | true requires =, false refuses it. Loose ignores this. |
#Going further
- Spellings strict mode still accepts: strict hex reads both cases, and Crockford base32 ignores case and reads
Oas0andIorLas1, because that alphabet's spec says so. For those, compare the decoded bytes, or the textstringifyreturns, never the text you received. - Pinning padding: when a format names one spelling, pin it: JOSE forbids
=, so passpadding: false; MIME requires it, so passpadding: true. - Custom base32 alphabets: these are read exactly as given, case included, in both modes.
- Grouped objects:
Hex,Base64andBase32hold the samestringifyandparsefunctions, ifBase64.parse(text)reads better in your code. - Text helpers:
textEncoderandtextDecoderare shared instances, so you don't need a new one per call.textDecoderkeeps a leading byte order mark (U+FEFF) as part of the text.