Skip to content

Passwords

Veloce includes salted, slow password hashing built entirely on the standard library. hash_password turns a plaintext password into a self-describing verifier string, and verify_password checks a candidate against a stored verifier in constant time. Never store plaintext passwords; store the output of hash_password.

from veloce import hash_password, verify_password

stored = hash_password("correct horse battery staple")
assert verify_password(stored, "correct horse battery staple") is True
assert verify_password(stored, "wrong password") is False

The stored value is a single string of the form method$params$salt$hash. Put that string straight into a database column — it carries the algorithm, work factors, and salt needed to verify later, so you never store those separately.

Choosing a method

hash_password accepts a method argument. Two methods are supported, both stdlib-only:

  • "scrypt" (the default) — memory-hard (RFC 7914), resistant to GPU and ASIC brute force.
  • "pbkdf2:sha256" — CPU-only (NIST SP 800-132), portable to restricted Python builds that lack scrypt.
from veloce import hash_password, verify_password

stored = hash_password("hunter2", method="pbkdf2:sha256")
assert verify_password(stored, "hunter2") is True

Unless you have a specific portability constraint, keep the scrypt default. Both methods produce the same method$params$salt$hash shape, and verify_password reads the method tag from the stored string — you do not pass method to verify_password.

The salt_length argument controls how many random bytes form the salt (default 16, the OWASP minimum). Values below 8 raise ValueError.

Verifying never raises

verify_password returns a bool and never raises. A malformed stored string, an unknown method tag, an empty candidate, or a plain mismatch all return False. This lets you treat the call as a single boolean check without wrapping it in try.

from veloce import verify_password

assert verify_password("not-a-valid-hash", "anything") is False
assert verify_password("", "anything") is False

Tampered work factors are rejected

verify_password enforces minimum cost floors at verify time. A stored hash whose scrypt cost or PBKDF2 iteration count has been lowered below the security floor (for example to make verification trivially fast) is treated as tampering and returns False, even if its format is otherwise valid.

Upgrading hashes on login

Over time the recommended work factor rises, and you may switch from PBKDF2 to scrypt. Rather than forcing a password reset, re-derive a stored hash at the current strength the next time the user logs in — that is the one moment the plaintext is available.

verify_and_needs_update checks the password and reports whether the stored verifier is weaker than the current defaults, returning (ok, needs_update).

from veloce import hash_password, verify_and_needs_update

# A legacy PBKDF2 verifier from an older deployment.
stored = hash_password("hunter2", method="pbkdf2:sha256")

ok, needs_update = verify_and_needs_update(stored, "hunter2")
if ok and needs_update:
    # Plaintext is in hand on a successful login — re-hash at the
    # current default (scrypt) and persist the new verifier.
    stored = hash_password("hunter2")

needs_update is True when the stored verifier used a non-default method, or scrypt cost parameters below the current defaults. It is always False on a failed verify — there is nothing to upgrade for a credential that did not match. needs_rehash exposes the same check without verifying, and verify_and_needs_update_async runs the verify on a worker thread for async handlers.

Async handlers

The key derivation is deliberately slow — roughly 100 ms of CPU — so calling hash_password or verify_password directly from an async handler blocks the event loop for that whole time. From async code, use hash_password_async and verify_password_async, which run the KDF on a worker thread and leave the loop free for other requests.

from veloce import (
    HTTPException,
    Request,
    Veloce,
    hash_password,
    hash_password_async,
    verify_password_async,
)

app = Veloce()

# A real app uses a database; a dict keeps the example self-contained.
_USERS: dict[str, str] = {}

# A throwaway hash computed once at startup. On the no-such-user path the
# submitted password is verified against this instead, which runs one real
# KDF so an unknown username costs the same as a wrong password.
_DUMMY_HASH = hash_password("password-that-no-account-uses")


@app.post("/signup")
async def signup(request: Request):
    body = await request.json()
    _USERS[body["username"]] = await hash_password_async(body["password"])
    return {"created": body["username"]}


@app.post("/login")
async def login(request: Request):
    body = await request.json()
    stored = _USERS.get(body["username"])
    # Fall back to the dummy hash when the user does not exist, so an unknown
    # username and a wrong password take the same time (one KDF either way).
    if not await verify_password_async(stored or _DUMMY_HASH, body["password"]):
        raise HTTPException(401, "Incorrect username or password")
    return {"ok": True}

The async variants accept the same arguments as their sync counterparts and return the same values. Keep the sync hash_password / verify_password for sync handlers, scripts, and CLI tools, where there is no event loop to protect.

Defeating username enumeration

verify_password returns immediately — before any KDF — when the stored hash is missing or malformed, so a plain stored is None or verify_password(...) login leaks which usernames exist: an unknown account answers in microseconds while a wrong password takes ~100 ms. Compute one throwaway hash at startup with hash_password(...) and, on the no-such-user path, verify the submitted password against it (verify_password(stored or _DUMMY_HASH, ...), as in the login handler above) so both cases pay exactly one KDF and cost the same.

Password strength policy

is_strong_password is a cheap baseline policy check. It returns True only when the password is at least min_length characters (default 8) and contains at least one letter and at least one digit.

from veloce import is_strong_password

assert is_strong_password("abc12345") is True
assert is_strong_password("short1") is False        # under 8 characters
assert is_strong_password("allletters") is False    # no digit
assert is_strong_password("123456789") is False     # no letter

Tighten the floor with the keyword-only min_length argument:

from veloce import is_strong_password

assert is_strong_password("abc12345", min_length=12) is False
assert is_strong_password("abcdefgh1234", min_length=12) is True

This is a baseline, not a full policy

is_strong_password is intentionally minimal. For NIST SP 800-63B style policy — blocking known-leaked passwords, dropping arbitrary composition rules, removing low maximum-length caps — layer your own checks on top. Reject weak passwords at signup before calling hash_password.

A signup endpoint usually combines the strength check with hashing:

from veloce import (
    HTTPException,
    Request,
    Veloce,
    hash_password_async,
    is_strong_password,
)

app = Veloce()
_USERS: dict[str, str] = {}


@app.post("/signup")
async def signup(request: Request):
    body = await request.json()
    if not is_strong_password(body["password"]):
        raise HTTPException(422, "Password too weak")
    _USERS[body["username"]] = await hash_password_async(body["password"])
    return {"created": body["username"]}

Next steps

  • Security schemes — extract credentials from requests with HTTP Basic, Bearer, API key, and OAuth2 schemes.
  • Signing — issue signed, time-limited tokens for sessions and password-reset links.
  • The API reference has full signatures for every hashing helper.

For background on the algorithms and storage advice, see the OWASP Password Storage cheat sheet.