Passwords¶
How CRUDAuth stores passwords, lets an OAuth-only user set one, and resets a forgotten one.
Storage and POST /set-password come with the base app; the reset flow additionally needs
email configured:
auth = CRUDAuth(
session=get_session, user_model=User, SECRET_KEY="change-me",
email=EmailConfig(sender=MySender(), frontend_url="https://app.example.com"), # for reset
)
app.include_router(auth.router) # adds /set-password and /password/reset-*
See Getting started for the base app and Email flows for the sender.
Storage¶
Passwords are hashed with bcrypt, after a SHA-256 pre-hash so bcrypt's 72-byte ceiling never
silently truncates a long password. Verification returns False for a missing, malformed or
unusable stored hash instead of raising, so a corrupted row is a clean "invalid password", not a
500, and it still pays a full bcrypt verification, so an OAuth-only account answers a login in
the same time as one with a password. You never handle the plaintext beyond the route that
receives it.
Every password is Unicode-normalized (NFKC) before it's hashed or verified, as NIST SP 800-63B
recommends. An é typed as one precomposed character on one device and as e plus a combining
accent on another is the same password. Hashes created before normalization keep verifying: the
password is checked as typed when its normalized form doesn't match, and a successful login
replaces that hash with a normalized one.
bcrypt is deliberately slow, so the built-in routes hash and verify in a worker thread and the event loop keeps serving other requests meanwhile. In your own async routes, use the async counterparts:
from crudauth import get_password_hash_async, verify_password_async
if not await verify_password_async(body.current_password, auth.repo.get(user, "hashed_password")):
raise UnauthorizedException("Incorrect password")
await auth.repo.update(
db, user, {"hashed_password": await get_password_hash_async(body.new_password)}
)
Moving users from another system¶
Hashes another library wrote don't verify against crudauth's scheme. Plain bcrypt, for instance,
hashes the password itself where crudauth hashes its SHA-256 digest. Pass checks for those hashes
as legacy_verifiers, and a user signs in with their old password once, after which their stored
hash is a crudauth one:
from crudauth.utils import verify_plain_bcrypt
auth = CRUDAuth(..., legacy_verifiers=[verify_plain_bcrypt])
They run only when crudauth's own check fails, at login, and a match is rehashed on the spot.
A verifier is any (plain_password, hashed_password) -> bool, so a hash of another kind
(argon2, PBKDF2, a framework's own format) needs only its check. Each runs in a worker thread,
and for an unknown user each still runs against a dummy hash, so a wrong password costs the same
whether or not the account exists; a verifier that raises counts as a non-match and is logged on
the crudauth logger. Once every user has signed in since the move, the option can go.
A verifier also runs against users already on a crudauth hash (the formats can't be told apart),
so it must not accept crudauth's hashes for anything but the real password. crudauth stores
bcrypt of the password's base64 SHA-256 digest, which a plain bcrypt check would accept for that
digest: an unsalted SHA-256 of a reused password, leaked elsewhere, would sign in. That's why
verify_plain_bcrypt refuses any password shaped like that digest (44 characters of base64 that
decode to 32 bytes); write the same refusal into a verifier of your own that checks bcrypt.
verify_plain_bcrypt compares only the first 72 bytes of the password, because that's all bcrypt
ever hashed: older libraries cut a longer password there silently and newer ones refuse it. So a
user whose password is longer migrates on any bcrypt version, and their new hash covers the whole
password. It's for plain bcrypt only; a hasher that pre-hashed before bcrypt (passlib's
bcrypt_sha256, Django's BCryptSHA256PasswordHasher) needs its own verifier.
get_password_hash and verify_password do the same work synchronously, for scripts and sync
code. crudauth.utils.verify_and_update_password_async also returns the replacement hash for a
pre-normalization match, for a login you verify yourself instead of through
auth.authenticate_password.
Password policy¶
Every new password, on /register, /set-password, /change-password and
/password/reset-confirm, must meet the configured PasswordPolicy. The default only requires
8 characters:
from crudauth import CRUDAuth, PasswordPolicy
auth = CRUDAuth(
session=get_session, user_model=User, SECRET_KEY="change-me",
password_policy=PasswordPolicy(min_length=12, require_digit=True),
)
require_uppercase, require_lowercase, require_digit and require_special are off by
default. A special character is anything that isn't a letter or a digit, spaces included. There's
no maximum length: the SHA-256 pre-hash makes a long password as cheap to hash as a short one.
The rules and validators see the normalized password, the form that gets hashed, so e plus a
combining accent counts as one character and ² counts as the digit 2.
A password that fails gets a 422 in FastAPI's validation-error format, one entry per unmet
rule at the password field (password on /register, new_password everywhere else):
{"detail": [
{"type": "string_too_short", "loc": ["body", "new_password"],
"msg": "String should have at least 12 characters", "ctx": {"min_length": 12}},
{"type": "password_policy", "loc": ["body", "new_password"],
"msg": "Password should contain a digit", "ctx": {"requirement": "digit"}}
]}
The rejected password isn't echoed back, and OpenAPI shows the minimum length and the rules on each password field.
Extra checks¶
For anything beyond the built-in rules, add validators: sync or async functions that raise
ValueError with the message to show. They run in order once the built-in rules pass and stop at
the first failure, so a breached-password lookup never runs for a password that's too short:
from crudauth import CRUDAuth, PasswordContext, PasswordPolicy
async def not_breached(password: str) -> None:
if await breach_count(password):
raise ValueError("This password has appeared in a data breach")
def not_personal(password: str, context: PasswordContext) -> None:
lowered = password.lower()
for value in (context.username, context.email and context.email.split("@")[0]):
if value and value.lower() in lowered:
raise ValueError("Password should not contain your username or email")
auth = CRUDAuth(
...,
password_policy=PasswordPolicy(min_length=12, validators=[not_breached, not_personal]),
)
A validator that takes a second required argument also gets a PasswordContext: the source
("register", "set", "change" or "reset"), the account's username and email, and the
user row (None on registration). One with an optional second parameter gets only the
password.
What the policy covers¶
The policy runs in CRUDAuth's routes, on a custom register_schema too, and in
EmailFlowService.reset_password when you call it directly (which returns
EmailFlowResult(user, redirect_to)). Code that sets a password itself
with get_password_hash_async should check it first:
Tightening the policy doesn't affect existing passwords. Login never checks it, so users keep signing in and meet the new rules the next time they set a password.
Setting a password on an OAuth-only account¶
A user who signed up through OAuth has no usable password (the stored value is an unusable
sentinel). POST /set-password is a built-in route that lets them set their first one while
authenticated. There's no current password to check, so the proof is a recent sign-in: the
session making the request must have signed in within the last 10 minutes, or hold an active
sudo elevation. Otherwise it answers 403 ("Sign in again to continue"), and
your frontend sends the user back through the provider first. A password is a lasting credential,
and email change and every other password-gated action follow from it, so an old or stolen
session cookie isn't enough. CRUDAuth(fresh_sign_in_seconds=...) sets the window (0 closes
the route), and a bearer token, which carries no sign-in time, is refused.
# 1. right after signing in through OAuth, set the password (the session cookie + CSRF header)
curl -X POST http://localhost:8000/set-password \
-H "X-CSRF-Token: <token>" -H "Content-Type: application/json" \
-b "session_id=<cookie>" \
-d '{"new_password": "a-strong-one"}'
# 2. the account can now log in by password too
curl -X POST http://localhost:8000/login \
-d "username=alice&password=a-strong-one"
This is set, not change: it refuses with 400 if the account already has a usable
password (use the reset flow to change an existing one), and it doesn't evict other sessions,
because establishing a first credential isn't a compromise response.
Changing a known password¶
A signed-in user with a password changes it through POST /change-password, a built-in route. The
current password is the re-authentication (the active session/token proves presence, the current
password proves intent), so no token round-trip is needed.
curl -X POST http://localhost:8000/change-password \
-H "X-CSRF-Token: <token>" -H "Content-Type: application/json" \
-b "session_id=<cookie>" \
-d '{"current_password": "old-one", "new_password": "a-new-strong-one"}'
Allowed over any transport: CSRF is automatic on the session path, and bearer has no CSRF surface.
A wrong current password is a 401; an account with no usable password is a 400 (use
/set-password instead). Because a password change is a compromise response, it bumps
token_version (evicting bearer tokens) and revokes the user's other sessions, keeping the
current one, and fires the on_after_password_changed hook.
Resetting a forgotten password¶
A user who can't log in uses the email reset flow: POST /password/reset-request sends a
link, and POST /password/reset-confirm sets the new password. See Email flows
for the setup. The reset also bumps token_version, so any bearer tokens issued before the
reset stop working.