Skip to main content
Lit Actions are stateless. Anything an action needs to remember between calls (who is allowed to do what, which version of a secret is current, whether a grant has been revoked) has to live somewhere else. The obvious home is your own database, and the obvious worry is that you now have to trust it. You don’t. Have a Lit Action sign every record before it is stored, and have every consumer verify the signature before acting on it. The signer is an action’s own CID-derived key, so a record can only exist if that exact audited code decided to issue it. Your database, API, and CDN become plumbing: they can withhold or serve stale data, but they cannot invent authority.
This is how the Lit Agent Keychain stores ciphertext, access policies, and recovery credentials in ordinary PostgreSQL with no trusted operator. This page extracts the pattern so you can use it for your own records.

The three roles

The issuer and consumers are usually derived actions built from shared templates, with the issuer’s CID stamped into every consumer’s manifest so the trust link is part of the consumer’s own identity.

Step 1: Canonical documents

Signatures cover bytes, so everyone must serialize the same object to the same bytes. Use a restricted canonical JSON:
  • Object keys sorted by ASCII code point.
  • Numbers must be safe integers. No floats, no -0.
  • No undefined, no prototype tricks, no unknown fields (validate with a strict schema before hashing).
  • Strings are exact Unicode text with no normalization.
Give every document a domain string (for example "my-app/policy/v1") and a v version field. Domain separation means a signature over a policy can never be replayed as a signature over a receipt or a request, even if the two happen to hash the same fields.

Step 2: The issuer signs a receipt, not the document

The receipt is a small payload that binds the document’s hash to a scope and a timestamp. Signing a fixed-shape payload rather than arbitrary documents keeps the issuer’s signing surface tiny and easy to audit.
Store the result as { document, receipt }. The document stays plaintext JSON your app can query and index; the receipt travels with it.
The example uses signMessage (EIP-191) because ethers is a runtime global and any EVM tool can verify it. The Keychain instead signs sha256(canonical(payload)) directly with secp256k1 via @noble/curves, which yields a compact 64-byte signature verifiable in Rust with no Ethereum dependency. Either works; pick one and pin it in the domain string.

Step 3: Consumers fetch and verify at runtime

A consumer action never trusts what it fetched until the receipt verifies under the issuer’s public key, which it looks up by the issuer’s CID. Because the issuer CID is stamped into the consumer’s manifest, a consumer can only ever be satisfied by receipts from the one issuer it was built to trust.
Three things to notice:
  • Every claim in the request is checked against the signed document. The requester says “policy hash X, envelope hash Y, my key Z, operation W”. The action confirms the signed policy has that hash, lists that key, permits that operation, and covers that envelope. Storage cannot substitute a different policy without the hash check failing.
  • Fetch fails closed. Timeouts, redirects, oversized bodies, non-2xx statuses, and schema failures all deny. The Keychain caps registry responses at 256 KB. Remember an action gets fifty outbound requests and ten key operations per execution.
  • The key is requested last and the expiry is re-checked immediately before use, because fetches take real time.

Step 4: Sign the request too

If the consumer is acting for an agent or user, the request itself should be a signed canonical document so the consumer can verify who is asking and that the ask is fresh and specific.
Inside the action: parse strictly, verify the Ed25519 signature under request.agentPublicKey, and require a short window (the Keychain allows at most 120 seconds of lifetime and rejects requests whose issue time is more than 30 seconds in the future). The nonce and window bound replay; the hashes bind the request to one exact policy and one exact ciphertext.

Step 5: Sign and encrypt the response

If the action’s output is sensitive, or if it passes through a proxy you do not control (a relayer, your own API, an MCP server), protect it on the way out:
  1. Encrypt to the requester. HPKE-seal the result to request.responsePublicKey, with the request hash in the HPKE info so a sealed result cannot be replayed to a different request.
  2. Sign the sealed result with the action key. Payload: { domain: "my-app/response/v1", requestHash: digest(request), sealed }. The client verifies against the consumer action’s public key (looked up by the CID it computed itself).
Now only the requester can read the result, and the requester can prove the result came from the exact code it intended to call. A malicious proxy can drop the response but cannot forge or alter it.

What storage can and cannot do

Be precise about the residual trust, because it shapes your design. The rollback row is the one to design around. The Keychain accepts it explicitly, and bounds it with two rules:
  • Finite windows on anything that grants access. Agent policies expire in 30 days by default and 90 at most. A rolled-back grant is therefore a bounded exposure, not a permanent one.
  • Epoch and previous-hash chaining. Each new policy carries epoch: n+1 and previousHash: digest(previous). The API enforces the chain on write so concurrent editors get a clean conflict instead of clobbering each other. This is an honesty check on the operator’s own writes, not rollback protection: a dishonest operator can still serve epoch 3 after epoch 4 exists.
If you need real rollback protection, anchor the latest document hash somewhere the operator cannot rewind. Posting digest(latestPolicy) to a contract on Base and having the consumer read it over a hostname-pinned RPC is one call and turns “operator can roll back” into “operator can only stall”.

When to use this instead of on-chain state

Most policy and ciphertext data belongs off-chain and signed. Anchor a hash on-chain only for the records where a rollback would be catastrophic.

Checklist

  • Canonical JSON with sorted keys and safe integers, shared by every party.
  • domain and v on every signed object. Different domains for receipts, requests, responses, and key bindings.
  • Receipts bind objectHash plus a scope such as vaultId.
  • Consumers look up the issuer’s key by CID and never accept a key from the fetched payload.
  • Every field the requester relies on is bound in the signed document and checked.
  • Fetches use HTTPS, reject redirects, time out, cap size, and fail closed.
  • Key material is requested after all independent checks pass and expiry is re-checked before use.
  • Grants have finite expiry. Chains use epoch and previous hash. Anchor on-chain if rollback matters.
  • Responses are encrypted to the requester and signed by the action when they cross an untrusted hop.
  • One generic error for every denial.

See also