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.ts —
Argon2Params,PasswordHashConfig, and override types. - errors.ts —
InvalidPepperError,InvalidConfigError,PasswordTooLongError. - config.ts — Environment loading and validation (
loadConfig()). - core.ts — The
PasswordHasherservice class.
Installation
pnpm dlx blockend-cli add password-hashDetect 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
| Package | Required |
|---|---|
@node-rs/argon2 | Yes |
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
| Option | Type | Default | Description |
|---|---|---|---|
pepper | string | Required | Base64-encoded secret; must decode to at least 32 bytes |
memoryCost | number | 65536 (64 MiB) | Argon2 memory cost in KiB |
timeCost | number | 3 | Argon2 iterations |
parallelism | number | 1 | Argon2 threads (keep 1 for web request handling) |
outputLen | number | 32 | Hash output length in bytes |
maxInputBytes | number | 128 | Maximum UTF-8 byte length accepted for a password |
Environment Variables
| Variable | Maps to | Default |
|---|---|---|
APP_PASSWORD_PEPPER | pepper | Required |
ARGON2_MEMORY_COST | memoryCost | 65536 |
ARGON2_TIME_COST | timeCost | 3 |
ARGON2_PARALLELISM | parallelism | 1 |
ARGON2_OUTPUT_LEN | outputLen | 32 |
PASSWORD_MAX_INPUT_BYTES | maxInputBytes | 128 |
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:
| Scenario | memoryCost | timeCost | parallelism | Notes |
|---|---|---|---|---|
| Block default | 65536 | 3 | 1 | 64 MiB — above both OWASP and RFC 9106 baselines |
| OWASP minimum | 19456 | 2 | 1 | 19 MiB — acceptable floor, do not go below |
| High-throughput API | 47104 | 1 | 1 | 46 MiB, single pass |
| Memory-constrained containers | 16384 | 3 | 1 | Compensate 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
memoryCostof 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.
maxInputBytescounts 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, raisePASSWORD_MAX_INPUT_BYTES.- Oversized input throws during hashing and during verification. A 200-byte login attempt rejects with
PasswordTooLongErrorinstead 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
zxcvbnand 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 thisverifyPassword(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 throwsEach 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
memoryCostexplicitly 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 falseTesting
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.
| Parameter | Type | Description |
|---|---|---|
password | string | Plaintext password (any Unicode) |
Returns — $argon2id$v=19$m=<memoryCost>,t=<timeCost>,p=<parallelism>$<salt>$<hash>
Throws
PasswordTooLongError— UTF-8 byte length exceedsmaxInputBytesTypeError— 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.
Returns — true 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 exceedsmaxInputBytesTypeError— 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 limitTypes
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>;Related Blocks
- 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_PEPPERand theARGON2_*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.
Idempotency
Exactly-once request execution with pluggable stores and cache, key validation, background cleanup jobs, and adapters for Express, Fastify, and Hono.
Production Health Checks & Graceful Shutdown
End-to-end guide for combining the Health Check and Graceful Shutdown blocks so load balancers and Kubernetes stop routing traffic before your process drains and exits cleanly.