Skip to content

Utilities

Cross-cutting helpers: password hashing, email and identifier normalization, safe redirect validation, client-IP resolution, and display masking.

crudauth.utils.normalize_password

normalize_password(password: str) -> str

Unicode-normalize a password (NFKC) so every way of typing it hashes the same.

é typed as one precomposed code point and as e plus a combining accent normalize to the same string, as NIST SP 800-63B recommends.

crudauth.utils.get_password_hash

get_password_hash(password: str) -> str

Hash a plaintext password with bcrypt (random salt per call).

The password is NFKC-normalized (see normalize_password) and SHA-256 pre-hashed before bcrypt (see [_bcrypt_input][crudauth.utils.hashing._bcrypt_input]), so there is no effective length ceiling and no silent truncation. This call blocks for the bcrypt work; use get_password_hash_async inside an async route.

Example
await auth.repo.create(db, {"email": e, "hashed_password": get_password_hash(pw)})

crudauth.utils.get_password_hash_async async

get_password_hash_async(password: str) -> str

get_password_hash in a worker thread, off the event loop.

Example
hashed = await get_password_hash_async(pw)
await auth.repo.update(db, user, {"hashed_password": hashed})

crudauth.utils.verify_password

verify_password(
    plain_password: str, hashed_password: str | None
) -> bool

Verify a plaintext password against a bcrypt hash.

The NFKC-normalized password is checked first, then the password as typed, so hashes created before normalization keep verifying.

Returns False (rather than raising) when the stored hash is missing, empty, the unusable sentinel, or malformed, so a corrupted row produces a clean "invalid password" path instead of a 500. Those cases still pay a full bcrypt verification, so an account without a verifiable hash answers in the same time as one with a real hash. This call blocks for the bcrypt work; use verify_password_async inside an async route.

Example
if not verify_password(form.password, auth.repo.get(user, "hashed_password")):
    raise UnauthorizedException("Incorrect username or password")

crudauth.utils.verify_password_async async

verify_password_async(
    plain_password: str, hashed_password: str | None
) -> bool

verify_password in a worker thread, off the event loop.

Example
if not await verify_password_async(form.password, auth.repo.get(user, "hashed_password")):
    raise UnauthorizedException("Incorrect username or password")

crudauth.utils.verify_and_update_password

verify_and_update_password(
    plain_password: str, hashed_password: str | None
) -> tuple[bool, str | None]

Verify a password and return a replacement hash when the stored one predates normalization.

Verification is the same as verify_password.

Returns:

Type Description
bool

(verified, new_hash). new_hash is a fresh hash of the normalized

str | None

password when the stored hash only matched the password as typed (a hash

tuple[bool, str | None]

created before normalization), and None otherwise.

Example
verified, new_hash = verify_and_update_password(pw, auth.repo.get(user, "hashed_password"))
if verified and new_hash is not None:
    await auth.repo.update(db, user, {"hashed_password": new_hash})

crudauth.utils.verify_and_update_password_async async

verify_and_update_password_async(
    plain_password: str, hashed_password: str | None
) -> tuple[bool, str | None]

verify_and_update_password in a worker thread, off the event loop.

crudauth.utils.dummy_verify_password

dummy_verify_password(plain_password: str) -> None

Run a throwaway bcrypt verification and discard the result.

For a hand-written flow's user-not-found branch, so the absent-user path pays the same bcrypt cost as the existing-user path; without it, a missing account returns measurably faster and becomes a user-enumeration oracle. verify_password with a None hash does the same work.

crudauth.utils.make_unusable_password

make_unusable_password() -> str

Return a sentinel that no input can ever verify against.

Used for OAuth-only accounts. The leading ! makes the value an invalid bcrypt hash, so verify_password always returns False for it. The random suffix makes every sentinel unique. Mirrors Django's set_unusable_password.

Example
# an OAuth-created account with no password yet
await auth.repo.create(db, {"email": e, "hashed_password": make_unusable_password()})

crudauth.utils.is_unusable_password

is_unusable_password(hashed_password: str) -> bool

Whether hashed_password is the unusable sentinel (or empty).

True means the account has no real password set - an OAuth-only account (see make_unusable_password, whose sentinel starts with !, never a valid bcrypt hash).

Example
if is_unusable_password(auth.repo.get(user, "hashed_password", "")):
    ...  # OAuth-only: offer /set-password rather than a password change

crudauth.utils.canonical_email

canonical_email(email: str) -> str
canonical_email(email: None) -> None
canonical_email(email: str | None) -> str | None

Normalize an email for storage/comparison (trim + lowercase).

Ensures a user created via Google as Foo@x.com can log in by password as foo@x.com without surprises.

crudauth.utils.canonical_identifier

canonical_identifier(identifier: str) -> str

Normalize a login identifier into its lockout key (trim + casefold).

Case variants of one identifier (v@x.com / V@x.com, bob / BOB) collapse to a single key, so an attacker can't reset the per-username counter by varying the case while a case-insensitive lookup or collation still reaches the same account. A key coarser than the lookup only makes the lockout stricter.

crudauth.utils.mask_email

mask_email(email: str) -> str

Mask an email for display: john@example.com -> j***@example.com.

A display helper for shoulder-surfing / casual logs - not a security control (it's obfuscation, not a guarantee). Returns "***" when there's no @; keeps only the first local-part character (so a single-char local part can't leak more than that one character).

Example
mask_email("john@example.com")  # "j***@example.com"
mask_email("a@x.io")            # "a***@x.io"
mask_email("not-an-email")      # "***"

crudauth.utils.is_cross_site

is_cross_site(request: Request) -> bool

Whether the browser marked request as sent from another site (Sec-Fetch-Site).

A request without the header (an API client, an older browser) isn't cross-site.

crudauth.utils.get_client_ip

get_client_ip(
    request: Request, trusted_hops: int = 0
) -> str

Resolve the client IP with a trusted-proxy boundary.

X-Forwarded-For is client-controllable at its left end, so honoring it blindly lets an attacker forge a fresh IP per request and slip every per-IP rate limit and lockout. This function only consults the header when the app declares how many trusted proxies sit in front of it.

Parameters:

Name Type Description Default
request Request

The incoming request.

required
trusted_hops int

Number of trusted reverse proxies in front of the app. 0 (default) ignores forwarding headers entirely and uses the socket peer - correct when the app is directly exposed. N reads the N-th X-Forwarded-For entry from the right: each trusted proxy appends the address it received the request from, so that entry is the client address your outermost proxy saw, and values an attacker prepends sit further left where they are never read. A chain shorter than N resolves to its left-most entry. Repeated X-Forwarded-For header lines are read as one list, in order.

0

Returns:

Type Description
str

The resolved client IP, or "unknown" if it cannot be determined.

Example
# App behind a single trusted reverse proxy (e.g. nginx, Caddy):
CRUDAuth(..., trusted_proxy_hops=1)

crudauth.utils.client_ip_key

client_ip_key(ip: str) -> str

The rate-limit and lockout key for a client IP.

An IPv4 address keys as itself. An IPv6 client keys by its /64 network, the smallest block a single subscriber is normally assigned, so rotating addresses inside one allocation can't mint fresh budgets. An IPv4-mapped IPv6 address keys as its IPv4 address. Anything that isn't an IP ("unknown") is returned unchanged.

Example
client_ip_key("2001:db8:1:2:3:4:5:6")  # "2001:db8:1:2::/64"
client_ip_key("::ffff:203.0.113.7")    # "203.0.113.7"

crudauth.utils.safe_redirect_path

safe_redirect_path(
    target: str | None, default: str = "/"
) -> str

Return a safe same-origin redirect path, or default when rejected.

Only single-slash-rooted relative paths are accepted. Absolute URLs, protocol-relative URLs, backslashes, control characters, and values with a URL scheme or network location are rejected. Use this for client-supplied post-login or post-logout redirect targets to prevent open redirects.

Parameters:

Name Type Description Default
target str | None

The untrusted redirect target.

required
default str

The fallback path returned for an unsafe or missing target.

'/'