TellWorldAI World Archive
Ask中文

Home›Practice›Practice

Practice

Signed receipts for auditable AI agents

Sign each agent tool call into a receipt with Ed25519 and chain receipts with hashes, so anyone holding the public key can verify them offline. Learn what receipts prove and what they do not.

Who this is for and what you can do after reading

This guide is for developers who let agents handle consequential actions such as bookings, refunds or transfers, and who must later prove to an auditor what the agent did. After reading, you should be able to produce a signed receipt for a tool call in Python, verify it offline with the issuer's public key, and link several receipts into a hash chain. You should also be clear about what receipts do not prove.[1]

Why agent logs are not enough

Imagine an agent for a travel company that looks up flights and books seats for customers, handling a large volume of bookings every day. An auditor arrives and asks to see what the agent did, then asks how you know the logs were never edited.[1]

The usual approaches each have a gap:[1]

  • Application logs are written by the agent itself and can be edited by anyone with file-system access.
  • Cloud logging services are tamper-evident at the platform level, but only if the auditor trusts the platform operator.
  • Database transaction logs suit database changes but do not cover arbitrary tool calls.

Inside an organization, that trust may be acceptable. For regulated workloads such as finance, healthcare, or anything subject to the EU AI Act, it is not.[1]

What a cryptographic receipt is

A receipt is a JSON object that records what an agent did, signed with a digital signature. The minimal example below records the tool called, hashes of its arguments and result, a policy identifier, a timestamp, a sequence number, the hash of the previous receipt, and a signature:[1]

{
  "type": "agent.tool_call.v1",
  "agent_id": "contoso-travel-bot",
  "tool_name": "lookup_flights",
  "tool_args_hash": "sha256:a3f9c1...",
  "result_hash": "sha256:7b2e1d...",
  "policy_id": "contoso-travel-policy-v3",
  "timestamp": "2026-04-25T14:30:00Z",
  "sequence": 47,
  "previous_receipt_hash": "sha256:9d4e6a...",
  "signature": {
    "alg": "EdDSA",
    "sig": "c5af83...",
    "public_key": "8f3b2c..."
  }
}

Three properties do the work:[1]

  1. The signature. The gateway signs the receipt with an Ed25519 private key. Anyone with the matching public key can verify it offline. A relying party must pin that key or obtain it from a trusted registry outside the receipt. Changing any signed field invalidates the signature.
  2. Canonical encoding. Before signing, the receipt is serialized with the JSON Canonicalization Scheme (JCS, RFC 8785), so two implementations producing the same logical receipt produce byte-identical output. Without canonicalization, different JSON serializers would produce different signatures for the same content.[2]
  3. Hash chaining. The previous_receipt_hash field links each receipt to the one before it. Removing or reordering a receipt breaks the presented sequence.

Together they give three guarantees: attribution to a key, meaning this key signed this content; integrity, meaning the content has not changed since signing; and ordering within the verified sequence, meaning this receipt links to that predecessor. To detect truncation, or a rewrite made by the signer, you also need a pinned current chain head, an independently witnessed checkpoint, or an authenticated closed-population commitment.[1]

Producing a receipt in Python

The example code is in three parts. The first imports the libraries and generates or loads the signing key. The code comments note that in production the key belongs in a key vault.[1]

import json
import hashlib
import base64
from nacl import signing
from jcs import canonicalize  # RFC 8785 canonical JSON

def b64url_nopad(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")

def sha256_canonical(obj) -> str:
    """SHA-256 of a Python object's JCS-canonical JSON form."""
    return f"sha256:{hashlib.sha256(canonicalize(obj)).hexdigest()}"

# Generate or load a signing key (in production, store in a key vault)
signing_key = signing.SigningKey.generate()
verify_key = signing_key.verify_key
TRUSTED_PUBLIC_KEYS = {b64url_nopad(bytes(verify_key))}  # pinned out of band by the verifier

The second part builds the payload, which is not signed yet. The receipt stores hashes of the arguments and the result rather than the raw content.[1]

# Build the receipt payload (no signature yet)
tool_args = {"origin": "SYD", "destination": "LAX"}
tool_result = [{"flight": "QF11", "price": 1850, "stops": 0}]

payload = {
    "type": "agent.tool_call.v1",
    "agent_id": "contoso-travel-bot",
    "tool_name": "lookup_flights",
    "tool_args_hash": sha256_canonical(tool_args),
    "result_hash": sha256_canonical(tool_result),
    "policy_id": "contoso-travel-policy-v3",
    "timestamp": "2026-04-25T14:30:00Z",
    "sequence": 0,
    "previous_receipt_hash": None,
}

The third part canonicalizes the payload and signs the JCS bytes directly. Ed25519 hashes internally, and the signature is attached as an object with alg, sig and public_key.[1][3]

# Canonicalize and sign the JCS bytes directly. PureEdDSA hashes internally.
canonical_bytes = canonicalize(payload)
signature_bytes = signing_key.sign(canonical_bytes).signature

# Attach a structured signature object.
receipt = {
    **payload,
    "signature": {
        "alg": "EdDSA",
        "sig": b64url_nopad(signature_bytes),
        "public_key": b64url_nopad(bytes(verify_key)),
    },
}

Verifying a receipt and catching tampering

Verification is the inverse. The function receives the receipt and the issuer's public key, and returns True if the signature is valid and False otherwise, including for malformed input. It needs no network call and no verification service, only the issuer's key.[1]

The verification code also has three parts. The first is a strict base64url decoder that accepts exactly one unpadded form.[1]

import base64
import hashlib
from nacl import signing
from nacl.exceptions import CryptoError
from jcs import canonicalize

def b64url_decode(s: str) -> bytes:
    # Strict: Python's decoder skips characters outside the alphabet and also
    # accepts padding, the standard +/ alphabet and non-zero spare bits.
    # Re-encoding and comparing accepts only the one unpadded base64url form.
    padding = "=" * ((4 - len(s) % 4) % 4)
    data = base64.urlsafe_b64decode(s + padding)
    if base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") != s:
        raise ValueError("not canonical unpadded base64url")
    return data

The second part opens verify_receipt. It checks the input first and returns False on malformed data instead of raising.[1]

def verify_receipt(receipt: dict, trusted_public_key: str) -> bool:
    # trusted_public_key is the issuer's key, obtained from somewhere other
    # than this receipt. Never use receipt["signature"]["public_key"] here:
    # see "What Receipts Prove (and What They Do Not)" below.
    # Fails closed: anything malformed returns False instead of raising.
    if not isinstance(receipt, dict) or not isinstance(trusted_public_key, str):
        return False
    sig_obj = receipt.get("signature")
    if not isinstance(sig_obj, dict) or sig_obj.get("alg") != "EdDSA":
        return False
    sig_b64 = sig_obj.get("sig")
    if not isinstance(sig_b64, str):
        return False

The third part rebuilds the payload that was actually signed, which is every field except signature, canonicalizes it and checks the signature.[1]

    try:
        # Reconstruct the payload that was actually signed (everything except signature).
        payload = {k: v for k, v in receipt.items() if k != "signature"}
        canonical_bytes = canonicalize(payload)
        verify_key = signing.VerifyKey(b64url_decode(trusted_public_key))
        verify_key.verify(canonical_bytes, b64url_decode(sig_b64))
        return True
    except (CryptoError, TypeError, ValueError, KeyError, OverflowError):
        return False
    except Exception:
        # An undecodable key or signature, or a payload that cannot be
        # canonicalized: fail closed rather than raise.
        return False

The key point is where the public key comes from. A verifier must never use the public_key stored inside the receipt. An attacker can alter a receipt, re-sign it with a key they generated, and embed that key. The result is internally consistent and passes against itself. Only the issuer's real key, obtained independently, exposes it.[1]

You can demonstrate this yourself. First produce a valid receipt and confirm it verifies. Then change one byte of tool_args_hash and verify again, which should fail. Finally, re-sign a tampered receipt with a key you generated and embed that key. It passes against its own embedded key and fails against the issuer's real public key.[1]

Chaining receipts for multi-step agents

A single signature protects one action; a hash chain protects a sequence, because each receipt records the hash of the one before it. Suppose there are three receipts and an attacker wants to remove the second one silently. They would have to either change the third receipt's previous_receipt_hash, which breaks that receipt's signature, or forge a new signature on the modified third receipt, which requires the agent's private key.[1]

If the private key sits in a hardware key vault and verifiers check every receipt against the issuer's independently obtained public key, neither route goes unnoticed. Publishing the key inside each receipt is not enough, because an attacker can re-sign with their own key and embed that one instead.[1]

What receipts prove, and what they do not

This is the most important boundary in the approach. Receipts prove three things: attribution, meaning a specific key signed a specific payload; integrity, meaning the payload has not changed since signing; and ordering within a presented, verified sequence.[1]

Receipts do not prove:[1]

  • That the action was correct. A receipt can be signed for a wrong answer just as cleanly as for a right one.
  • That a policy was evaluated, or that it would have permitted the action.
  • Which person authorized the action behind the key.
  • That the inputs were true. If an agent acts on a manipulated prompt, the receipt faithfully records the action.
  • That the action executed or had a real-world effect.
  • That the record set is complete. A missing receipt can mean it was blocked, omitted, lost or never instrumented.
  • That the time is trusted. A signed timestamp shows only that the signer carried that value, not that it was correct.

Having receipts does not mean being governed. Receipts are a foundation, and input validation, policy enforcement and identity infrastructure have to be built on top.[1]

High-risk actions: have an approver sign

A receipt says that a key signed some content, not that a person authorized it. For high-risk actions such as refunds, deletions and wire transfers, a separate approver signature can bind the exact action. Tying the approver's key to a real person and confirming that the person is present remain separate responsibilities.[1]

Production checklist

Before deploying receipt-signed agents in a real environment, check these points:[1]

  • Move the signing key off the developer laptop, into Azure Key Vault, AWS KMS or a hardware security module.
  • Publish verifier keys through a JWK Set or registry, and have relying parties pin the accepted issuer identity, key set and rotation policy.
  • Periodically anchor the latest chain head hash in a transparency log, such as Sigstore Rekor, an RFC 3161 timestamp authority or a second internal system, so an outside party can confirm the chain existed at a given time.
  • Store receipts immutably, for example in Azure Storage with immutability policies or in AWS S3 with Object Lock.
  • Plan retention. Each receipt is about 500 bytes, so an agent making 10,000 calls a day produces about 1.8 GB a year.
  • Document what receipts do not cover. Input validation, policy enforcement, rate limiting and identity infrastructure still need their own controls.

Limits and common questions

  • Ed25519, the signature algorithm, is widely deployed but is not quantum-resistant.[1]
  • This guide covers the ideas of signing, verification and audit, not identity management or policy engines.

Where to go next

For the signature algorithm itself, read RFC 8032.[3] For the details of canonicalization, read RFC 8785.[2]

Adapted from lesson 18 of Microsoft's open-source course *AI Agents for Beginners*[1], released under the MIT License.

Sources

  1. 1
    Securing AI Agents with Cryptographic ReceiptsMicrosoft ai-agents-for-beginners (MIT)
  2. 2
  3. 3

Cite this page: Signed receipts for auditable AI agents. TellWorld, https://www.tellworld.net/practice/practice-securing-ai-agents. Updated 2026-11-14

Related news

View all