A collection of runtime-agnostic cryptographically-secure utilities.
unsecure is a collection of runtime-agnostic cryptographically-secure utilities. (ba dum tss 🥁)
Install the package:
# ✨ Auto-detect (supports npm, yarn, pnpm, deno and bun)
npx nypm install unsecure
Import:
ESM (Node.js, Bun, Deno)
// Main functions
import {
// Hashing / MAC / KDF
hash,
hmac,
hmacVerify,
hkdf,
// OTP
hotp,
hotpVerify,
totp,
totpVerify,
generateOTPSecret,
otpauthURI,
// UUID
uuidv4,
uuidv7,
secureUUID,
createUUIDv7Generator,
uuidv7Timestamp,
isUUIDv4,
isUUIDv7,
// Generation / comparison / entropy
secureGenerate,
secureCompare,
entropy,
// Sanitization
sanitizeObject,
sanitizeObjectCopy,
safeJsonParse,
// Randomness
createSecureRandomGenerator,
secureRandomNumber,
secureRandomBytes,
secureShuffle,
randomJitter,
// Encoding (also available via `unsecure/utils`)
hexEncode,
hexDecode,
base64Encode,
base64Decode,
base64UrlEncode,
base64UrlDecode,
base32Encode,
base32Decode,
} from "unsecure";
CDN (Deno, Bun and Browsers)
For CDN delivery, prefer the per-module subpaths — each module ships as its own bundle so only the bytes you import are downloaded.
// Per-module — ships only what the module needs
import { uuidv7, createUUIDv7Generator } from "https://esm.sh/unsecure/uuid";
import { hkdf } from "https://esm.sh/unsecure/hkdf";
import { totp, generateOTPSecret } from "https://esm.sh/unsecure/otp";
import { base64Encode, base32Decode } from "https://esm.sh/unsecure/utils";
Each of compare, entropy, generate, hash, hkdf, hmac, otp, random, sanitize, uuid, utils is an independent subpath.
Hashes input data using a specified cryptographic algorithm. It uses the Web Crypto API, so it works in any modern JavaScript runtime.
options:
SHA-1, SHA-256, SHA-384, SHA-512 (default SHA-256)hex, base64, base64url, bytes (default hex)[!WARNING]
hash()operates on complete data. The Web Crypto API does not support incremental/streaming digests, so for hashing large streams (e.g. file uploads) you’ll need a platform-specific API like Node.js’scrypto.createHash()or Deno’scrypto.subtle.digestStream().
import { hash } from "unsecure";
// Hash an input using the default SHA-256 and return as a hex string
const hashHex = await hash("hello world");
// 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
// Hash an input using the default SHA-256 and return as a base64 string
const hashBase64 = await hash("hello world", { returnAs: "base64" });
// 'UhywQV8aBkKEVtnvTpSMAnCoBkQjJSU8t6imt+Q9qcc='
// Hash an input using the default SHA-256 and return as a base64 URL string
const hashBase64URL = await hash("hello world", { returnAs: "base64url" });
// 'UhywQV8aBkKEVtnvTpSMAnCoBkQjJSU8t6imt-Q9qcc'
// Hash and get raw bytes (Uint8Array)
const hashBytes = await hash("hello world", { returnAs: "bytes" });
// Uint8Array(32) [ 185, 77, 39, 185, 147, 77, ... ]
// Hash using SHA-512
const hash512 = await hash("hello world", { algorithm: "SHA-512" });
// '309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f...'
Computes an HMAC signature using the Web Crypto API. Supports the same algorithms and output formats as hash(). When returnAs is not specified, the output type mirrors the input: string data returns a hex string, BufferSource data returns a Uint8Array.
options:
SHA-1, SHA-256, SHA-384, SHA-512 (default SHA-256)hex, base64, base64url, bytes (default mirrors input type)import { hmac, hmacVerify } from "unsecure";
// Sign a string — returns hex by default
const sig = await hmac("my-secret", "hello world");
// 'a3a65e5...'
// Sign with SHA-512 and return as base64
const sig64 = await hmac("my-secret", payload, {
algorithm: "SHA-512",
returnAs: "base64",
});
// Verify a webhook signature in constant time
const expected = request.headers["x-hub-signature-256"].replace("sha256=", "");
const valid = await hmacVerify(webhookSecret, requestBody, expected);
// true or false
// Verify a base64-encoded signature
const valid = await hmacVerify(secret, body, expectedBase64Sig, {
returnAs: "base64",
});
HKDF key derivation (RFC 5869) via crypto.subtle.deriveBits. Extract-and-expand from high-entropy input keying material — shared secrets, ECDH output, seeds. For password-based derivation use PBKDF2/Argon2 instead; HKDF has no work factor.
options:
SHA-1, SHA-256, SHA-384, SHA-512 (default SHA-256)32, max 255 * HashLen)BufferSource, default empty)BufferSource, default empty)hex, base64, base64url, bytes (default uint8array)import { hkdf } from "unsecure";
// Derive a 256-bit key from a shared secret
const key = await hkdf(sharedSecret, { salt, info: "my-app/auth/v1" });
// 512-bit key, SHA-512, base64url output
const keyB64 = await hkdf(ikm, {
algorithm: "SHA-512",
length: 64,
info: "encryption-key",
returnAs: "base64url",
});
// Domain separation — same IKM, different `info` → independent keys
const encKey = await hkdf(ikm, { salt, info: "encrypt" });
const macKey = await hkdf(ikm, { salt, info: "authenticate" });
[!TIP]
A differentinfoper usage site (ideally versioned, e.g."myapp/enc/v1") lets you rotate key derivation without breaking old data. Requests beyond255 * HashLenthrow aRangeErrorbefore reaching Web Crypto.
RFC 4226 (HOTP) and RFC 6238 (TOTP) one-time password generation and verification, built on top of hmac().
Secrets can be passed as raw Uint8Array bytes or as a base32-encoded string.
[!NOTE]
The RFCs recommend the secret to be at least as long as the hash output (20 bytes for SHA-1, 32 for SHA-256, 48 for SHA-384, 64 for SHA-512). The defaultgenerateOTPSecret()produces 20 bytes, which works with any algorithm but is ideal for SHA-1. UsegenerateOTPSecret(32)orgenerateOTPSecret(64)when targeting SHA-256 or SHA-512.
Generate and verify HMAC-based One-Time Passwords (RFC 4226).
options:
SHA-1, SHA-256, SHA-384, SHA-512 (default SHA-1)6)0)import { hotp, hotpVerify } from "unsecure";
// Generate an OTP for counter 0
const code = await hotp(secretBytes, 0);
// "755224"
// Generate an 8-digit OTP
const code8 = await hotp(secretBytes, 0, { digits: 8 });
// Verify an OTP
const { valid, delta } = await hotpVerify(secret, "287082", 0, { window: 5 });
// valid: true, delta: 1 (matched at counter 0 + 1)
Generate and verify Time-based One-Time Passwords (RFC 6238).
options:
SHA-1, SHA-256, SHA-384, SHA-512 (default SHA-1)6)30)1)import { totp, totpVerify } from "unsecure";
// Generate a TOTP for the current time
const code = await totp(base32Secret);
// Verify a user-provided code (checks current, previous, and next time steps)
const { valid, delta } = await totpVerify(secret, userCode);
// delta: 0 = current step, -1 = previous, +1 = next
Generates a cryptographically random OTP secret, returned as a base32-encoded string (without padding).
import { generateOTPSecret } from "unsecure";
const secret = generateOTPSecret();
// "JBSWY3DPEHPK3PXP..."
// Custom length (32 bytes for SHA-256)
const secret256 = generateOTPSecret(32);
Builds an otpauth:// URI for provisioning OTP tokens via QR code.
import { otpauthURI } from "unsecure";
const uri = otpauthURI({
type: "totp",
secret: base32Secret,
account: "user@example.com",
issuer: "MyApp",
});
// "otpauth://totp/MyApp:user%40example.com?secret=...&issuer=MyApp&algorithm=SHA1&digits=6&period=30"
// HOTP URI (counter is required)
const hotpUri = otpauthURI({
type: "hotp",
secret: secretBytes,
account: "user@example.com",
counter: 0,
});
Generates a cryptographically secure string. You can customize its length and character set (all enabled by default). If a string is passed it will be used as a set of allowed characters.
Internally it uses a buffer, which is constantly updated, to minimize Web Crypto API calls and greatly improve performance. This becomes useful when generating 128-512 characters long tokens.
import { secureGenerate } from "unsecure";
// Generate a default 16-character password
const password = secureGenerate();
// e.g. 'Zk(p4@L!v9{g~8sB'
// Generate a 28-character password
const password = secureGenerate({ length: 28 });
// e.g. '4~j&zgf-tO+PoMBVl}tK/}5$FgzF'
// Generate a 24-character token with no special characters
const token = secureGenerate({
length: 24,
specials: false,
});
// Generate a pin using only custom numbers
const pin = secureGenerate({
length: 6,
uppercase: false,
lowercase: false,
specials: false,
numbers: "13579",
});
// Generate a token with current timestamp prefix
const stamp = secureGenerate({ length: 20, timestamp: true });
// Output example: "mi6dcq...random..."
// Generate a token with a specific date prefix
const date = new Date("2023-01-01T00:00:00Z");
const datestamp = secureGenerate({ length: 20, timestamp: date });
// Output example: "lcclw5...random..."
Compares two values (string or Uint8Array) in a timing-attack-safe manner. The first argument (expected) is always the trusted, server-side value, which determines the loop length. The second argument (received) is the untrusted, user-provided value.
import { secureCompare } from "unsecure";
const expected = "my-super-secret-token";
const received = "my-super-secret-token"; // from user input
if (secureCompare(expected, received)) {
console.log("Tokens match!");
} else {
console.log("Tokens do not match!");
}
// Also works with Uint8Array and mixed types
const mac1 = new Uint8Array([1, 2, 3]);
const mac2 = new Uint8Array([1, 2, 3]);
secureCompare(mac1, mac2); // true
secureCompare(expected, mac2); // false
// Handles undefined / empty `expected` gracefully — returns false by default
secureCompare(expected, undefined); // false
secureCompare("", received); // false
secureCompare(undefined, undefined); // false — never "empty matches empty"
// Opt-in strict mode throws on empty / undefined `expected` (pre-0.2 behavior).
// Useful during development to surface server-side config bugs — avoid in
// attacker-reachable code paths since the throw itself is a side-channel.
secureCompare(undefined, "x", { strict: true }); // throws
Computes three complementary quality signals for a string or Uint8Array: Shannon unigram entropy (frequency-based), bigram entropy (adjacent-pair structure), and the longest strictly-monotonic run (ascending/descending). Unigram entropy alone is blind to symbol order — "abcdefghijklmnop" scores the maximum for its alphabet despite being deterministic. The other two fields close that gap.
Result fields:
bits / bitsPerSymbol / symbolCount / uniqueSymbols / maxBitsPerSymbol: classic Shannon entropy, unchanged.bigramBits / bigramBitsPerSymbol: entropy over adjacent-pair frequencies. Catches sequential, alternating, and blocked patterns. Length-sensitive — see below.longestRun + monotonicDirection: length of the longest strictly ascending or descending run, with its direction ("ascending" | "descending" | "none"). Reliable on any input length.import { entropy } from "unsecure";
// Strong 16-char token — high across all metrics
const result = entropy("Zk(p4@L!v9{g~8sB");
console.log(result.bits); // ~60+ bits
console.log(result.bitsPerSymbol); // entropy per character
console.log(result.uniqueSymbols); // distinct characters
console.log(result.longestRun); // small
// Weak secret — low unigram entropy
entropy("aaaaaaa").bitsPerSymbol; // 0
// Sorted-alphabet fake — caught by longestRun
const fake = entropy("abcdefghijklmnop");
fake.bitsPerSymbol; // 4 (maximum for 16 unique chars)
fake.longestRun; // 16
fake.monotonicDirection; // "ascending"
fake.bigramBitsPerSymbol; // ~2.46 — noticeably low
// Random bytes — near-max across the board
const bytes = new Uint8Array(256);
crypto.getRandomValues(bytes);
entropy(bytes).bitsPerSymbol; // ~7.9+
[!TIP]
bigramBitsPerSymbolis length-sensitive: its ceiling islog2(min(symbolCount - 1, k²))wherek = uniqueSymbols. Compare against a known-random control of the same length, or rely onlongestRunfor short inputs. For a password gate, requiringlongestRun < 5rejects obvious keyboard-walk and sorted patterns that slip past unigram entropy.
Includes createSecureRandomGenerator, secureRandomNumber, secureRandomBytes, secureShuffle, and randomJitter.
import {
createSecureRandomGenerator,
secureRandomNumber,
secureRandomBytes,
secureShuffle,
randomJitter,
} from "unsecure";
// Creates a secure random number generator (more performant for subsequent calls)
const generator = createSecureRandomGenerator();
// Get a random number in range [0, max)
const n = generator.next(1000); // 0 to 999
// Get a random number in range [min, max)
const n2 = generator.next(50, 150); // 50 to 149
// Exclude specific values using an array or Set
const n3 = generator.next(10, [3, 5, 7]); // 0-9, excluding 3, 5, 7
const n4 = generator.next(50, 100, new Set([55, 60, 65])); // 50-99, excluding 55, 60, 65
// Get a secure random number (more memory-efficient for single use)
const num = secureRandomNumber(100); // 0 to 99
const num2 = secureRandomNumber(50, 150); // 50 to 149
const num3 = secureRandomNumber(10, [2, 4, 6]); // 0-9, excluding 2, 4, 6
// Generate random bytes
const key = secureRandomBytes(32); // 256-bit key material (Uint8Array)
// Securely shuffle an array in-place
const list = [1, 2, 3, 4, 5];
secureShuffle(list); // e.g. [ 3, 1, 5, 2, 4 ]
// Or spread the original to not mutate it
const shuffled = secureShuffle([...list]);
// Reuse generator for better performance when shuffling multiple times
const gen = createSecureRandomGenerator();
secureShuffle(list1, gen);
secureShuffle(list2, gen);
// Add a random delay as defense-in-depth against timing side-channels
await randomJitter(); // 0-99ms
await randomJitter(50); // 0-49ms
await randomJitter(50, 100); // 50-99ms
UUID generation per RFC 9562, backed by crypto.getRandomValues. uuidv7() is the default new-UUID choice — its timestamp prefix sorts chronologically, which is ideal for database primary keys (e.g. Postgres 18 native uuidv7() semantics).
Neither version delegates to crypto.randomUUID(); the version/variant bits are set explicitly so the implementation is self-contained and won’t shift if a runtime changes its shortcut behavior.
import {
uuidv4,
uuidv7,
secureUUID,
createUUIDv7Generator,
uuidv7Timestamp,
isUUIDv4,
isUUIDv7,
} from "unsecure";
// Pure random (122 bits of randomness)
uuidv4(); // "8a7c…-4xxx-…"
// Time-ordered; 48-bit Unix-ms prefix + 74 bits of random filler
uuidv7(); // "0195c…-7xxx-…"
// Alias of uuidv7 — use when the version isn't part of the contract
secureUUID();
// Embed a specific timestamp (tests, backfills, replay)
uuidv7(new Date("2020-01-01"));
uuidv7(1_609_459_200_000); // numeric ms
// Read the embedded timestamp back
const u = uuidv7();
new Date(uuidv7Timestamp(u));
// Type guards (case-insensitive; format + version + variant checks)
if (isUUIDv7(input)) {
/* input: string */
}
if (isUUIDv4(input)) {
/* input: string */
}
Stateful generator with a dual-clock design: the internal counter and its reference timestamp progress from Date.now() only (never perturbed by user-supplied arguments), while the embedded 48-bit timestamp field reflects either Date.now() or a caller-supplied value. Out-of-order backfills are safe — UUIDs for past events remain unique and sort by their embedded timestamp.
const gen = createUUIDv7Generator();
gen.next(); // Counter monotonic per process
gen.next(new Date("2020-01-01")); // Embeds that date; counter still advances
gen.next(1_577_836_800_000); // Numeric ms
Key properties:
rand_a, reseeded randomly in [0, 0x7ff] each new wall-clock ms (≥2048 increments of headroom before overflow).Date.now() regressions (NTP adjust, VM pause) hold the reference and keep incrementing the counter — output never goes backward..next(invalidTs) does not mutate internal state (validation runs before the counter advances).[!NOTE]
Mixingnext()andnext(pastTs)calls gives UUIDs that sort by embedded timestamp, not call order — usually what you want for DB PKs. If you need “latest inserted sorts last,” omit the argument or feed monotonic timestamps. For true backfills of past events, call the statelessuuidv7(date)instead.
unsecure/utils)A collection of supplementary encoding/decoding utilities: hexEncode/hexDecode, base64Encode/base64Decode, base64UrlEncode/base64UrlDecode, base32Encode/base32Decode, plus shared textEncoder / textDecoder. All three encoding families share the same shape:
string | Uint8Array (any backing buffer, including SharedArrayBuffer-backed views) and always return string. Empty input ("" or new Uint8Array(0)) returns "". null / undefined throws TypeError — for optional fields, normalize at the call site with ?? "".string | Uint8Array (any backing buffer). The default return type mirrors the input: string in → UTF-8 string out (decoded bytes interpreted as UTF-8), Uint8Array in → Uint8Array<ArrayBuffer> out. When returning bytes, the output is always a freshly-allocated ArrayBuffer-backed Uint8Array — never a view into Node’s internal Buffer pool. Override with { returnAs: "uint8array" | "bytes" | "string" }. null / undefined throws TypeError.Available both from the main barrel and from unsecure/utils (use the subpath for CDN delivery to ship only these helpers).
import { hexEncode, hexDecode, base32Encode, base32Decode } from "unsecure/utils";
const hex = hexEncode("hello"); // "68656c6c6f"
const text = hexDecode(hex); // "hello" (string → string by default)
const bytes = hexDecode(hex, { returnAs: "uint8array" }); // Uint8Array
// Base32 (RFC 4648) — commonly used for OTP secrets; behaves identically
const b32 = base32Encode("foobar"); // "MZXW6YTBOI======"
base32Decode("MZXW6YTBOI"); // "foobar"
base32Decode("MZXW6YTBOI", { returnAs: "uint8array" }); // raw bytes
// Normalize optional fields at the call site rather than relying on coercion
hexEncode(optionalField ?? ""); // "" when field is null/undefined
base64Encode(optionalBuffer ?? new Uint8Array(0));
sanitizeObject / sanitizeObjectCopy / safeJsonParse)Three complementary tools for stripping prototype-pollution vectors (__proto__, prototype, constructor) from untrusted input.
| If you… | Use |
|---|---|
| Receive JSON text — parse + sanitize | safeJsonParse |
| Already have a parsed object you own | sanitizeObject (fastest) |
| Must preserve the caller’s object | sanitizeObjectCopy |
safeJsonParse is cheapest — a reviver drops dangerous keys during parsing so they never materialize on the result. sanitizeObject is the fastest post-parse variant: single-pass traversal, mutates in place, no intermediate allocations. sanitizeObjectCopy is the non-mutating alternative, cycle-safe via WeakMap (cycles in the input become cycles in the output pointing at the copied node, never at the original).
import { safeJsonParse, sanitizeObject, sanitizeObjectCopy } from "unsecure";
// 1. Parse + sanitize in one step — dangerous keys never exist on the result
const payload = safeJsonParse<{ user: { name: string } }>(untrustedInput);
// 2. Post-parse, mutate in place (cheapest on hot paths)
const parsed = JSON.parse(untrustedInput);
sanitizeObject(parsed); // returns the same reference
// 3. Post-parse, keep the caller's object untouched
const safe = sanitizeObjectCopy(req.body);
// All three handle nested objects + arrays, and preserve cycles safely
const data = {
user: {
name: "alice",
__proto__: { hacked: true },
profile: [{ constructor: "bad" }, { prototype: { x: 1 } }],
},
};
sanitizeObject(data);
Object.hasOwn(data.user, "__proto__"); // false
Object.hasOwn(data.user.profile[0]!, "constructor"); // false
Notes:
__proto__, prototype, and constructor are removed.sanitizeObject mutates in place for performance; use sanitizeObjectCopy if the caller may still hold a reference.sanitizeObjectCopy rebuilds onto plain Object.prototype — even null-prototype input comes back rooted normally.Date, Map, Set, functions, and primitives are returned unchanged (but still traversed through if found as nested values on a plain object/array).Inspired by DeepSource Corp work.
Published under the MIT license.
Made by community 💛
🤖 auto updated with automd