ProvenPin Install

passlib bcrypt hashing with a cryptography HMAC pepper: working versions (tested 2026-10-15)

Hash and verify passwords with passlib's CryptContext bcrypt scheme, prefixing each password with a cryptography HMAC-SHA256 pepper so bcrypt's 72 byte limit cannot silently truncate, and cover identify, unknown hashes and work factor rehash detection offline.

passed5 passed · 0 failed2026-10-15 15:57 UTC
Status
passed
Last passed
2026-10-15 15:57 UTC
Tests
5 / 5
Language
Python
Runtime
Python 3.13.16
pinned versions4
passlib@1.7.4bcrypt@4.0.1cryptography@50.0.2pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38764699834

The code

Exactly the files that passed. Your agent can pull them with get_recipe("cryptography-passlib-bcrypt") (install), or see them on GitHub.

requirements.txt
passlib==1.7.4
bcrypt==4.0.1
cryptography==50.0.2
pytest==9.1.1
passwords.py
import base64

from cryptography.hazmat.primitives import hashes, hmac
from passlib.context import CryptContext

bcrypt_ctx = CryptContext(schemes=["bcrypt"], bcrypt__rounds=12)


def pepper(password: str, key: bytes) -> bytes:
    h = hmac.HMAC(key, hashes.SHA256())
    h.update(password.encode("utf-8"))
    return base64.b64encode(h.finalize())


def hash_password(password: str, key: bytes) -> str:
    return bcrypt_ctx.hash(pepper(password, key))


def verify_password(password: str, stored_hash: str, key: bytes) -> bool:
    return bcrypt_ctx.verify(pepper(password, key), stored_hash)


def needs_rehash(stored_hash: str) -> bool:
    return bcrypt_ctx.needs_update(stored_hash)


def hash_scheme(stored_hash: str) -> str | None:
    return bcrypt_ctx.identify(stored_hash)
tests/test_passwords.py
import pytest
from passlib.context import CryptContext
from passlib.exc import UnknownHashError

from passwords import (
    bcrypt_ctx,
    hash_password,
    hash_scheme,
    needs_rehash,
    pepper,
    verify_password,
)

KEY = bytes(range(32))


def test_roundtrip_salts_each_hash():
    first = hash_password("correct horse battery staple", KEY)
    second = hash_password("correct horse battery staple", KEY)
    assert first != second
    assert verify_password("correct horse battery staple", first, KEY)
    assert not verify_password("wrong password", first, KEY)
    assert hash_scheme(first) == "bcrypt"


def test_pepper_avoids_the_72_byte_truncation_collision():
    base = "a" * 72
    left = hash_password(base + "LEFT", KEY)
    assert not verify_password(base + "RIGHT", left, KEY)
    raw = bcrypt_ctx.hash(base + "LEFT")
    assert bcrypt_ctx.verify(base + "RIGHT", raw)


def test_pepper_key_is_required_and_deterministic():
    stored = hash_password("hunter2", KEY)
    assert verify_password("hunter2", stored, KEY)
    assert not verify_password("hunter2", stored, bytes(32))
    assert pepper("hunter2", KEY) == pepper("hunter2", KEY)
    assert len(pepper("x" * 1000, KEY)) < 72


def test_verify_raises_for_unidentifiable_hashes():
    forged = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$abcdef"
    assert hash_scheme(forged) is None
    with pytest.raises(UnknownHashError):
        verify_password("hunter2", forged, KEY)


def test_needs_rehash_tracks_work_factor():
    cheap_ctx = CryptContext(schemes=["bcrypt"], bcrypt__rounds=4)
    cheap = cheap_ctx.hash(pepper("hunter2", KEY))
    current = hash_password("hunter2", KEY)
    assert needs_rehash(cheap)
    assert not needs_rehash(current)
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.