Biscuit Tokens¶
tuvl uses Biscuit tokens for authentication and authorization. Biscuit is an open standard for capability tokens — they are cryptographically signed, carry Datalog facts (identity, roles, scopes, expiry), and cannot be forged or tampered with.
Token Structure¶
Every tuvl Biscuit token contains an authority block with four kinds of Datalog facts:
user("550e8400-e29b-41d4-a716-446655440000"); // user UUID
group("hr_manager"); // role name(s)
scope("requisition:write"); // permission scope(s)
scope("candidate:read"); // multiple scopes allowed
exp(1747180800); // UNIX expiry timestamp (optional)
The token is signed with the server's Ed25519 private key and encoded as URL-safe Base64. It is sent in every API request as an HTTP Bearer token:
Token Lifecycle¶
stateDiagram-v2
[*] --> Minted: POST /auth/token\nor OAuth callback
Minted --> Active: Valid, not expired, not blacklisted
Active --> Expired: exp() fact timestamp passed
Active --> Revoked: POST /auth/logout\nor POST /auth/refresh (old token)
Active --> Refreshed: POST /auth/refresh (new token issued)
Expired --> [*]
Revoked --> [*]
Token TTL¶
Tokens are minted with an embedded exp({unix_ts}) fact. The default TTL is 86400 seconds
(24 hours). You can change it per deployment:
Setting TUVL_TOKEN_TTL_SECONDS=0 omits the expiry fact entirely. This is not recommended
for production — tokens without an expiry can only be invalidated by blacklisting.
When a request arrives with an expired token, tuvl returns:
Token Refresh¶
A client may request a new token before expiry using the refresh endpoint:
The server: 1. Verifies the current token (must be valid and not expired) 2. Looks up the user in the database (must still be active) 3. Mints a new token with a fresh TTL 4. Revokes the old token by adding it to the blacklist
Token Revocation (Blacklist)¶
Explicit revocation is handled by a token blacklist. Revoked tokens are rejected
on every subsequent request, even before their exp timestamp is reached.
The blacklist stores the SHA-256 hash of each revoked token string — the raw token value is never persisted.
Storage backends¶
| Backend | When used | Scope |
|---|---|---|
| Redis | A type: redis datasource is configured |
Shared across all workers |
| In-process dict | No Redis configured (fallback) | Single worker only |
Multi-worker deployments
Without Redis, a token revoked by one worker is still accepted by all other workers because each process has its own blacklist. Configure a Redis datasource before scaling to multiple workers or processes. See Redis Configuration.
Automatic TTL¶
When a token is blacklisted, the entry is stored with the token's remaining TTL (time until
its exp fact). Once the natural expiry passes, the blacklist entry is cleaned up
automatically. For tokens without an exp fact, a 24-hour fallback TTL is used.
Signing Keys¶
tuvl tokens are signed with an Ed25519 private key. The key lifecycle is handled by the
TuvlKeyManager:
Persistent mode (recommended for production)¶
Generate a key:
python3 -c "
from biscuit_auth import PrivateKey
import secrets
key = PrivateKey.from_bytes(secrets.token_bytes(32))
print(key.to_bytes().hex())
"
Store the hex string in your .env file or secrets manager.
Ephemeral mode (development only)¶
If TUVL_BISCUIT_PRIVATE_KEY is not set:
- Production mode (
tuvl_dev_mode=false, the default): the server refuses to start with aRuntimeError. This prevents accidentally running production without a stable key. - Dev mode (
tuvl_dev_mode=true, set bytuvl dev): a fresh random key is generated in memory. All tokens are invalidated on restart.
A warning is logged in dev mode:
⚠️ Auth: No TUVL_BISCUIT_PRIVATE_KEY found — using an EPHEMERAL key.
All Biscuit tokens will be invalid after process restart.
Security Guarantees¶
| Property | How it is achieved |
|---|---|
| Integrity | Ed25519 signature — any modification invalidates the token |
| No server-side state (normal) | Facts are self-contained in the authority block |
| Expiry | exp({ts}) Datalog fact checked against time.time() on every request |
| Revocation | SHA-256 blacklist in Redis or in-process memory |
| Scope injection prevention | Scope/group values validated against ^[\w:.\-/]+$ before minting |
Token Inspection¶
You can decode a Biscuit token for debugging (the signature is not verified client-side):
Or in Python: