pepper
Peppering wraps a password with a server-side HMAC secret before it enters the KDF. The user still types the raw password; the DB still stores an argon2 / scrypt / bcrypt string. What changes: an attacker who exfiltrates only the password-hash table cannot mount an offline dictionary attack — they’d need the pepper too, which lives in your KMS or secrets manager.
createPepper({
secret: string | Buffer | Uint8Array
| Array<string | Buffer | Uint8Array>, // [newest, …older] for rotation
hash?: 'sha256' | 'sha512', // default sha256
encoding?: 'base64' | 'hex', // default base64
}): {
wrap: (password) => string, // wraps with newest secret
wrapAll: (password) => string[], // wraps with EVERY configured secret
size: number, // getter, count of secrets
}Basic use
import { createPepper, password } from '@exortek/password';
const pepper = createPepper({ secret: process.env.PW_PEPPER });
// Signup
const peppered = pepper.wrap(input);
const stored = await password.scrypt.hash(peppered);
await db.users.insert({ email, pw_hash: stored });
// Login
const ok = await password.verify(pepper.wrap(candidate), user.pw_hash);Store the pepper separately from the hashes. In a KMS, an env var
read from a secrets manager, or an HSM — not in the same database
as the users table. If both leak in the same dump, the pepper has
bought you nothing.
Rotation — the whole point of wrapAll
Peppers are compile-time forever. Rotating one invalidates every stored hash that used it, so you need a rotation strategy up front — otherwise a leaked pepper means “log everyone out permanently”.
Pass an array, newest first, and use wrapAll in your verify path:
const pepper = createPepper({
secret: [process.env.PW_PEPPER_NEW, process.env.PW_PEPPER_OLD],
});
async function login(email, input) {
const user = await db.users.findByEmail(email);
if (!user) return unauthorized();
const candidates = pepper.wrapAll(input);
for (let i = 0; i < candidates.length; i++) {
if (await password.verify(candidates[i], user.pw_hash)) {
if (i > 0) {
// Matched under an older pepper — rehash under the current one
const fresh = await password.scrypt.hash(candidates[0]);
await db.users.update(user.id, { pw_hash: fresh });
}
return signIn(user);
}
}
return unauthorized();
}Cost of rotation: one extra HMAC per unmatched old pepper. Negligible compared to the ~200 ms of the KDF verify.
Once no user hashes remain under the old pepper — either through organic login-time rehash or a batch job — drop it from the array on your next deploy.
Do I need a pepper?
Yes for high-value corpora (financial, healthcare, government), or whenever a single database dump can be a “game over” event.
Optional for consumer SaaS where you’re already OK with the “attacker runs argon2id at 19MiB × 2 iterations per guess” cost. Argon2’s memory hardness pushes 8-character password cracking well past practicality-for-most-attackers on its own.
Cost is one HMAC per hash / verify — microseconds against a KDF running in hundreds of milliseconds. If in doubt, add it.
Not a replacement for a strong KDF
Peppering is a layer, not a substitute. Don’t do:
// ❌ Wrong — HMAC alone is fast, one dictionary run breaks it
const stored = pepper.wrap(input);Always feed the peppered value into the KDF:
// ✓ Right — HMAC layered before argon2/scrypt/bcrypt
const stored = await password.scrypt.hash(pepper.wrap(input));Secret requirements
- Minimum 16 bytes.
createPepperthrowsINVALID_ARGUMENTfor anything shorter — a small pepper is guessable and defeats the point. - High-entropy. Not a memorable phrase. Generate one:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" - Same across every server that verifies. Load from the same secrets source; don’t hardcode per environment.