Skip to content

Migrating Passwords

Moving an existing user base onto this boilerplate means moving their password hashes, and you cannot turn a hash from one scheme into another — only the password itself can be rehashed, and you do not have it. legacy_verifiers closes that gap: the old hashes come across as they are, and each one is replaced with a crudauth hash the first time its owner signs in. Nobody resets a password, and no password is stored in two schemes at once.

Why the old hashes don't just work

crudauth stores bcrypt(base64(sha256(password))). The SHA-256 step is a fixed-width transform, not the work factor — bcrypt ignores input past 72 bytes, so hashing the password first makes the comparison cover all of it. The consequence for a migration is that a hash another library wrote with bcrypt.hashpw(password, salt) never matches crudauth's own check, even though both are bcrypt. Those accounts would all answer 401 at login.

Wiring a verifier

A verifier is (plain_password, hashed_password) -> bool. crudauth ships one for plain bcrypt:

# infrastructure/auth/setup.py
from crudauth.utils import verify_plain_bcrypt

auth = CRUDAuth(
    ...,
    legacy_verifiers=[verify_plain_bcrypt],
)

At login, crudauth tries its own check first. Only if that fails does each verifier run, in a worker thread. On a match the user is signed in and hashed_password is rewritten in crudauth's format, so the verifier matters exactly once per account.

Old scheme What to pass
plain bcrypt (bcrypt.hashpw, passlib's bcrypt) verify_plain_bcrypt
passlib bcrypt_sha256, Django BCryptSHA256PasswordHasher a verifier of your own: those pre-hash differently
Django's default PBKDF2, Argon2, scrypt, legacy MD5/SHA-1 a verifier of your own, calling that library

Write your own as a plain function and add it to the list:

from passlib.hash import django_pbkdf2_sha256


def verify_django_pbkdf2(plain_password: str, hashed_password: str) -> bool:
    """Check a password against a hash Django's default hasher wrote."""
    try:
        return django_pbkdf2_sha256.verify(plain_password, hashed_password)
    except ValueError:
        return False


auth = CRUDAuth(..., legacy_verifiers=[verify_django_pbkdf2, verify_plain_bcrypt])

Several may be listed, which a migration from more than one source needs. A verifier that raises counts as a non-match and is logged, so a broken one doesn't look like every migrated user typing the wrong password — watch the log for legacy verifier ... raised after you deploy one.

A verifier of your own can time how long a username takes

crudauth runs every verifier against a bcrypt dummy hash when the username is unknown, so a verifier for another scheme answers those instantly while a real migrated account pays its full cost. That difference tells a caller which usernames exist for as long as the verifier is configured, which is one more reason to remove it once the migration is done. verify_plain_bcrypt is unaffected: the dummy is bcrypt, so it costs the same either way.

A verifier must never accept a crudauth hash for anything but the real password

crudauth's own hashes are bcrypt, so a naive plain-bcrypt check would accept the base64 SHA-256 digest crudauth bcrypts. An attacker holding an unsalted SHA-256 of a password — leaked from somewhere else entirely — could then type that digest and sign in. verify_plain_bcrypt refuses any password shaped like that digest (44 base64 characters decoding to 32 bytes) for exactly this reason. Keep the same guard in a verifier of your own, or verify with a library whose format cannot collide with bcrypt.

Importing the rows

Copy each account's old hash straight into user.hashed_password. Nothing needs to mark it as legacy: crudauth tries its own check first and falls back to the verifiers when that fails.

await crud_users.create(
    db=db,
    object=UserCreateInternal(
        name=row.full_name,
        username=row.username,
        email=row.email,
        hashed_password=row.password,  # the old system's hash, as it is
    ),
)

Two things to know about what comes across:

  • The password policy isn't applied to a hash. PASSWORD_MIN_LENGTH and the rest are checked when a password is set — registration, change, reset, /set-password — so a migrated password that wouldn't pass today still signs its owner in, and is rehashed as it is. Requiring the new policy means requiring a reset, not a verifier.
  • An account with no password (one that only ever signed in with a provider) belongs in the database as make_unusable_password(), not as an empty string. /api/v1/auth/login then refuses it, and the account signs in with its provider or sets a password through the reset link, which proves the address first (Passwords).

Finishing the migration

Each sign-in converts one account, so the verifier can go once every active account has signed in. To see how far along you are, count the hashes that are not crudauth's. crudauth's are bcrypt over a 44-character base64 digest, which you cannot tell from a plain bcrypt hash by looking at it — so record the migration at import time instead, in a column or a side table of your own, and clear each row as its account signs in (crudauth's on_after_login hook is where to do it, next to the refusal audit).

What is left after that are accounts that never came back. Expire them, or ask those users to reset their password: POST /api/v1/auth/password/reset-request sends the email, and a reset writes a crudauth hash like any other.

Keep the verifier only that long. It is a second way to verify a password, and every login that fails crudauth's own check pays for it in a worker thread.

Key Files

Component Location
auth = CRUDAuth(...), where legacy_verifiers is passed backend/src/infrastructure/auth/setup.py
verify_plain_bcrypt, make_unusable_password crudauth's crudauth.utils
Tests covering a legacy sign-in and its rehash backend/tests/integration/auth/test_legacy_passwords.py

← Sessions User Management →