How We Built Cryptographic Audit Receipts for Non-Repudiable Access Control
Every authorization decision produces a signed proof token. Here's the merkle-tree structure, the signing key rotation strategy, offline verification, and why we chose Ed25519 over RSA.
Every authorization decision in WardenAuth — allow or deny — produces a signed, cryptographic receipt. This is not a log line in a database. It is a portable proof token: a small, self-contained, signed message that anyone can verify offline without trusting us. The receipts make our audit trail non-repudiable — neither the system operator nor a user can deny that a specific decision was made at a specific time. This is how we built it.
Why a database audit log is not enough
A standard audit trail stores decisions in a database. The database operator — your infrastructure team, your cloud provider, an attacker who compromised an admin credential — can modify, delete, or fabricate entries. Even with write-once append-only semantics (which DynamoDB does not natively provide), a sufficiently privileged actor can alter the log. The fix is cryptographic signatures: each receipt is signed with a key the operator does not control, and the receipts are chained so insertion or deletion is detectable.
Receipt structure
Each receipt is a small JSON document containing the decision context and a signature:
{
"v": 1,
"ts": "2026-07-19T14:31:22.123Z",
"sub": "user_abc123",
"scope": "acme-corp",
"res": "invoice",
"act": "delete",
"dec": "deny",
"rsn": "permission invoice:delete → deny (role: restricted)",
"prev": "sha256:abc123...",
"sig": "ed25519:def456..."
}- v: schema version — receipts are forward-compatible
- ts: ISO 8601 timestamp with millisecond precision
- sub, scope, res, act: the access check parameters — who asked, in what tenant, for what
- dec, rsn: the decision (allow/deny) and reasoning — which permissions matched or why none did
- prev: SHA-256 hash of the previous receipt in the chain — creates a Merkle-tree-like integrity chain
- sig: Ed25519 signature over the entire receipt (excluding the sig field itself)
Why Ed25519 over RSA
We chose Ed25519 for three reasons. First, signing speed: Ed25519 signs a 256-bit message hash in under 100 microseconds — critical because signing happens on the hot path of every authorization decision. RSA-2048 signing is 20-50x slower. Second, signature size: Ed25519 produces 64-byte signatures; RSA-2048 produces 256 bytes. Each receipt is already ~300 bytes; keeping signatures small matters at scale. Third, security: Ed25519 has no known timing side-channel attacks, making it safe to implement in a multi-tenant environment where signing keys are segregated by scope.
The Merkle chain: preventing insertion and deletion
Each receipt contains prev — the SHA-256 hash of the previous receipt in the chain for that scope. This creates an append-only, tamper-evident chain:
Receipt 1 → prev: null
Receipt 2 → prev: sha256(Receipt 1)
Receipt 3 → prev: sha256(Receipt 2)
...To verify the chain, you recompute each hash and check that it matches the next receipt's prev. A missing receipt breaks the chain. An inserted receipt will not match its successor. A modified receipt changes its hash, breaking the next link. This is the same integrity mechanism that powers Git commits and blockchain — just applied to authorization decisions.
Key management: signing and rotation
Each scope gets its own Ed25519 signing key pair, generated at scope creation time. The private key never leaves the Lambda execution environment — it is stored in AWS Secrets Manager with automatic rotation every 90 days. The public verification key is published at a well-known endpoint for offline verification.
// Key rotation: new key signs new receipts; old key verifies old receipts.
// Receipts include a key ID so verifiers know which public key to use.
interface ReceiptHeader {
v: number // schema version
kid: string // key ID → which public key verifies this receipt
alg: 'ed25519' // algorithm identifier
}Offline verification
The entire point of cryptographic receipts is offline verification — an auditor should be able to validate the audit trail without accessing the live system. The verification flow:
- Fetch the public verification key for the scope (published at a known URL)
- For each receipt, recompute the hash chain: does
sha256(prev_receipt) === this_receipt.prev? - Verify the Ed25519 signature: does the signature validate against the public key and the receipt payload?
- If both checks pass, the receipt is authentic and the chain is intact — no trust in the operator required
What we learned
The cryptographic signing itself was straightforward — Ed25519 libraries are mature and fast. The hard part was the chain management: what happens when the hash chain for a scope grows to millions of receipts? We added periodic checkpointing — every 10,000 receipts, a checkpoint receipt is generated that summarizes the previous 10,000. Verifiers can start from any checkpoint rather than walking the entire chain from genesis.