Signing¶
Signer turns a JSON-serialisable value into
a signed, timestamped, URL-safe token and verifies it later.
Use it when the server hands a value to the client, the client returns it, and the server needs to know the value was not tampered with — password-reset links, email-confirmation tokens, signed cookies, and similar.
Signing is built on HMAC-SHA256 (RFC 2104) from the standard library.
from veloce.signing import Signer
signer = Signer(secret="server-secret", salt="reset-token")
token = signer.dumps({"user_id": 42})
data = signer.loads(token)
assert data == {"user_id": 42}
Import path
Signer, BadSignature, BadTimeSignature, and BadData are part
of the top-level public API and can also be imported directly with
from veloce import Signer. This guide imports from
veloce.signing to keep the source module explicit.
Warning
A token is not encryption — the payload is signed, not hidden. Anyone can read the value inside a token; they just cannot change it without the secret. Do not put data in a token that the client must not see.
Signing and reading values¶
Signer.dumps serialises the value to
JSON, appends a timestamp, and signs both.
Signer.loads verifies the signature
and returns the original value. The signature is checked before the payload
is decoded, so a tampered token is rejected before its JSON ever reaches the
parser.
from veloce.signing import Signer
signer = Signer(secret="server-secret")
token = signer.dumps({"email": "alice@example.com", "action": "confirm"})
claims = signer.loads(token)
assert claims["email"] == "alice@example.com"
The value passed to dumps may be any JSON-serialisable object — a dict,
a list, a string, a number. loads returns exactly what you put in.
Per-purpose salts¶
The salt argument (default "veloce.signing") derives a distinct
signing key from the same secret. Tokens signed with one salt do not
verify under another, so you can share one application secret across
purposes — sessions, CSRF, password reset — without those tokens
cross-validating.
from veloce.signing import BadSignature, Signer
reset_signer = Signer(secret="app-secret", salt="password-reset")
confirm_signer = Signer(secret="app-secret", salt="email-confirm")
token = reset_signer.dumps({"user_id": 7})
# The same secret with a different salt cannot read the token.
try:
confirm_signer.loads(token)
except BadSignature:
print("reset token rejected by the confirm signer")
Use a different, descriptive salt per token purpose. The secret stays the same; the salt keeps the namespaces apart.
Timed signatures¶
Pass max_age (in seconds) to Signer.loads
to reject tokens older than a window. This is what makes a password-reset
link expire.
from veloce.signing import BadTimeSignature, Signer
signer = Signer(secret="server-secret", salt="password-reset")
token = signer.dumps({"user_id": 42})
# Within the window the token reads back normally.
assert signer.loads(token, max_age=3600) == {"user_id": 42}
# A token whose age exceeds max_age raises BadTimeSignature. A negative
# window forces that path for a freshly minted token: its age (0 seconds)
# already exceeds a -1 second window.
try:
signer.loads(token, max_age=-1)
except BadTimeSignature:
print("token expired")
The timestamp is embedded in the token at dumps time, so the same token
can be checked against different max_age windows by different callers.
A token whose timestamp is in the future (a clock skew or a forged date)
also raises BadTimeSignature.
Handling failures¶
Verification raises one of three exceptions, all subclasses of
BadSignature:
BadSignature— the signature did not match the configured secret (tampering, wrong secret, or wrong salt).BadTimeSignature— the signature verified but the token is older thanmax_age(or future-dated).BadData— the token was structurally malformed (bad base64, invalid JSON, or not a string).
Because BadTimeSignature and BadData both inherit from
BadSignature, a single except BadSignature catches all three when you
do not need to distinguish them.
from veloce import HTTPException, Veloce
from veloce.signing import BadSignature, Signer
app = Veloce()
signer = Signer(secret="server-secret", salt="password-reset")
@app.get("/reset/{token}")
async def reset(token: str):
try:
claims = signer.loads(token, max_age=3600)
except BadSignature:
raise HTTPException(400, "Invalid or expired reset link")
return {"user_id": claims["user_id"]}
Catching BadSignature here covers expired links, tampered tokens, and
malformed input with one branch — the user sees the same generic error
for all of them, which avoids leaking why a token was rejected.
Rotating the secret¶
To change the signing secret without invalidating tokens already in the
wild, configure the new secret as primary and register the old one as a
fallback with add_fallback_secret.
New tokens are signed with the primary secret; tokens signed with the
fallback still verify during the rotation window.
from veloce.signing import Signer
signer = Signer(secret="new-secret", salt="password-reset")
signer.add_fallback_secret("old-secret", salt="password-reset")
# Tokens minted under the old secret still verify; new tokens use the new one.
The fallback secret is accepted for verification only — it is never used to sign new tokens. Remove the fallback once the rotation window has passed and old tokens have expired.
An empty fallback is refused, the same way an empty primary secret is. A
fallback is a verification key, so an empty one does not merely sign weakly:
it accepts anything signed with the key derived from b"", which anyone can
compute. Watch for the shape a rotation list grows from an environment:
# Raises: the second entry installs a key anyone can derive.
SessionMiddleware(secret_key=[os.environ["SECRET_KEY"],
os.environ.get("OLD_SECRET_KEY", "")])
Changed in version 0.20.0
An empty fallback secret was previously accepted, and every token signed with the key derived from it verified.
Next steps¶
- Security schemes — extract Bearer tokens and API keys from incoming requests.
- Passwords — hash and verify passwords for the login endpoint that issues your signed tokens.
- Sessions — Veloce's session support builds on the same signing primitives.
- The API reference has full signatures for
Signerand the exception hierarchy.