Skip to content

Symmetric encryption with Fernet, PBKDF2 key derivation, and HMAC-signed values — including key rotation, storage rules, and the max_age parameter that does nothing.

Hashing is for data you never need to read again. This module is for the other case: data you must be able to recover, or data you need to hand to a client and trust when it comes back.

Two capabilities, and they solve different problems:

  • Encryption hides content. Someone with the ciphertext and no key learns nothing. Use it for stored third-party API tokens, personal data at rest, anything your database backup should not expose.
  • Signing does not hide anything. It proves a value was produced by you and has not been modified. The value stays readable. Use it for cookies, unsubscribe links, and any value that makes a round trip through a client.

Reaching for the wrong one is the usual mistake. If the user must not read it, encrypt. If the user may read it but must not change it, sign.

Terminal window
uv add cryptography
from sillo.helpers import crypto

The signing functions (sign_value, unsign_value) are stdlib-only and work without cryptography. The encryption functions require it.

Symmetric means one key both encrypts and decrypts. Anyone holding the key can do both, so the key is the entire security boundary.

Produces a Fernet key: 32 random bytes, base64-encoded to 44 characters.

key = crypto.generate_key()
# b'GAtE_ZQ78Peo...' 44 bytes

encrypt(value, key) -> str and decrypt(token, key) -> str

Section titled “encrypt(value, key) -> str and decrypt(token, key) -> str”
token = crypto.encrypt("sk_live_abc123", key)
# 'gAAAAABqZ6KSC2KP79YBBhvLML3s...' ~100 chars
crypto.decrypt(token, key) # 'sk_live_abc123'

Encryption uses Fernet, which is AES-128 in CBC mode with an HMAC-SHA256 authentication tag. That combination gives you two guarantees rather than one: confidentiality (the plaintext is hidden) and authenticity (tampering is detected rather than producing garbage plaintext).

Encrypting the same value twice gives different ciphertext, because a fresh random IV is used each time:

crypto.encrypt("x", key) != crypto.encrypt("x", key) # True

That is required. Deterministic encryption leaks equality — an attacker who sees two identical ciphertexts learns the plaintexts are identical, which is often enough to break the scheme in practice.

Decrypting with the wrong key raises InvalidToken from the cryptography package, and so does decrypting a ciphertext that has been modified by even one byte.

Storing a third-party API token
from sillo.helpers.crypto import encrypt, decrypt
@app.post("/integrations/stripe")
async def connect_stripe(request, response):
body = await request.json
await Integration.create(
user_id=request.user.id,
provider="stripe",
token_encrypted=encrypt(body["api_key"], settings.ENCRYPTION_KEY),
)
return response.json({"connected": True}, status_code=201)
async def call_stripe(user_id: int):
integration = await Integration.get(user_id=user_id, provider="stripe")
api_key = decrypt(integration.token_encrypted, settings.ENCRYPTION_KEY)
return await stripe_client.charge(api_key, ...)

Fernet keys have no expiry, so rotation is a process you build rather than a flag you set. The pattern is to decrypt with the old key and re-encrypt with the new one, in a background job:

Rotating an encryption key
from sillo.helpers.crypto import decrypt, encrypt
async def rotate_batch(old_key: bytes, new_key: bytes, limit: int = 500) -> int:
rows = await Integration.filter(key_version=1).limit(limit)
for row in rows:
plaintext = decrypt(row.token_encrypted, old_key)
row.token_encrypted = encrypt(plaintext, new_key)
row.key_version = 2
await row.save(update_fields=["token_encrypted", "key_version"])
return len(rows)

Keep a key_version column from the start. Adding one later means guessing which key each row used, and the only way to guess is trial decryption.

derive_key(password, salt=None, length=32, iterations=600000)

Section titled “derive_key(password, salt=None, length=32, iterations=600000)”

Turns a human password into a cryptographic key using PBKDF2-HMAC-SHA256. Returns a (key, salt) tuple.

key, salt = crypto.derive_key("correct-horse-battery")
len(key) # 32
len(salt) # 16
# Same password + same salt -> same key
key2, _ = crypto.derive_key("correct-horse-battery", salt=salt)
key2 == key # True
# No salt -> a new random salt, so a different key
key3, salt3 = crypto.derive_key("correct-horse-battery")
key3 == key # False

600,000 iterations is the OWASP recommendation for PBKDF2-HMAC-SHA256 and takes roughly a quarter of a second. Lowering it makes offline attacks on the password proportionally cheaper. Raising it costs your users latency on every derivation.

sign_value(value, secret, algorithm="sha256") -> str

Section titled “sign_value(value, secret, algorithm="sha256") -> str”

Appends an HMAC signature to a value:

signed = crypto.sign_value("user-42", secret="app-secret")
# 'dXNlci00Mg.1c6d6e1bd46195aafb5bc593f78574ff3024ad51303c9d6ce2195ea37d3a4c19'

The format is base64url(value).hex_signature. The value is encoded, not encrypted — anyone can base64-decode the first segment and read it. What they cannot do is change it, because they cannot recompute the signature without the secret.

unsign_value(signed, secret, algorithm="sha256", max_age=None) -> str

Section titled “unsign_value(signed, secret, algorithm="sha256", max_age=None) -> str”

Verifies and returns the original:

crypto.unsign_value(signed, secret="app-secret") # 'user-42'

Raises BadSignature on a wrong secret, a modified payload, a modified signature, or malformed input:

from sillo.helpers.crypto import BadSignature
try:
value = crypto.unsign_value(cookie, secret=settings.SECRET_KEY)
except BadSignature:
return response.json({"error": "Invalid token"}, status_code=400)

Verification uses hmac.compare_digest, so it is not vulnerable to the timing attack described in Hashing.

A worked example, an unsubscribe link that needs no database lookup:

Stateless unsubscribe links
from sillo.helpers.crypto import sign_value, unsign_value, BadSignature
def unsubscribe_url(user_id: int) -> str:
token = sign_value(f"unsub:{user_id}", settings.SECRET_KEY)
return f"https://example.com/unsubscribe?t={token}"
@app.get("/unsubscribe")
async def unsubscribe(request, response):
try:
value = unsign_value(request.query_params.get("t", ""), settings.SECRET_KEY)
except BadSignature:
return response.json({"error": "Invalid link"}, status_code=400)
_, _, user_id = value.partition(":")
await User.filter(id=int(user_id)).update(subscribed=False)
return response.json({"unsubscribed": True})

The user can read their own ID in the token, which is fine — it is their ID. They cannot change it to somebody else’s without invalidating the signature.

RequirementTool
Never need it back (passwords)hash_password
Need it back, must stay secretencrypt / decrypt
Client may read it, must not change itsign_value / unsign_value
Client-readable, tamper-proof, with expiryJWT
Key from a user passphrasederive_key

Do not commit an encryption key. Environment or secret manager only.

Do not default a missing key. Crash at boot instead.

Do not use derive_key for password storage. Use hash_password.

Do not unpack derive_key as a single value. It returns (key, salt).

Do not throw the salt away. Without it the key cannot be re-derived and the data is unrecoverable.

Do not rely on max_age. It is ignored. Put the expiry inside the signed payload.

Do not sign secrets. Signing authenticates, it does not conceal. Encrypt instead.

Do not query encrypted columns. Add an HMAC lookup column.

Do not lower the PBKDF2 iteration count to speed things up. That is the security parameter.

Do not reuse one key everywhere. Separate keys for encryption, lookup HMACs, and session signing, so one leak is not total.

OperationRough cost
generate_keymicroseconds
encrypt / decryptmicroseconds for small values
sign_value / unsign_valuemicroseconds
derive_key~250 ms at 600,000 iterations

Only derive_key is expensive, and deliberately so. Never call it per request. Derive once at startup or at login, cache the resulting key in memory for the session, and never write the derived key to disk.

Fernet output is larger than its input: roughly 100 characters for a short string, because of the version byte, timestamp, IV, and HMAC tag. Size database columns for ciphertext length, not plaintext length.

FunctionSignatureReturns
generate_key()44-byte base64 Fernet key
encrypt(value: str, key: bytes)Fernet token string
decrypt(token: str, key: bytes)Original string; raises InvalidToken
derive_key(password: str, salt: bytes | None = None, length: int = 32, iterations: int = 600000)(key, salt) tuple
sign_value(value: str, secret: str, algorithm: str = "sha256")base64url(value).signature
unsign_value(signed: str, secret: str, algorithm: str = "sha256", max_age: int | None = None)Original value; raises BadSignature. max_age is ignored.
BadSignatureExceptionRaised on any verification failure
  • Hashing — one-way hashing, password storage, HMAC
  • JWT — signed tokens with working expiry
  • Sessions — signed session cookies built on these primitives
  • Strings — token generation and secret masking