Skip to content

Migrate existing passwords into your application

Import existing password hashes when moving your application to its own login. This requires an export you are authorized to use and a verifier for its format. When those are available, users can keep their passwords. @udibo/oauth2/identity verifies each user's existing (foreign-format) password hash on their first sign-in and, on a match, transparently rehashes the password into the package's native format. Users keep signing in with the password they already have; the migration completes one login at a time.

Automatic upgrades require the optional IdentityUserStore.replaceCredential capability. It atomically compares the currently stored credential with the expected credential before replacing it; undefined expects no native credential during legacy import. If a password reset won the race, return false. Stores without this capability still authenticate, but skip automatic rehashing and legacy migration. Explicit resets use setCredential.

The model: upgrade-on-login

You import each user with their foreign password hash stored in a legacyCredential column and no native credential. On sign-in, IdentityService:

  1. Checks the native credential first (absent for a not-yet-migrated user).

  2. Reads the foreign hash via IdentityUserStore.getLegacyCredential and runs it through the configured legacyVerifiers (the first whose canVerify accepts the hash string).

  3. On a match: rehashes the plaintext into the native PBKDF2 format (replaceCredential), clears the foreign hash (clearLegacyCredential), emits a password.upgraded event, and signs the user in.

  4. From then on the native path is used — the foreign verifier is never called for that user again.

The response is uniform (null), the return value never reveals whether an account exists, and the timing does not either: a wrong password for a not-yet-migrated account runs your (possibly slow, BYO) verifier where a migrated one runs a single native verify, so every rejected signIn is held to failedSignInFloorMs — 250 ms by default, measured from the start of the call rather than added to its work. Unpadded, that difference would name exactly the accounts still carrying an imported hash to any unauthenticated caller.

Size that floor against your slowest verifier. A branch that outruns it returns as soon as its own work finishes, and whatever it spends above the floor is visible again — bcrypt at a high cost factor, or an argon2 verifier, can easily cost more than 250 ms, so measure yours and raise the option to suit. Per-IP throttling at your route is still worth having during the migration window; it bounds the volume the floor makes uniform.

Native credentials are authoritative: once a user has a native credential (after their first legacy sign-in, or any password reset/change), the imported hash is never consulted again — a wrong password can't fall back to a stale foreign hash — so a reset can't be silently reverted.

Which algorithms are built-in vs. bring-your-own

The package ships zero new runtime dependencies — it uses Web Crypto only. That single constraint decides what can be built-in:

FamilyStatusWhy
PBKDF2 (SHA-1/256/512)Built-inPBKDF2 is a Web Crypto primitive (crypto.subtle.deriveBits); pbkdf2Verifier() handles it dep-free.
bcryptBYONo Web Crypto primitive; needs a bcrypt implementation (Blowfish key schedule).
argon2 (id/i/d)BYONo Web Crypto primitive; memory-hard, needs a native/WASM argon2.
scryptBYONot exposed by Web Crypto or @std/crypto; memory-hard (Salsa20/8 core).
MD5-crypt / SHA-cryptBYO$1$ / $5$ / $6$ use custom multi-round mixing, not a single digest.

Built-in means the package can verify it dep-free. BYO (bring-your-own) means you implement the tiny LegacyPasswordVerifier seam using your app's own dependency and pass it in. This is deliberate: shipping bcrypt/argon2/scrypt would force a dependency on every consumer, and the package stays a thin, composable layer — the same seam philosophy as SessionStore and the rate limiter. Most providers store bcrypt, so in practice you add one small BYO verifier.

parsePhc is exported to parse the PHC / modular-crypt strings ($argon2id$v=19$m=…$salt$hash, $scrypt$…) those algorithms use, so a BYO verifier is only a few lines.

Wiring it up

Add a legacyCredential column to your users table (nullable text) and implement the two legacy hooks below. The existing store must also implement atomic replaceCredential as shown in add-login.md; without it passwords verify but are never migrated.

TypeScript
import {
  IdentityService,
  type IdentityUserStore,
} from "@udibo/oauth2/identity";
import type { IdentityUser } from "@udibo/oauth2/identity";
import {
  type LegacyPasswordVerifier,
  pbkdf2Verifier,
} from "@udibo/oauth2/identity/migration";

interface User extends IdentityUser {
  email: string;
}

declare const existingStore:
  & IdentityUserStore<User>
  & Required<Pick<IdentityUserStore<User>, "replaceCredential">>;
declare const bcryptVerifier: LegacyPasswordVerifier;
declare const db: {
  users: {
    find(id: string): Promise<{ legacyCredential: string | null } | undefined>;
    update(
      id: string,
      patch: { legacyCredential: string | null },
    ): Promise<void>;
  };
};

const users: IdentityUserStore<User> = {
  ...existingStore,

  async getLegacyCredential(userId) {
    const row = await db.users.find(userId);
    return row?.legacyCredential ?? null;
  },
  async clearLegacyCredential(userId) {
    await db.users.update(userId, { legacyCredential: null });
  },
};

const identity = new IdentityService({
  users,
  legacyVerifiers: [pbkdf2Verifier(), bcryptVerifier],
});

legacyVerifiers is tried in order; put the formats you actually imported in the list. When it's unset (or the store lacks getLegacyCredential), the legacy path is skipped entirely and sign-in behaves exactly as before — imported-only users simply have no usable password until you enable it.

Bring-your-own verifiers

The seam is three members: a stable id, a synchronous canVerify format sniff, and an async constant-time verify.

bcrypt (the common case)

The app adds a bcrypt dependency — e.g. npm:bcryptjs (pure JS, no native build) — the package never does:

TypeScript
import { compare } from "bcryptjs";
import type { LegacyPasswordVerifier } from "@udibo/oauth2/identity/migration";

const bcryptVerifier: LegacyPasswordVerifier = {
  id: "bcrypt",
  canVerify: (phc) => /^\$2[aby]?\$/.test(phc),
  verify: (password, phc) => compare(password, phc),
};

argon2

TypeScript
import { verify as argon2Verify } from "@node-rs/argon2";
import type { LegacyPasswordVerifier } from "@udibo/oauth2/identity/migration";

const argon2Verifier: LegacyPasswordVerifier = {
  id: "argon2",
  canVerify: (phc) => phc.startsWith("$argon2"),
  verify: (password, phc) => argon2Verify(phc, password).catch(() => false),
};

scrypt

parsePhc gives you the parameters and salt/hash bytes; feed them to your scrypt dependency and compare:

TypeScript
import { scrypt } from "node:crypto";
import { timingSafeEqual } from "@std/crypto/timing-safe-equal";
import {
  type LegacyPasswordVerifier,
  parsePhc,
} from "@udibo/oauth2/identity/migration";

const scryptVerifier: LegacyPasswordVerifier = {
  id: "scrypt",
  canVerify: (phc) => phc.startsWith("$scrypt$"),
  verify: (password, phc) =>
    new Promise((resolve) => {
      const parsed = parsePhc(phc);
      if (!parsed?.salt || !parsed.hash) return resolve(false);
      const N = Number(
        parsed.params.ln ? 2 ** Number(parsed.params.ln) : parsed.params.N,
      );
      const r = Number(parsed.params.r);
      const p = Number(parsed.params.p);
      scrypt(
        password,
        parsed.salt,
        parsed.hash.length,
        { N, r, p },
        (err, dk) => {
          resolve(!err && timingSafeEqual(dk, parsed.hash!));
        },
      );
    }),
};

Bulk-importing your users

Export your users from the old system and insert them with the foreign hash in legacyCredential and no native credential. Nothing special is required — it's your own insert:

TypeScript
declare const oldExport: {
  email: string;
  emailVerified: boolean;
  passwordHash: string;
}[];
declare const db: {
  users: { insert(row: Record<string, unknown>): Promise<void> };
};

for (const record of oldExport) {
  await db.users.insert({
    email: record.email,
    emailVerified: record.emailVerified,
    passwordHash: null, // native credential — filled on first login
    legacyCredential: record.passwordHash, // the foreign PHC/modular-crypt string
  });
}

Then set legacyVerifiers for the formats present in the export. As users sign in, legacyCredential clears and passwordHash fills. You can optionally sweep rows that still have a non-null legacyCredential after a cutoff and send those users a password-reset link.

Before the cutover, do a dry-run validation pass with verifyLegacyPassword to confirm your verifiers accept the exported hashes (against a known test account).

Auditing the migration

Every upgrade emits a password.upgraded event carrying the verifierId that matched, so you can watch migration progress through your existing onEvent audit sink:

TypeScript
import { IdentityService } from "@udibo/oauth2/identity";
import type { IdentityUser, IdentityUserStore } from "@udibo/oauth2/identity";
import {
  type LegacyPasswordVerifier,
  pbkdf2Verifier,
} from "@udibo/oauth2/identity/migration";

interface User extends IdentityUser {
  email: string;
}

declare const users: IdentityUserStore<User>;
declare const bcryptVerifier: LegacyPasswordVerifier;
declare const metrics: {
  increment(name: string, tags: Record<string, string>): void;
};
declare const auditLog: { write(event: unknown): void };

const identity = new IdentityService({
  users,
  legacyVerifiers: [pbkdf2Verifier(), bcryptVerifier],
  onEvent: (event) => {
    if (event.type === "password.upgraded") {
      metrics.increment("auth.password_upgraded", { from: event.verifierId });
    }
    auditLog.write(event);
  },
});

This guide documents the package's password-verification API. Export availability, account provisioning, and migration into Udibo's hosted service follow that service's own documentation and beta support process.