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 from unsecure.
  • An encoding is not encryption. Anyone who sees the text can decode it.
  • stringify takes a string, which it encodes as UTF-8 first, or raw bytes: an ArrayBuffer, a DataView or any typed array. parse takes the encoded text, as a string or as its bytes in a Uint8Array.

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:

encode-decode.ts
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: base64url uses - 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: false turns 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 throws MALFORMED, 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:

encode-decode.ts
/**
 * 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

SituationWhat happens
Text holds a character outside the alphabet, whitespace includedStrict 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 byteStrict throws MALFORMED. Loose drops what it cannot use.
Lowercase text for base32 or base32hexStrict throws MALFORMED. Loose reads it.
returnAs is "string" and the bytes are not valid UTF-8Strict throws MALFORMED. Loose puts U+FFFD in place of each bad sequence.
Input is null, undefined, a number or another non-text valueThrows INVALID_TYPE in both modes.
A custom base32 alphabet is not 32 distinct ASCII characters, or contains = or whitespaceThrows OUT_OF_RANGE, because the alphabet is your configuration, not input.

#Options

OptionDefaultNotes
returnAs (parse)mirrors the input"string", "bytes" or "uint8array", the last two being the same.
loose (parse)falseDrops 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 crockfordWhether to write the trailing =.
padding (parse)eithertrue 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 O as 0 and I or L as 1, because that alphabet's spec says so. For those, compare the decoded bytes, or the text stringify returns, never the text you received.
  • Pinning padding: when a format names one spelling, pin it: JOSE forbids =, so pass padding: false; MIME requires it, so pass padding: true.
  • Custom base32 alphabets: these are read exactly as given, case included, in both modes.
  • Grouped objects: Hex, Base64 and Base32 hold the same stringify and parse functions, if Base64.parse(text) reads better in your code.
  • Text helpers: textEncoder and textDecoder are shared instances, so you don't need a new one per call. textDecoder keeps a leading byte order mark (U+FEFF) as part of the text.