Argon2 Hash & Verify — TypeScript source
Hash passwords with Argon2id — the winner of the Password Hashing Competition. Configure memory, iterations, and parallelism. WASM-powered, client-side.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Argon2id password hashing via the argon2-browser WASM build (reference
// Argon2 v1.0.2 compiled by Antelle). Pure logic - no React, no DOM. Async
// because WASM instantiation and the KDF itself are.
//
// WASM LOADING: the package's own wrapper (lib/argon2.js) sniffs its runtime
// and, in any ESM context (Vite / vitest), takes a CommonJS `require` path it
// cannot complete. Instead we drive its emscripten glue (dist/argon2.js)
// directly: the glue is a plain script that adopts its config from
// globalThis.Module at evaluation time, so we install a config carrying the
// wasm binary (fetched from Vite's content-hashed ?url asset in the browser;
// read straight from node_modules under Node) BEFORE importing it, and wait
// for its postRun callback. One code path for browser and tests - nothing
// leaves the device.
//
// PHC string format (what `encoded` holds - the string you store in a DB):
// $argon2id$v=19$m=65536,t=3,p=1$<b64 salt>$<b64 digest>
// Salt and digest are unpadded standard Base64.
/** Defaults follow the OWASP-recommended Argon2id profile (64 MiB, 3 passes). */
export const ARGON2_DEFAULTS = {
memory: 65_536,
iterations: 3,
parallelism: 1,
hashLength: 32,
} as const;
/** Random salt size in bytes (128 bits - the PHC recommendation). */
export const SALT_BYTES = 16;
/** Argon2 variant ids as the C library encodes them. */
const TYPE_BY_NAME = { argon2d: 0, argon2i: 1, argon2id: 2 } as const;
/** C return code for "password does not match" - a verdict, not an error. */
const VERIFY_MISMATCH = -35;
export interface Argon2Options {
/** Memory cost in KiB (default 65536 = 64 MiB). Must be >= 1024. */
memory?: number;
/** Time cost - passes over memory (default 3). Must be >= 1. */
iterations?: number;
/** Parallelism - lanes (default 1). Must be >= 1. */
parallelism?: number;
/** Digest length in bytes (default 32). Must be 16..64. */
hashLength?: number;
/** Explicit salt bytes; a random 16-byte salt is generated when omitted. */
salt?: Uint8Array;
}
export interface Argon2Result {
/** Raw digest, lowercase hex (hashLength bytes). */
hash: string;
/** Self-contained PHC string - store this, verify against it. */
encoded: string;
/** Salt used, lowercase hex (16 bytes unless an explicit salt was given). */
salt: string;
}
/** Parameters extracted from a PHC string (parseArgon2's return type). */
export interface Argon2Params {
type: 'argon2d' | 'argon2i' | 'argon2id';
version: number;
memory: number;
iterations: number;
parallelism: number;
/** Salt, decoded from the embedded Base64 into lowercase hex. */
salt: string;
/** Digest, decoded from the embedded Base64 into lowercase hex ('' if absent). */
hash: string;
}
/** Lowercase hex of a byte array. */
function bytesToHex(bytes: Uint8Array): string {
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** charCode -> 6-bit value for the standard Base64 alphabet. */
const B64_INDEX: Record<number, number | undefined> = (() => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const map: Record<number, number | undefined> = {};
for (let i = 0; i < alphabet.length; i++) map[alphabet.charCodeAt(i)] = i;
return map;
})();
/** Unpadded standard Base64 (the PHC encoding) -> bytes. Throws on any
* non-alphabet character or an impossible length (1 mod 4). */
function phcBase64ToBytes(b64: string): Uint8Array {
if (b64.length === 0) throw new Error('Invalid Argon2 string: empty Base64 field');
if (!/^[A-Za-z0-9+/]+$/.test(b64)) throw new Error('Invalid Argon2 string: non-Base64 characters');
if (b64.length % 4 === 1) throw new Error('Invalid Argon2 string: impossible Base64 length');
let outLength = Math.floor((b64.length * 3) / 4);
if (b64.length % 4 === 2) outLength = ((b64.length - 2) / 4) * 3 + 1;
if (b64.length % 4 === 3) outLength = ((b64.length - 3) / 4) * 3 + 2;
const bytes = new Uint8Array(outLength);
let p = 0;
for (let i = 0; i < b64.length; i += 4) {
const d0 = B64_INDEX[b64.charCodeAt(i)];
// Length was validated above (not 1 mod 4), so every group has >= 2 chars.
const d1 = B64_INDEX[b64.charCodeAt(i + 1)];
if (d0 === undefined || d1 === undefined) throw new Error('Invalid Argon2 string: non-Base64 characters');
if (p < outLength) bytes[p++] = (d0 << 2) | (d1 >> 4);
if (i + 2 < b64.length) {
const d2 = B64_INDEX[b64.charCodeAt(i + 2)];
if (d2 === undefined) throw new Error('Invalid Argon2 string: non-Base64 characters');
if (p < outLength) bytes[p++] = ((d1 & 0x0f) << 4) | (d2 >> 2);
if (i + 3 < b64.length) {
const d3 = B64_INDEX[b64.charCodeAt(i + 3)];
if (d3 === undefined) throw new Error('Invalid Argon2 string: non-Base64 characters');
if (p < outLength) bytes[p++] = ((d2 & 0x03) << 6) | d3;
}
}
}
return bytes;
}
/**
* Parse a PHC-format Argon2 string (`$argon2id$v=19$m=65536,t=3,p=1$salt$hash`)
* into its typed parameters. Accepts argon2d / argon2i / argon2id. The digest
* segment is optional (some encoders omit it); salt and hash are returned as
* lowercase hex. Throws on any malformed input.
*/
export function parseArgon2(encoded: string): Argon2Params {
const m = /^\$(argon2(?:d|i|id))\$v=(\d+)\$m=(\d+),t=(\d+),p=(\d+)\$([A-Za-z0-9+/]+)(?:\$([A-Za-z0-9+/]+))?$/.exec(
encoded.trim(),
);
if (!m) throw new Error('Invalid Argon2 string: expected $argon2id$v=19$m=…,t=…,p=…$salt$hash');
return {
type: m[1] as Argon2Params['type'],
version: Number(m[2]),
memory: Number(m[3]),
iterations: Number(m[4]),
parallelism: Number(m[5]),
salt: bytesToHex(phcBase64ToBytes(m[6])),
hash: m[7] ? bytesToHex(phcBase64ToBytes(m[7])) : '',
};
}
/** Validate + normalise hashing parameters, throwing with a clear message. */
function normalizeOptions(options: Argon2Options = {}): Required<Omit<Argon2Options, 'salt'>> {
const { memory = ARGON2_DEFAULTS.memory, iterations = ARGON2_DEFAULTS.iterations } = options;
const { parallelism = ARGON2_DEFAULTS.parallelism, hashLength = ARGON2_DEFAULTS.hashLength } = options;
if (!Number.isFinite(memory) || memory < 1024) throw new Error('Memory must be at least 1024 KiB');
if (!Number.isFinite(iterations) || iterations < 1) throw new Error('Iterations must be at least 1');
if (!Number.isFinite(parallelism) || parallelism < 1) throw new Error('Parallelism must be at least 1');
if (!Number.isFinite(hashLength) || hashLength < 16 || hashLength > 64) {
throw new Error('Hash length must be between 16 and 64 bytes');
}
return { memory, iterations, parallelism, hashLength };
}
/**
* The emscripten glue's runtime surface (assigned onto the config object it
* adopts - see loadRuntime). Only what hash/verify marshalling needs.
*/
interface Argon2Runtime {
ALLOC_NORMAL: number;
/** Copy bytes into wasm memory; returns the pointer. */
allocate(slab: Uint8Array, type: string, allocator: number): number;
UTF8ToString(ptr: number): string;
HEAP8: Int8Array;
_free(ptr: number): void;
_argon2_error_message(code: number): number;
_argon2_encodedlen(
t: number, m: number, p: number, saltlen: number, hashlen: number, type: number,
): number;
_argon2_hash_ext(
t: number, m: number, p: number,
pwd: number, pwdlen: number,
salt: number, saltlen: number,
hash: number, hashlen: number,
encoded: number, encodedlen: number,
type: number,
secret: number, secretlen: number,
ad: number, adlen: number,
version: number,
): number;
_argon2_verify_ext(
encoded: number, pwd: number, pwdlen: number,
secret: number, secretlen: number,
ad: number, adlen: number,
type: number,
): number;
}
/** The wasm binary: browser -> fetch Vite's hashed `?url` asset; Node
* (vitest/SSR) -> read node_modules directly. The Node-only specifier is
* built dynamically + @vite-ignore so Rollup never tries to bundle it, and
* the relative wasm path is kept in a variable so Vite's static
* new-URL-asset detection leaves it alone. */
async function loadWasmBinary(): Promise<Uint8Array> {
if (typeof process !== 'undefined' && process.versions?.node) {
const fsSpecifier = 'node:fs/promises';
const { readFile } = await import(/* @vite-ignore */ fsSpecifier);
const wasmPath = '../../node_modules/argon2-browser/dist/argon2.wasm';
return new Uint8Array(await readFile(new URL(wasmPath, import.meta.url)));
}
const { default: assetUrl } = await import('argon2-browser/dist/argon2.wasm?url');
const res = await fetch(assetUrl);
if (!res.ok) throw new Error(`Failed to load argon2.wasm (HTTP ${res.status})`);
return new Uint8Array(await res.arrayBuffer());
}
let runtimePromise: Promise<Argon2Runtime> | null = null;
/**
* Instantiate the wasm runtime once. The glue (argon2-browser/dist/argon2.js)
* is a plain script that adopts its configuration from globalThis.Module at
* evaluation time - so the config (with the binary + a postRun latch) must be
* installed before the import below resolves. postRun fires once all exports
* and heap views are live, which is when the config object has become the
* runtime.
*/
function loadRuntime(): Promise<Argon2Runtime> {
runtimePromise ??= (async () => {
const wasmBinary = await loadWasmBinary();
const g = globalThis as { Module?: unknown };
const config: { wasmBinary: Uint8Array; postRun?: () => void } = { wasmBinary };
const ready = new Promise<void>((resolve) => {
config.postRun = () => resolve();
});
g.Module = config;
try {
await import('argon2-browser/dist/argon2.js');
await ready;
} catch (e) {
runtimePromise = null; // allow a retry after a load failure
throw e;
} finally {
delete g.Module;
}
return config as unknown as Argon2Runtime;
})();
return runtimePromise;
}
/** UTF-8 bytes of a string, copied into wasm memory as a NUL-terminated C string. */
function allocCString(runtime: Argon2Runtime, text: string): { ptr: number; len: number } {
const bytes = new TextEncoder().encode(text);
const ptr = runtime.allocate(new Uint8Array([...bytes, 0]), 'i8', runtime.ALLOC_NORMAL);
return { ptr, len: bytes.length };
}
/**
* Hash a password with Argon2id (hybrid of Argon2i's side-channel resistance
* and Argon2d's GPU resistance - the Password Hashing Competition winner and
* the recommended mode for password storage). Returns the digest (hex), the
* salt used (hex), and the self-contained PHC string. A fresh random 16-byte
* salt is generated per call unless `options.salt` is given.
*/
export async function argon2Hash(password: string, options?: Argon2Options): Promise<Argon2Result> {
const { memory, iterations, parallelism, hashLength } = normalizeOptions(options);
const salt = options?.salt ?? crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const runtime = await loadRuntime();
const pwd = await allocCString(runtime, password);
const saltPtr = runtime.allocate(
new Uint8Array([...salt, 0]), 'i8', runtime.ALLOC_NORMAL,
);
const hashPtr = runtime.allocate(new Uint8Array(hashLength), 'i8', runtime.ALLOC_NORMAL);
const encodedLen = runtime._argon2_encodedlen(
iterations, memory, parallelism, salt.length, hashLength, TYPE_BY_NAME.argon2id,
);
const encodedPtr = runtime.allocate(new Uint8Array(encodedLen + 1), 'i8', runtime.ALLOC_NORMAL);
try {
const res = runtime._argon2_hash_ext(
iterations, memory, parallelism,
pwd.ptr, pwd.len,
saltPtr, salt.length,
hashPtr, hashLength,
encodedPtr, encodedLen,
TYPE_BY_NAME.argon2id,
0, 0, 0, 0,
0x13, // Argon2 version 1.3 (v=19)
);
if (res !== 0) {
throw new Error(runtime.UTF8ToString(runtime._argon2_error_message(res)));
}
const digest = new Uint8Array(hashLength);
for (let i = 0; i < hashLength; i++) digest[i] = runtime.HEAP8[hashPtr + i]! & 0xff;
return { hash: bytesToHex(digest), encoded: runtime.UTF8ToString(encodedPtr), salt: bytesToHex(salt) };
} finally {
runtime._free(pwd.ptr);
runtime._free(saltPtr);
runtime._free(hashPtr);
runtime._free(encodedPtr);
}
}
/**
* Verify a password against a PHC-format encoded hash (as produced by
* argon2Hash). Resolves true on match, false on mismatch; throws only on a
* malformed encoded string or a runtime error. Any Argon2 type (d/i/id) is
* accepted - the type is read from the string itself.
*/
export async function argon2Verify(encoded: string, password: string): Promise<boolean> {
const params = parseArgon2(encoded); // validate format up front
const runtime = await loadRuntime();
const pwd = await allocCString(runtime, password);
const enc = await allocCString(runtime, encoded);
try {
const res = runtime._argon2_verify_ext(
enc.ptr, pwd.ptr, pwd.len,
0, 0, 0, 0,
TYPE_BY_NAME[params.type],
);
if (res === 0) return true;
if (res === VERIFY_MISMATCH) return false;
throw new Error(runtime.UTF8ToString(runtime._argon2_error_message(res)));
} finally {
runtime._free(pwd.ptr);
runtime._free(enc.ptr);
}
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →