Skip to Content
@exortek/passwordmigration — silent rehash on login

migration

The core migration pattern is the same regardless of source / target algorithms: verify with the umbrella router, rehash to the target on successful login, write it back. Users never see it happen.

import { password } from '@exortek/password'; async function login(email, input) { const user = await db.users.findByEmail(email); const ok = await password.constantTimeVerify(input, user?.pw_hash); if (!ok) return unauthorized(); // Silent rehash if the stored format is behind our current default if (password.needsRehash(user.pw_hash, { target: 'argon2id' })) { const fresh = await password.argon2.hash(input); await db.users.update(user.id, { pw_hash: fresh }); } return signIn(user); }

The verify router works across any combination of algorithms present in your database — bcrypt corpora from 2018 mix with argon2 hashes minted last week without a special code path.

Common scenarios

Bcrypt → Argon2id

You’re on bcryptjs today, want to move to Argon2id over the next month of logins:

if (password.needsRehash(user.pw_hash, { target: 'argon2id' })) { const fresh = await password.argon2.hash(input); await db.users.update(user.id, { pw_hash: fresh }); }

needsRehash returns true for every non-Argon2id hash — bcrypt, scrypt, PBKDF2 — so this single check handles all of them. Users who log in during the migration window silently upgrade; users who never log in stay on bcrypt until you run a batch rehash.

Peers during migration: you need bcryptjs installed to verify the old hashes, plus argon2 to mint the new ones. Remove bcryptjs only after the last bcrypt-storing user has been rehashed — either by logging in or by a background job (see below).

Legacy PBKDF2 (from another Node lib) → scrypt

Same shape:

if (password.needsRehash(user.pw_hash, { target: 'scrypt' })) { const fresh = await password.scrypt.hash(input); await db.users.update(user.id, { pw_hash: fresh }); }

Zero-dep — scrypt lives in node:crypto — so this is the cheapest migration if you don’t need Argon2id specifically.

Params drift within the same algorithm

Say you deployed with scrypt N=2^15 two years ago and want to move to 2^17 (the current OWASP recommendation). needsRehash catches this too, since it compares stored params against the target’s defaults:

if (password.scrypt.needsRehash(user.pw_hash)) { // Reject on param mismatch OR wrong algo — same call, same code path const fresh = await password.scrypt.hash(input); await db.users.update(user.id, { pw_hash: fresh }); }

Users who never log in

Login-driven migration is graceful but incomplete — a dormant account stays on the old algorithm forever. Two options:

Rehash lazily when anything touches the account, not just logins:

async function loadUser(id) { const user = await db.users.findById(id); if (user && password.needsRehash(user.pw_hash, { target: 'argon2id' })) { // Can't rehash without the raw password — flag for the next login instead queueForMigrationOnNextLogin(user.id); } return user; }

You can’t rehash without the plaintext, so a batch job can only mark accounts as “needs rehash” — the actual work still happens on next login.

Option B — force rotation

For high-security migrations (say, moving off a leaked-pepper hash), you can force-expire the password:

// Migration script for await (const user of db.users.streamWhere({ pw_algo: 'bcrypt' })) { await db.users.update(user.id, { must_reset_password: true }); } // Login handler if (user.must_reset_password) { return res.redirect('/reset-password'); }

This is user-visible so use it sparingly — the login-time silent rehash covers 95% of active accounts within a few weeks.

Rotating the pepper

If you use createPepper and the pepper leaks (or you’re rotating on a schedule), pass an array of secrets in newest-first order. wrapAll lets verify walk the list:

const pepper = createPepper({ secret: [NEW_PEPPER, OLD_PEPPER] }); 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();

Once no accounts remain under the old pepper (batch-verify or migration-flag pattern), drop it from the array and redeploy.

Last updated on