Skip to content

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 →