Skip to content

Password hashing with bcrypt, content digests, HMAC, constant-time comparison, and streaming file hashes — including the 72-byte bcrypt limit that breaks passphrases.

Hashing is one-way. You put data in, you get a fixed-size fingerprint out, and there is no supported way back. That property makes it the right tool for exactly two jobs and the wrong tool for everything else.

The two jobs are storing passwords (you need to check a password without being able to read it) and verifying integrity (you need to know whether two things are identical without comparing them byte by byte). This module covers both, plus the constant-time comparison you need to make either safe.

If you need the original value back, you do not want hashing. You want Crypto, which encrypts and decrypts.

from sillo.helpers import hashing

Password hashing needs bcrypt:

Terminal window
uv add bcrypt

Everything else is stdlib-only and always available.

The most important distinction on this page

Section titled “The most important distinction on this page”

There are two families of function here and they are not interchangeable.

Password hashing (hash_password, verify_password) uses bcrypt, which is deliberately, expensively slow. A single call takes around 250 milliseconds of CPU. That is the point: it makes offline brute-forcing of a stolen database economically painful.

Digests (md5, sha1, sha256, sha512, digest) are deliberately fast. A modern CPU computes hundreds of megabytes of SHA-256 per second, and a GPU rig computes billions of hashes per second.

Hashes a password with bcrypt, generating a fresh random salt each time.

hashed = hashing.hash_password("correct-horse-battery")
# "$2b$12$3HCTa1AWrcvE/..." 60 characters

The output is a 60-character string that packs four things together: the algorithm identifier ($2b$), the cost factor ($12$), the 22-character salt, and the 31-character hash. You store that one string. You do not need a separate salt column — the salt is already in there, which is why verify_password needs only the password and the hash.

Hashing the same password twice gives two different strings, because the salt differs:

hashing.hash_password("same") != hashing.hash_password("same") # True

That is correct and required. It means an attacker who sees two identical hashes in your database learns nothing, and it defeats precomputed rainbow tables.

Checks a candidate password against a stored hash. It reads the salt and cost factor out of the stored hash, recomputes, and compares in constant time.

hashing.verify_password("correct-horse-battery", hashed) # True
hashing.verify_password("wrong", hashed) # False
hashing.verify_password("", hashed) # False

The full login flow:

Registration and login
from sillo import silloApp
from sillo.helpers.hashing import hash_password, verify_password
app = silloApp()
@app.post("/register")
async def register(request, response):
body = await request.json
user = await User.create(
email=body["email"],
password=hash_password(body["password"]), # never store the raw value
)
return response.json({"id": user.id}, status_code=201)
@app.post("/login")
async def login(request, response):
body = await request.json
user = await User.get_or_none(email=body["email"])
# Always run the same work whether or not the account exists, so response
# timing does not reveal which emails are registered.
stored = user.password if user else _DUMMY_HASH
valid = verify_password(body["password"], stored)
if not user or not valid:
return response.json({"error": "Invalid credentials"}, status_code=401)
return response.json({"token": issue_token(user)})

_DUMMY_HASH is a bcrypt hash of any throwaway string, computed once at import. Without it, a request for an unknown email returns in a millisecond while a request for a known email takes 250ms, and an attacker times the difference to enumerate your users.

Reports whether a stored hash was computed with a lower cost factor than you now require. Hardware gets faster, so the cost factor that was expensive in 2015 is cheap today, and you want to raise it over time.

hashing.needs_rehash(hashed, rounds=12) # False — hashed at the current cost
hashing.needs_rehash(hashed, rounds=14) # True — hashed cheaper than you now want

You cannot upgrade a hash on its own, because you do not have the password. You can only upgrade it at login, in the one moment the plaintext is briefly in memory:

Transparent cost upgrades at login
if verify_password(candidate, user.password):
if needs_rehash(user.password, rounds=14):
user.password = hash_password(candidate)
await user.save(update_fields=["password"])
return issue_token(user)

Users who log in are migrated silently. Users who never log in keep the old cost, which is fine — an unused account is not an active risk, and you will never get their plaintext again.

Raising the cost factor doubles login CPU time per increment. Cost 12 is roughly 250ms; cost 14 is roughly one second. Measure on your production hardware before raising it, because login latency is user-visible and bcrypt occupies a whole CPU core for the duration.

Five functions produce a hex-encoded digest of arbitrary data. All accept str or bytes; strings are UTF-8 encoded first.

hashing.md5("hello") # 32 hex chars
hashing.sha1("hello") # 40 hex chars
hashing.sha256("hello") # 64 hex chars
hashing.sha512("hello") # 128 hex chars
hashing.digest("hello") # sha256 by default
hashing.digest("hello", algorithm="sha512") # explicit

digest dispatches by name and raises for an unknown algorithm, which makes it the right choice when the algorithm comes from configuration.

Legitimate uses of a fast digest:

  • Cache keys. Hash a long or awkward key into a fixed-size one.
  • Change detection. Store the digest of a document; if it changes, re-index.
  • Deduplication. Two uploads with the same SHA-256 are the same file.
  • ETags. See Content Negotiation.
Content-addressed uploads
from sillo.helpers.hashing import sha256
@app.post("/upload")
async def upload(request, response):
form = await request.form
data = await form["file"].read()
fingerprint = sha256(data)
existing = await Blob.get_or_none(digest=fingerprint)
if existing:
# Byte-identical file already stored; link rather than duplicate.
return response.json({"id": existing.id, "deduplicated": True})
blob = await Blob.create(digest=fingerprint, size=len(data))
return response.json({"id": blob.id, "deduplicated": False}, status_code=201)

hmac_digest(key, data, algorithm="sha256") -> str

Section titled “hmac_digest(key, data, algorithm="sha256") -> str”

A keyed hash. Anyone can compute sha256(data), but only someone holding the key can compute hmac_digest(key, data). That makes it a proof of authenticity, not just integrity.

signature = hashing.hmac_digest("shared-secret", "user=42&role=admin")
# 64 hex chars, unreproducible without the secret

The standard use is verifying an inbound webhook:

Verifying a webhook signature
from sillo.helpers.hashing import hmac_digest, constant_time_compare
@app.post("/webhooks/stripe")
async def stripe_webhook(request, response):
raw = await request.body # the exact bytes, before JSON parsing
sent = request.headers.get("stripe-signature", "")
expected = hmac_digest(WEBHOOK_SECRET, raw)
if not constant_time_compare(sent, expected):
return response.json({"error": "Bad signature"}, status_code=400)
event = await request.json
await handle_event(event)
return response.json({"received": True})

Two details in that example are not optional. Sign the raw body, not the re-serialized JSON — key order and whitespace change after a parse/dump round trip, and the signature stops matching. And compare with constant_time_compare, not ==.

Compares two values in time that does not depend on where they first differ.

hashing.constant_time_compare("secret", "secret") # True
hashing.constant_time_compare("secret", "s3cret") # False
hashing.constant_time_compare("a", "abcdef") # False

hash_file(path, algorithm="sha256", chunk_size=65536) -> str

Section titled “hash_file(path, algorithm="sha256", chunk_size=65536) -> str”

Hashes a file by streaming it in chunks, so memory use stays flat regardless of file size.

hashing.hash_file("upload.bin") # sha256
hashing.hash_file("upload.bin", algorithm="md5") # md5
hashing.hash_file("upload.bin", chunk_size=1048576) # 1 MiB chunks

The result is identical to hashing the whole file in memory — chunk_size affects only the memory/syscall trade-off, never the digest. A missing path raises OSError.

Hex-encoded random bytes from secrets. Note that the argument is a byte count, so the returned string is twice as long:

len(hashing.random_salt()) # 32 characters (16 bytes, hex-encoded)
len(hashing.random_salt(32)) # 64 characters

You do not need this for passwords. hash_password generates and embeds its own salt. random_salt is for the cases where you are building something else that needs a nonce or a unique per-record value.

Do not store passwords with a digest function. sha256, md5, and friends are too fast. Use hash_password.

Do not add your own salt to hash_password. bcrypt generates and stores one. Prepending your own achieves nothing and breaks verify_password.

Do not compare secrets with ==. Use constant_time_compare.

Do not sign parsed JSON. Sign the raw request body. Re-serializing changes key order and whitespace, and the signature stops matching.

Do not let unbounded passwords reach hash_password. Cap length at 72 bytes in validation, and do not truncate silently.

Do not call hash_file in an async handler. It blocks the event loop for every connected client. Thread it or queue it.

Do not use needs_rehash without also rehashing. Detecting a weak hash and doing nothing leaves the weak hash in place.

Do not log a hash. A bcrypt hash is not a password, but it is offline-crackable material. Treat the column as a secret.

OperationRough costNotes
hash_password~250 msCost factor 12. Deliberate. One full CPU core.
verify_password~250 msSame cost as hashing, by design
sha256 of 1 KBmicrosecondsEffectively free
hmac_digest of 1 KBmicrosecondsEffectively free
hash_file of 1 GBsecondsDisk-bound; thread it

The password functions dominate any endpoint they appear on. A login handler cannot serve more concurrent requests than you have CPU cores, because each one occupies a core for a quarter of a second. That is the correct trade, but plan capacity around it and rate-limit login endpoints so an attacker cannot use your own hashing cost as a denial-of-service lever. See Rate Limiting.

FunctionSignatureReturns
hash_password(password: str)60-char bcrypt hash
verify_password(password: str, hashed: str)bool, constant-time
needs_rehash(hashed: str, rounds: int = 12)bool
md5(data: str | bytes)32 hex chars
sha1(data: str | bytes)40 hex chars
sha256(data: str | bytes)64 hex chars
sha512(data: str | bytes)128 hex chars
digest(data: str | bytes, algorithm: str = "sha256")Hex digest; raises on unknown algorithm
hmac_digest(key, data, algorithm: str = "sha256")Keyed hex digest
constant_time_compare(a, b)bool
hash_file(path: str, algorithm: str = "sha256", chunk_size: int = 65536)Hex digest; raises OSError
random_salt(length: int = 16)Hex string, 2 × length chars
  • Crypto — for values you need to read back
  • Strings — token generation and masking
  • JWT — signed tokens built on HMAC
  • Authentication — the full auth stack
  • Rate Limiting — protecting expensive login endpoints