Skip to content

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 than max_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.

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 Signer and the exception hierarchy.