Skip to main content

xtr-password-hasher

Password hashing behind one interface: argon2id by default, legacy hashes verified and upgraded.

python 3.11+ typed license MIT

Why?

Storing a password is not "call a hash function". It is choosing an algorithm strong enough for today, reading back hashes made by yesterday's choice, and moving each account to the current one the next time its owner signs in — without the calling code knowing which era a hash came from.

This package puts all of that behind one small interface:

  • 🔐 argon2id by default — memory-hard, so a stolen hash is expensive to attack; bcrypt and PBKDF2 are there to read hashes other stacks made.
  • 🔁 Verify then upgrade — a hash made by an older algorithm still verifies, and needs_rehash tells you the moment to replace it (right after a successful check, the one time the plaintext is in hand).
  • 🧩 One hasher per user kind — a factory picks the hasher by the user's class or a name it declares, so people and machine accounts can hash differently.
  • 🪶 One runtime dependency — pwdlib with argon2.

The xtr-security bundle configures this package — the factory, a user-facing hasher and the security:hash-password command — for an application on xtr-dependency-injection. This package ships no bundle of its own.

hasher = NativePasswordHasher()
stored = hasher.hash("correct horse battery staple")
hasher.verify(stored, "correct horse battery staple")  # True

Install

uv add xtr-password-hasher                 # argon2id, PBKDF2 and plaintext
uv add "xtr-password-hasher[bcrypt]"       # + bcrypt, for hashes from other stacks
uv add "xtr-password-hasher[console]"      # + the security:hash-password command

Requires Python 3.11+.

Quick start

from xtr_password_hasher import NativePasswordHasher

hasher = NativePasswordHasher()  # argon2id

stored = hasher.hash(plain)  # keep this string; it describes its own algorithm
...
if hasher.verify(stored, attempt):
    if hasher.needs_rehash(stored):
        stored = hasher.hash(attempt)  # re-store under the current settings

Per-user hashing goes through a factory and the user hasher, which reads the stored hash off the user itself:

from xtr_password_hasher import NativePasswordHasher, PasswordHasherFactory, UserPasswordHasher


class Account:
    def __init__(self, password: str | None = None) -> None:
        self._password = password

    def get_password(self) -> str | None:
        return self._password


hasher = UserPasswordHasher(PasswordHasherFactory({Account: NativePasswordHasher()}))

account = Account(hasher.hash_password(Account(), "s3cret"))
hasher.is_password_valid(account, "s3cret")  # True
hasher.needs_rehash(account)  # False, until the settings change

A password that encodes to more than MAX_PASSWORD_LENGTH (4096) UTF-8 bytes is refused by hash with InvalidPasswordError and can never match in verify — a guard against spending a hasher's whole cost on one unbounded input, not a policy on how long a password may be. The bound is on bytes, not characters, because that is the work a hasher reads: a multi-byte character costs its bytes.

Hashers

Hasher What it is
NativePasswordHasher argon2id (default) or bcrypt, on pwdlib; verifies hashes of either when the backend is installed
Pbkdf2PasswordHasher PBKDF2-HMAC from the standard library, salted from outside — for legacy hashes
PlaintextPasswordHasher stores the password as it is, {salt} appended — tests only
MigratingPasswordHasher hashes with a preferred hasher, verifies older ones behind it

Every hasher answers the same three methods — hash(plain_password) -> str, verify(hashed_password, plain_password) -> bool, needs_rehash(hashed_password) -> bool — so they compose. MigratingPasswordHasher(best, *extras) hashes new passwords with best, verifies a hash best recognises with best alone, and offers a hash it does not to each extra in turn. Never put a PlaintextPasswordHasher among the extras: a leaked hash would then be a working password.

Hashers salted from outside

argon2id and bcrypt mint a salt and keep it inside the hash. Older schemes stored the salt in a column of its own and only the digest in the hash; a hasher for those implements LegacyPasswordHasherInterface, whose hash and verify take a salt (None for none):

from xtr_password_hasher import Pbkdf2PasswordHasher

hasher = Pbkdf2PasswordHasher()  # sha512, base64, 1000 iterations, 40 bytes
hasher.verify(stored_digest, attempt, stored_salt)

Pbkdf2PasswordHasher(algorithm="sha512", encode_hash_as_base64=True, iterations=1000, length=40) writes the derived key alone, base64 or hex; its defaults are the ones such hashes were typically made with. The hash carries no parameters, so needs_rehash is always False — keep it behind a self-salting hasher in a MigratingPasswordHasher, which passes the salt on to the hashers that take one. PlaintextPasswordHasher(ignore_password_case=False) merges a salt as password{salt} and refuses a salt holding { or }.

A user whose salt is stored beside its password implements LegacyPasswordAuthenticatedUserInterface — get_salt() next to get_password() — and UserPasswordHasher hands that salt to the hasher. Both interfaces share their method names with the self-salting ones, so isinstance cannot tell them apart: ask is_legacy_password_hasher(hasher), which checks the declared interface.

Migrating legacy hashes

argon2id is the default because it is memory-hard: an attacker needs both time and a lot of memory per guess, which blunts the massively parallel hardware that makes bcrypt and PBKDF2 cheaper to attack. New passwords should be argon2id.

But you rarely start clean. A NativePasswordHasher verifies a hash produced by another stack as long as its backend is present — an $argon2id$… string, or a bcrypt $2a$ / $2b$ / $2y$ string with the bcrypt extra installed — and reports it through needs_rehash so it is upgraded on the next successful sign-in. The auto configuration builds exactly this: argon2id in front, bcrypt (when installed) and PBKDF2 behind it.

bcrypt's 72-byte limit. bcrypt reads at most 72 bytes and refuses a longer input outright. A longer password is folded first — the base64 of its SHA-512 digest, cut to the 72 bytes bcrypt would itself have kept — on both hashing and verifying, so a long password stays stable and a hash another stack made the same way still verifies.

Hashing off the event loop

A single argon2id hash costs tens of milliseconds of CPU by design. That is fine in a script or a command, but in an async server it would block the event loop and stall every other request for that whole time. The hashers are synchronous and CPU-bound on purpose; call them through a worker thread in async code:

import anyio

stored = await anyio.to_thread.run_sync(hasher.hash, plain)
ok = await anyio.to_thread.run_sync(hasher.verify, stored, attempt)

In an application

This package ships no bundle. The xtr-security bundle configures it for an application on xtr-dependency-injection: it registers a PasswordHasherFactoryInterface, a UserPasswordHasherInterface, and the security:hash-password command, and maps user classes to hashers through the security configuration. A service then asks for either interface by its type:

from xtr_password_hasher import UserPasswordHasherInterface


class SignUp:
    def __init__(self, hasher: UserPasswordHasherInterface) -> None:
        self._hasher = hasher

PasswordHasherFactory takes a mapping of user class, "module:Class" string or declared name to a ready hasher instance — the factory carries no configuration vocabulary, so it hashes the same standalone as it does behind a container. Turning inert configuration (argon2 costs, migrating chains, a hasher the container provides) into a live hasher is the security bundle's job: it reads the password_hashers of the security configuration, builds each hasher, and hands the factory the instances. create_auto_password_hasher() builds the one an application gets when it asks for no particular hasher — argon2id, with bcrypt (when installed) and PBKDF2 verifying behind it — and is what the bundle's auto configuration builds. A user chooses its hasher by class (walking its bases), or by a name it returns from PasswordHasherAwareInterface.get_password_hasher_name().

Console command

With the console extra, once the xtr-security bundle registers it:

Command Does
security:hash-password [PASSWORD] [USER-CLASS] [--empty-salt] Hashes PASSWORD with the hasher configured for USER-CLASS and prints the hasher, the hash and any generated salt

The command takes the factory and the configured user classes from its constructor — UserPasswordHashCommand(factory, user_classes); the security bundle supplies both. With no USER-CLASS it hashes for the first configured user class, asking which when several are configured and the run is interactive; USER-CLASS is a module:Class or the name of a configured hasher.

The password is asked for, hidden, when left out, and must not be blank. A password given as an argument lands in the shell history and the process list, so an interactive run warns about it; - reads it from standard input instead (echo "$PASSWORD" | … security:hash-password -n -- -). A hasher salted from outside gets a generated salt — 30 random bytes, base64 — printed with the hash for you to store beside it, after a confirmation when the run is interactive; --empty-salt hashes without one.

Errors

Every error derives from PasswordHasherError and carries what went wrong as typed attributes.

Error Raised when
InvalidPasswordError A password encodes to more than MAX_PASSWORD_LENGTH UTF-8 bytes (also a ValueError)
UnknownPasswordHasherError No hasher is configured for a user, class or name (also a LookupError)
InvalidArgumentError A hasher or a config is given a value it cannot use (also a ValueError)

Layout

xtr_password_hasher/
├── password_hasher_interface.py            hash / verify / needs_rehash, and MAX_PASSWORD_LENGTH
├── legacy_password_hasher_interface.py     the same, salted from outside; is_legacy_password_hasher
├── password_authenticated_user_interface.py
├── legacy_password_authenticated_user_interface.py
├── hasher/                                 the hashers, the factory, the user hasher,
│                                           and create_auto_password_hasher
├── command/                                security:hash-password (UserPasswordHashCommand)
└── exception/                              one error per module, all a PasswordHasherError

Development

Developed in the python-xtr monorepo, under packages/xtr-password-hasher; run the commands below from there. The python-xtr-password-hasher repository is a read-only copy, so send issues and pull requests to the monorepo.

uv sync --all-extras
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest

License

MIT — see LICENSE.

Metadata

Release files for xtr-password-hasher 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-password-hasher 3.0.0
File Size Uploaded
xtr_password_hasher-3.0.0.tar.gz 21.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-password-hasher 3.0.0
File Interpreter ABI Platform
xtr_password_hasher-3.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 54.5 kB

Release files / xtr_password_hasher-3.0.0.tar.gz

Download URL xtr_password_hasher-3.0.0.tar.gz
Size 21.2 kB
Tags Source
SHA-256 checksum
How to use checksums
9fcbf95d084e35c9ac1cd227f7bb761806593d97ca8e141f36c7ea42b9b9508f
BLAKE2b-256 checksum
How to use checksums
bd9e2f7835061355468636d0c9408a15cd562cd32ec0e281fcdece1141da2a42
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release files / xtr_password_hasher-3.0.0-py3-none-any.whl

Download URL xtr_password_hasher-3.0.0-py3-none-any.whl
Size 33.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
25f023ca15a4ab1c2499e0a2a8c7c97e3e5f81f9e3ebd97aba0a6274bf7bb806
BLAKE2b-256 checksum
How to use checksums
d0eeca7a4f7978f55c19c57731540b293ce6afa8cc1143f93f9ae182291e6680
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

3.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page