Enjoying Blockend?

Give us a star on GitHub

Star on GitHub
blockend

Blockend

02 blocks

Password Hashing

Argon2id password hashing with HMAC-SHA256 peppering, automatic salting, rehash detection, and fail-fast configuration validation.

The Password Hashing block provides production-grade password storage using Argon2id with HMAC-SHA256 peppering.

Instead of wiring @node-rs/argon2 by hand and inventing your own pepper, salt, and upgrade strategy, instantiate PasswordHasher once and get hashed credentials that exceed OWASP baselines, reject legacy algorithms, and detect weak or differing parameters for transparent rehashing on login.


Features

  • Argon2id only — weaker variants (argon2i, argon2d) and foreign formats such as bcrypt are rejected during verification
  • HMAC-SHA256 peppering with a key that never appears in the stored hash
  • Unique 128-bit random salt generated for every hash
  • PHC-standard output ($argon2id$v=19$m=...,t=...,p=...$salt$hash)
  • needsRehash() detection for transparent parameter upgrades (memory, time, parallelism, output length, version)
  • Fail-fast configuration validation: strict base64 pepper checks and bounded numeric parameters
  • Byte-exact input limiting to prevent denial-of-service through oversized passwords
  • Explicit type checks — non-string passwords throw TypeError
  • Framework agnostic — plain async class usable in Express, Fastify, Hono, scripts, or queues

This block stores passwords. It does not authenticate requests, manage sessions, look up users, or rate-limit login attempts. Those concerns belong to your application and the Rate Limiter block.


File Structure

password-hash
├── types.ts
├── errors.ts
├── config.ts
└── core.ts
  • types.tsArgon2Params, PasswordHashConfig, and override types.
  • errors.tsInvalidPepperError, InvalidConfigError, PasswordTooLongError.
  • config.ts — Environment loading and validation (loadConfig()).
  • core.ts — The PasswordHasher service class.

Installation

pnpm dlx blockend-cli add password-hash

Detect Project

Blockend detects your project configuration and determines the correct output location.

Install Dependencies

@node-rs/argon2 is installed automatically.

Generate Files

The password hashing block is generated inside your configured blocks directory.

Copy the files below into your project's blocks directory.

Peer Dependencies

PackageRequired
@node-rs/argon2Yes

blocks/password-hash/types.ts

Configuration interfaces for Argon2 parameters, full config, and overrides.

/**
 * Core configuration for Argon2id hashing.
 */
export interface Argon2Params {
  /** Memory cost in KiB (e.g., 65536 for 64 MiB) */
  memoryCost: number;
  /** Number of iterations */
  timeCost: number;
  /** Degree of parallelism (use 1 for web apps) */
  parallelism: number;
  /** Length of the hash output in bytes */
  outputLen: number;
}

/**
 * Full configuration including pepper and input limits.
 */
export interface PasswordHashConfig extends Argon2Params {
  /** Base64-encoded pepper (must decode to ≥32 bytes) */
  pepper: string;
  /** Maximum allowed password byte length (prevent DoS) */
  maxInputBytes: number;
}

/**
 * Options that can be overridden when instantiating PasswordHasher.
 */
export type PasswordHashConfigOverrides = Partial<PasswordHashConfig>;

blocks/password-hash/errors.ts

Typed errors: invalid pepper, invalid configuration, oversized password.

export class PasswordHashError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "PasswordHashError";
  }
}

export class InvalidPepperError extends PasswordHashError {
  constructor(message = "Pepper must be a base64-encoded string of at least 32 bytes") {
    super(message);
    this.name = "InvalidPepperError";
  }
}

export class InvalidConfigError extends PasswordHashError {
  constructor(message = "Invalid password-hash configuration parameters") {
    super(message);
    this.name = "InvalidConfigError";
  }
}

export class PasswordTooLongError extends PasswordHashError {
  constructor(maxBytes: number) {
    super(`Password exceeds maximum allowed byte length (${maxBytes} bytes)`);
    this.name = "PasswordTooLongError";
  }
}

blocks/password-hash/config.ts

Loads and validates configuration from environment variables with optional overrides.

import type { PasswordHashConfig, PasswordHashConfigOverrides } from "./types";
import { InvalidPepperError, InvalidConfigError } from "./errors";

/**
 * Load configuration from environment variables, with optional overrides.
 * Environment variables:
 *   APP_PASSWORD_PEPPER       (required)
 *   ARGON2_MEMORY_COST        (default: 65536)
 *   ARGON2_TIME_COST          (default: 3)
 *   ARGON2_PARALLELISM        (default: 1)
 *   ARGON2_OUTPUT_LEN         (default: 32)
 *   PASSWORD_MAX_INPUT_BYTES  (default: 128)
 */

// RFC 4648 base64 (standard alphabet, optional padding). Node's decoder is
// lenient and silently skips invalid characters, so the format must be
// validated before decoding.
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;

const MAX_UINT32 = 4294967295;
const MAX_PARALLELISM = 255; // native binding limit

function resolveNumber(
  override: number | undefined,
  envValue: string | undefined,
  fallback: number,
  label: string,
  max: number = MAX_UINT32
): number {
  // Empty string is treated as unset so defaults still apply.
  const resolved =
    override ?? (envValue === undefined || envValue.trim() === "" ? fallback : Number(envValue));
  if (!Number.isInteger(resolved) || resolved < 1 || resolved > max) {
    const received = override !== undefined ? String(override) : (envValue ?? String(fallback));
    throw new InvalidConfigError(
      `${label} must be a positive integer between 1 and ${max}, but received "${received}"`
    );
  }
  return resolved;
}

export function loadConfig(overrides: PasswordHashConfigOverrides = {}): PasswordHashConfig {
  const pepper = overrides.pepper ?? process.env.APP_PASSWORD_PEPPER;
  if (!pepper) {
    throw new InvalidPepperError("APP_PASSWORD_PEPPER environment variable is required");
  }
  if (!BASE64_PATTERN.test(pepper)) {
    throw new InvalidPepperError();
  }

  const pepperBuffer = Buffer.from(pepper, "base64");
  if (pepperBuffer.length < 32) {
    throw new InvalidPepperError();
  }

  return {
    pepper,
    memoryCost: resolveNumber(
      overrides.memoryCost,
      process.env.ARGON2_MEMORY_COST,
      65536,
      "ARGON2_MEMORY_COST"
    ),
    timeCost: resolveNumber(
      overrides.timeCost,
      process.env.ARGON2_TIME_COST,
      3,
      "ARGON2_TIME_COST"
    ),
    parallelism: resolveNumber(
      overrides.parallelism,
      process.env.ARGON2_PARALLELISM,
      1,
      "ARGON2_PARALLELISM",
      MAX_PARALLELISM
    ),
    outputLen: resolveNumber(
      overrides.outputLen,
      process.env.ARGON2_OUTPUT_LEN,
      32,
      "ARGON2_OUTPUT_LEN"
    ),
    maxInputBytes: resolveNumber(
      overrides.maxInputBytes,
      process.env.PASSWORD_MAX_INPUT_BYTES,
      128,
      "PASSWORD_MAX_INPUT_BYTES"
    )
  };
}

blocks/password-hash/core.ts

The PasswordHasher class implementing hashing, verification, and rehash detection.

import crypto from "node:crypto";
import { hash, verify } from "@node-rs/argon2";
import type { PasswordHashConfig, PasswordHashConfigOverrides } from "./types";
import { loadConfig } from "./config";
import { PasswordTooLongError } from "./errors";

/**
 * Framework-agnostic password hashing service using Argon2id.
 * Can be used in Express, Fastify, Hono, or any Node.js environment.
 */
export class PasswordHasher {
  private readonly config: PasswordHashConfig;
  private readonly pepperBuffer: Buffer;

  constructor(overrides: PasswordHashConfigOverrides = {}) {
    this.config = loadConfig(overrides);
    this.pepperBuffer = Buffer.from(this.config.pepper, "base64");
  }

  /**
   * Apply pepper using HMAC-SHA256.
   * The result is always 32 bytes.
   * Enforces maximum input byte length.
   */
  private applyPepper(password: string): Buffer {
    if (typeof password !== "string") {
      throw new TypeError("password must be a string");
    }

    const byteLength = Buffer.byteLength(password, "utf8");
    if (byteLength > this.config.maxInputBytes) {
      throw new PasswordTooLongError(this.config.maxInputBytes);
    }

    return crypto.createHmac("sha256", this.pepperBuffer).update(password, "utf8").digest();
  }

  /**
   * Hash a plaintext password and return a PHC string.
   * The peppered digest is passed as base64 because @node-rs/argon2's
   * verify() rejects raw binary buffers ("invalid utf-8 sequence"), while
   * a fixed-width ASCII encoding stays byte-stable across hash and verify.
   */
  async hashPassword(password: string): Promise<string> {
    const peppered = this.applyPepper(password).toString("base64");
    const salt = crypto.randomBytes(16); // 128-bit salt

    return hash(peppered, {
      algorithm: 2, // Argon2id
      memoryCost: this.config.memoryCost,
      timeCost: this.config.timeCost,
      parallelism: this.config.parallelism,
      outputLen: this.config.outputLen,
      salt
    });
  }

  /**
   * Verify a plaintext password against a stored Argon2id PHC string.
   * Returns false if the hash is not Argon2id or verification fails.
   * Throws PasswordTooLongError / TypeError for caller mistakes
   * (same policy as hashing).
   */
  async verifyPassword(password: string, storedHash: string): Promise<boolean> {
    if (typeof storedHash !== "string" || !storedHash.startsWith("$argon2id$")) {
      return false;
    }

    // Deliberately outside try/catch: caller mistakes such as oversized
    // passwords or non-string input must propagate instead of being
    // masked as a failed login.
    const peppered = this.applyPepper(password).toString("base64");

    try {
      return await verify(storedHash, peppered);
    } catch {
      // Malformed hash, unsupported parameters, or native binding error.
      // Treat as verification failure — never throw for corrupt stored data.
      return false;
    }
  }

  /**
   * Check if a stored hash was created with weaker or different parameters
   * and should be rehashed.
   */
  needsRehash(storedHash: string): boolean {
    if (typeof storedHash !== "string" || !storedHash.startsWith("$argon2id$")) {
      return true;
    }

    const params = this.extractPhcParams(storedHash);
    if (!params) return true;

    // Force rehash on version mismatch or any parameter that is weaker
    // or different from the current configuration.
    return (
      params.version !== 19 ||
      params.memoryCost < this.config.memoryCost ||
      params.timeCost < this.config.timeCost ||
      params.parallelism !== this.config.parallelism ||
      params.outputLen !== this.config.outputLen
    );
  }

  /**
   * Expose current configuration (read-only).
   */
  get currentConfig(): Readonly<PasswordHashConfig> {
    return { ...this.config };
  }

  /**
   * Parse the parameter section of a PHC string.
   * Returns null on any parse failure.
   *
   * Expected form: $argon2id$v=19$m=65536,t=3,p=1$salt$hash
   * (outputLen is not always present in the PHC string produced by
   * @node-rs/argon2; we treat a missing value as the library default of 32)
   */
  private extractPhcParams(storedHash: string): {
    version: number;
    memoryCost: number;
    timeCost: number;
    parallelism: number;
    outputLen: number;
  } | null {
    // Split: ["", "argon2id", "v=19", "m=65536,t=3,p=1", "salt", "hash"]
    const parts = storedHash.split("$");
    if (parts.length < 5 || parts[1] !== "argon2id") {
      return null;
    }

    const versionPart = parts[2];
    const paramPart = parts[3];

    // Explicit guards — TypeScript cannot always narrow array access
    if (!versionPart || !paramPart || !versionPart.startsWith("v=")) {
      return null;
    }

    const version = parseInt(versionPart.slice(2), 10);
    if (Number.isNaN(version)) return null;

    const paramMap = new Map<string, number>();
    for (const pair of paramPart.split(",")) {
      const [key, value] = pair.split("=");
      if (!key || value === undefined) continue;
      const num = parseInt(value, 10);
      if (!Number.isNaN(num)) {
        paramMap.set(key, num);
      }
    }

    const memoryCost = paramMap.get("m");
    const timeCost = paramMap.get("t");
    const parallelism = paramMap.get("p");

    if (memoryCost === undefined || timeCost === undefined || parallelism === undefined) {
      return null;
    }

    // @node-rs/argon2 does not always emit "l=" (output length).
    // Fall back to the common default of 32 when absent.
    const outputLen = paramMap.get("l") ?? 32;

    return {
      version,
      memoryCost,
      timeCost,
      parallelism,
      outputLen
    };
  }
}

Configuration

Configuration resolves in order: constructor override → environment variable → secure default.

PasswordHashConfig

OptionTypeDefaultDescription
pepperstringRequiredBase64-encoded secret; must decode to at least 32 bytes
memoryCostnumber65536 (64 MiB)Argon2 memory cost in KiB
timeCostnumber3Argon2 iterations
parallelismnumber1Argon2 threads (keep 1 for web request handling)
outputLennumber32Hash output length in bytes
maxInputBytesnumber128Maximum UTF-8 byte length accepted for a password

Environment Variables

VariableMaps toDefault
APP_PASSWORD_PEPPERpepperRequired
ARGON2_MEMORY_COSTmemoryCost65536
ARGON2_TIME_COSTtimeCost3
ARGON2_PARALLELISMparallelism1
ARGON2_OUTPUT_LENoutputLen32
PASSWORD_MAX_INPUT_BYTESmaxInputBytes128

Validation is strict and fails at startup. The pepper must match the standard base64 alphabet and decode to at least 32 bytes. Every numeric parameter must be an integer within its native binding limits (parallelism maxes out at 255). Invalid values throw immediately — they are never silently coerced into Argon2.


Security Recommendations

This section covers what OWASP recommends and how to configure the block responsibly. Read it before deploying.

Follow the OWASP baseline — then exceed it deliberately

The OWASP Password Storage Cheat Sheet recommends Argon2id with a minimum of 19 MiB memory, 2 iterations, 1 degree of parallelism, and states that reduced memory must be compensated with more iterations. RFC 9106 offers a higher-memory variant at 46 MiB, 1 pass.

Common starting points:

ScenariomemoryCosttimeCostparallelismNotes
Block default655363164 MiB — above both OWASP and RFC 9106 baselines
OWASP minimum194562119 MiB — acceptable floor, do not go below
High-throughput API471041146 MiB, single pass
Memory-constrained containers1638431Compensate lower memory with more iterations

Two operational rules the cheat sheet implies and that you should enforce yourself:

  • Measure on production hardware. Keep hash and verify latency inside your interactive budget (typically under 500 ms). If login feels slow, users — and your support team — will notice.
  • Budget total memory, not per-hash memory. Every concurrent hash or verify holds memoryCost of RAM. Twenty simultaneous logins at 64 MiB is 1.25 GiB. Combine this block with the Rate Limiter on credential endpoints and queue bursts if needed.

Generate and store the pepper correctly

The pepper is a symmetric secret mixed into every hash via HMAC-SHA256. Its entire value is that an attacker who steals your database still cannot verify password guesses offline without also stealing the environment variable or secret.

Generate it with real entropy:

openssl rand -base64 32
# or
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Non-negotiable rules:

  • At least 32 bytes of entropy — the block enforces this and will refuse to start otherwise.
  • Store it in a secret manager or your process environment. Never commit it, never hardcode it, never put it in the database next to the hashes.
  • Never log it. The block never includes the pepper in hashes, error messages, or stack traces — keep it that way in your own code.
  • Understand rotation: changing the pepper invalidates every stored hash, because verification recomputes the same HMAC. If you must rotate, support two peppers temporarily (verify against old, rehash with new) or schedule a password-reset campaign.

Rehash on login

Parameter upgrades only take effect when you rewrite the stored hash. The standard pattern is to check needsRehash() after a successful verification and persist the stronger hash:

if (await hasher.verifyPassword(password, user.passwordHash)) {
  if (hasher.needsRehash(user.passwordHash)) {
    await users.updatePasswordHash(user.id, await hasher.hashPassword(password));
  }
}

Old hashes remain verifiable forever — Argon2 reads the parameters embedded in each PHC string — so upgrades are backward compatible and gradual. Hashes created with a different Argon2 version, different output length, unknown algorithms, or unreadable data always report needsRehash: true.

Know the limits you inherit

Being explicit about what the block does and does not decide for you:

  • Empty passwords hash successfully. Minimum-length policy is an application decision (NIST SP 800-63B suggests allowing at least 64 characters and checking against breach corpora; many teams require 8–15). Enforce it at registration, not in the hashing layer.
  • maxInputBytes counts bytes, not characters. 128 bytes fits any 64-character ASCII password, but multi-byte scripts consume more: is 3 bytes, emoji are 4. If your audience writes Japanese or uses emoji in passphrases, raise PASSWORD_MAX_INPUT_BYTES.
  • Oversized input throws during hashing and during verification. A 200-byte login attempt rejects with PasswordTooLongError instead of silently hashing truncated data.
  • Non-string input throws TypeError. The block does not coerce values.
  • Verification returns false — it never throws — for anything that is not a well-formed Argon2id hash: garbage strings, truncated PHC strings, bcrypt hashes, argon2i, argon2d. This is deliberate downgrade protection. Migrating off bcrypt means detecting the $2b$ prefix yourself and rehashing after the user supplies the correct plaintext.
  • No breach-list checking, no password strength scoring, no username-similarity rules. Pair with a library like zxcvbn and the HaveIBeenPwned range API if your threat model calls for them.

Architecture

new PasswordHasher(overrides)


loadConfig() — override → env var → default, strict validation, fail fast
│ ├── missing / non-base64 / short pepper → InvalidPepperError
│ └── non-integer or out-of-range param → InvalidConfigError

hashPassword(password)


applyPepper(password)
├── typeof !== "string" → TypeError
├── Buffer.byteLength > maxInputBytes → PasswordTooLongError
└── HMAC-SHA256(key = decoded pepper bytes) → fixed 32-byte digest → base64


Argon2id(digest, random 16-byte salt, config params)


PHC string "$argon2id$v=19$m=65536,t=3,p=1$salt$hash" → store this
verifyPassword(password, storedHash)

├── not a string or not "$argon2id$..." → false (downgrade protection)
├── applyPepper fails → TypeError / PasswordTooLongError propagates
└── argon2 verify
    ├── params read from the hash itself (backward compatible)
    └── malformed hash → false, never throws

Each password is pre-hashed with HMAC-SHA256 keyed by the decoded pepper bytes. Because HMAC produces a fixed 32-byte digest, Argon2 always receives uniform-length input regardless of whether the user typed 4 characters or 100. The digest is handed to Argon2 as base64 so hashing and verification see identical bytes. The stored PHC string contains the salt and parameters but never the pepper.


When to Use

  • You store user credentials and want OWASP-grade parameters enforced from startup, not by convention.
  • Your database may be exposed through backups, SQL injection, or misconfigured snapshots, and you want offline cracking to require a second stolen secret.
  • You plan to tune Argon2 costs over time and need old hashes to keep working while new ones get stronger parameters.

When Not to Use

  • You need full authentication (sessions, tokens, OAuth) — combine this block with your auth framework instead.
  • You are hashing API keys, reset tokens, or session identifiers — use SHA-256/HMAC directly; password hashing is intentionally slow.
  • You run on hardware where 19 MiB per concurrent attempt already exhausts memory — reduce memoryCost explicitly and compensate with iterations rather than failing at runtime.

Usage

Setup

Generate a pepper, put it in your environment, and instantiate one hasher for the whole application:

# .env — generate with: openssl rand -base64 32
APP_PASSWORD_PEPPER="Zm9vYmFyYmF6cXV1eGZvb2JhcmJhenF1ZXhmb29iYXJiYXo="
import { PasswordHasher } from "@/blocks/password-hash/core";

// One instance per process. Construction validates everything and throws
// InvalidPepperError / InvalidConfigError immediately on bad configuration.
export const passwordHasher = new PasswordHasher();

Registration and login with transparent upgrades

import express from "express";
import { passwordHasher } from "./security";
import { db } from "./db";

// Generate once at startup so parameters always match real hashes.
const DUMMY_HASH = await passwordHasher.hashPassword("timing-equalizer");

const app = express();

app.post("/register", async (req, res) => {
  const { email, password } = req.body;
  const passwordHash = await passwordHasher.hashPassword(password);
  await db.users.create({ email, passwordHash });
  res.status(201).json({ ok: true });
});

app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);

  // Same Argon2 work happens whether or not the user exists.
  // This prevents timing attacks that reveal which emails are registered.
  const valid = user
    ? await passwordHasher.verifyPassword(password, user.passwordHash)
    : await passwordHasher.verifyPassword(password, DUMMY_HASH); // always false

  if (!valid) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  // Upgrade hashes created under older/weaker/different parameters.
  if (passwordHasher.needsRehash(user.passwordHash)) {
    const upgraded = await passwordHasher.hashPassword(password);
    await db.users.updatePasswordHash(user.id, upgraded);
  }

  res.json({ ok: true });
});

Timing equalization for unknown users

If you skip Argon2 entirely when the email is unknown, response times leak which addresses are registered. Always verify against a dummy hash instead:

// Generate once at application startup from the same PasswordHasher instance.
const DUMMY_HASH = await passwordHasher.hashPassword("timing-equalizer");

// Unknown user path — burns the same CPU/memory as a real verification.
const valid = await passwordHasher.verifyPassword(password, DUMMY_HASH); // always false

Testing

describe("credential flow", () => {
  const hasher = new PasswordHasher({
    pepper: Buffer.alloc(32, 7).toString("base64"),
    memoryCost: 8192, // fast parameters for tests only — never in production
    timeCost: 1,
    parallelism: 1,
    outputLen: 32,
    maxInputBytes: 128
  });

  it("round-trips a password", async () => {
    const stored = await hasher.hashPassword("Sup3r-Secret!");
    expect(await hasher.verifyPassword("Sup3r-Secret!", stored)).toBe(true);
    expect(await hasher.verifyPassword("wrong", stored)).toBe(false);
  });

  it("rejects non-string input", async () => {
    // @ts-expect-error
    await expect(hasher.hashPassword(123)).rejects.toThrow(TypeError);
  });

  it("flags bcrypt-era hashes for migration", () => {
    expect(hasher.needsRehash("$2b$10$abcdefghijklmnopqrstuv")).toBe(true);
  });

  it("flags hashes with weaker parameters", async () => {
    const weakHasher = new PasswordHasher({
      pepper: Buffer.alloc(32, 7).toString("base64"),
      memoryCost: 4096,
      timeCost: 1,
      parallelism: 1,
      outputLen: 32
    });
    const weakHash = await weakHasher.hashPassword("test");
    expect(hasher.needsRehash(weakHash)).toBe(true);
  });
});

API Reference

PasswordHasher

class PasswordHasher {
  constructor(overrides?: PasswordHashConfigOverrides);
  async hashPassword(password: string): Promise<string>;
  async verifyPassword(password: string, storedHash: string): Promise<boolean>;
  needsRehash(storedHash: string): boolean;
  get currentConfig(): Readonly<PasswordHashConfig>;
}

Create one instance per process. The constructor validates configuration eagerly.

passwordHasher.hashPassword

async function hashPassword(password: string): Promise<string>;

Peppers and hashes the password, returning a PHC string to store.

ParameterTypeDescription
passwordstringPlaintext password (any Unicode)

Returns$argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=<parallelism>$<salt>$<hash>

Throws

  • PasswordTooLongError — UTF-8 byte length exceeds maxInputBytes
  • TypeError — input is not a string

passwordHasher.verifyPassword

async function verifyPassword(password: string, storedHash: string): Promise<boolean>;

Verifies plaintext against a stored hash. Parameters are read from the hash itself, so hashes created under older configurations still verify.

Returnstrue only for an exact match against a well-formed Argon2id hash; false for wrong passwords, foreign algorithms (bcrypt, argon2i, argon2d), non-string stored hashes, and malformed input. Never throws for corrupt hashes.

Throws

  • PasswordTooLongError — input exceeds maxInputBytes
  • TypeError — password is not a string

passwordHasher.needsRehash

function needsRehash(storedHash: string): boolean;

Returns true when the hash was produced under weaker or different parameters than the current configuration, uses a different Argon2 version, has a different output length, is not Argon2id at all, or cannot be parsed. Synchronous and side-effect free.

passwordHasher.currentConfig

get currentConfig(): Readonly<PasswordHashConfig>;

Snapshot of the resolved configuration. Mutating the returned object has no effect on the hasher.

loadConfig

function loadConfig(overrides?: PasswordHashConfigOverrides): PasswordHashConfig;

Resolves override → environment variable → default and validates everything. Exported for advanced setups that need the resolved values before constructing a hasher.

Error classes

class PasswordHashError extends Error {}
class InvalidPepperError extends PasswordHashError {} // missing, non-base64, or < 32 decoded bytes
class InvalidConfigError extends PasswordHashError {} // numeric parameter outside native limits
class PasswordTooLongError extends PasswordHashError {} // message includes the configured byte limit

Types

interface Argon2Params {
  memoryCost: number; // KiB
  timeCost: number;
  parallelism: number;
  outputLen: number; // bytes
}

interface PasswordHashConfig extends Argon2Params {
  pepper: string; // base64, decodes to >= 32 bytes
  maxInputBytes: number; // UTF-8 bytes
}

type PasswordHashConfigOverrides = Partial<PasswordHashConfig>;

  • Rate Limiter — Throttle credential endpoints; Argon2 is expensive by design, so unthrottled login routes amplify into a memory DoS vector.
  • Environment Configuration — Validate APP_PASSWORD_PEPPER and the ARGON2_* variables alongside the rest of your environment schema.
  • Logger — Log verification failures and rehash events, but never passwords, hashes, or the pepper itself.

FAQ

Why an HMAC pre-hash instead of passing the password straight to Argon2?

It lets the pepper act as a secret key rather than another string input. An attacker with only the database cannot evaluate guesses offline, because every candidate requires the HMAC key. As a bonus, the fixed 32-byte digest gives Argon2 uniform input regardless of password length. This mirrors the classic "peppered hashing" strategy from the OWASP cheat sheet while keeping standard, portable PHC output.

Why does the stored hash contain no trace of the pepper?

Only HMAC digests ever reach Argon2, and the pepper lives solely in the HMAC key slot. The PHC string carries algorithm, version, parameters, salt, and digest — inspect one; nothing in it decodes to your secret.

I changed ARGON2_MEMORY_COST (or any other parameter) in production. Did I break existing logins?

No. Verification reads the parameters from each stored hash, so old hashes keep working. needsRehash() reports true for any hash whose memory cost, time cost, parallelism, output length, or version differs from the current configuration, letting you upgrade lazily on login.

Why did verification throw for one user and return false for everyone else?

A thrown PasswordTooLongError or TypeError means the submitted password was oversized or not a string — a caller mistake worth surfacing loudly. Corrupted or foreign hashes never throw; they return false so a hostile database value cannot crash your login route.

Can I migrate users from bcrypt?

Not automatically, by design — the verifier rejects $2b$ hashes to prevent downgrade confusion. Detect the prefix yourself, authenticate the user through your legacy path once, then store hashPassword() output. From then on this block handles them, and needsRehash() stays false.

My test suite passes a low memoryCost. Is that safe?

For tests, yes — that is what overrides exist for. Just make sure production reads its parameters from the environment and that no test fixture leaks into deployment configuration. The block enforces validity, not intent.

Does the block enforce a minimum password length?

No. It hashes empty strings happily because length policy varies by product and jurisdiction. Enforce your minimum (and breach-list checks) at registration.

On this page