Lit.Actions.getLitActionPrivateKey() returns a secp256k1 private key that the TEE derives from the action’s IPFS CID. Change one byte of the source and you get a new CID and a new key.
That property is usually described as a guarantee (“only this exact code can sign”). It is also a factory. Take one audited template, append a small JSON constant, and you have a brand-new immutable action with a brand-new key that only that code can use. Repeat with a different constant and you have another. We call these derived actions, and they are the primitive underneath the Lit Agent Keychain, where every stored secret is its own derived action.
Who can use an action-derived key?
An action-derived key belongs to the action’s code identity, not to the account paying for execution. Within the same key-derivation environment, identical source produces the same key across accounts and usage keys. Changing runtimejs_params does not change the key; changing the source does.
Lit.Actions.getLitActionPrivateKey() returns the key for the currently executing action to that action’s code. It accepts no CID argument, so code cannot request another action’s private key. Public-key and address lookups can accept another CID because they return only public information.
A valid wildcard usage key (execute_in_groups: [0]) can execute an unregistered action and let it use its own derived key without creating a group or PKP. PKP wallet access is separate: an action requesting a PKP private key or PKP encryption/decryption still needs a matching action-and-wallet group in the key’s account.
Put authorization for derived-key operations inside the action. Anyone with permission to execute the source, including someone using their own account’s wildcard key, can invoke its behavior. An action that signs any caller-supplied message allows those callers to request signatures. An action that embeds an owner’s identity and verifies the owner’s signed request before signing or decrypting enforces that policy for every caller. Removing the verification changes the source, CID, and derived key. Merely embedding an owner address is not enough; the code must verify authorization and bind it to the requested operation.
The caller does not automatically receive the raw private key. However, code that returns or logs it exposes it, so never export the key or expose unrestricted signing/decryption when building a protected action.
Why you’d want this
The last row is the important one. Anything in the source is trusted; anything in
js_params is not. Moving configuration from parameters into the source turns it from caller-controlled input into part of the action’s identity.
The pattern, step by step
1. Write the template once
The template is ordinary action code that reads its configuration from a constant instead of fromjs_params. Keep every dependency inside the file so the bytes are reproducible: either bundle with esbuild into a single IIFE, or use version-pinned imports whose specifier bytes never change.
2. Derive an instance by appending a canonical constant
canonical() must be deterministic: sort object keys, reject floats and undefined, and never depend on insertion order. Two parties who compute the source independently (a browser, a backend, a verifier) must get identical bytes, or they will get different CIDs. The Keychain’s implementation is protocol/crypto.ts; it sorts ASCII property names and permits only safe integers.
3. Compute the CID locally
Chipotle identifies an action by the CIDv0 of its UTF-8 source (UnixFS, 256 KiB chunks, balanced layout). You can compute it in three ways:- JavaScript
- Rust
- REST
4. Enroll the CID in a group
Skip this step when using a wildcard execution key and no PKP wallet. For a scoped execution key, add the derived CID to a group exactly as you would any action.add_action_to_group takes the raw CID; add_group takes keccak256 hashes of the CID string.
5. Execute by sending the source
There is no upload step. Every execution submits the full derived source ascode; the server hashes it and checks execution permission. Scoped usage keys require a permitted group matching that CID; wildcard usage keys do not require a group to execute it.
cidForCode(code) and compare it with the CID whose key signed the response.
Give each caller an execute-only key
Protect derived-key operations with authorization in the action itself. To limit which actions callers can execute using your account’s credentials, hand end users a usage key that can execute and nothing else:Discovering an action’s public key without running it
Verifiers need the public key of a derived action before trusting anything it signed. Two options: Inside another action:Lit.Actions.getLitActionPublicKey({ ipfsId }) and getLitActionWalletAddress({ ipfsId }) work for any CID. They require no group permission (the key is public) and count toward the per-execution limit of ten key operations.
From outside Lit: run a tiny fixed helper action that does nothing but look up a key. This is what the Keychain SDK does; the helper is enrolled in a group every usage key can execute.
Fetch public keys from the Lit origin you trust, directly over TLS, and cache them. Never accept a “here is the action’s public key” value from an intermediary such as your own backend, because a compromised backend could substitute a key it controls. The Keychain SDK pins the Lit origin at build time and refuses replacement endpoints from API responses. For a stronger check, verify the TEE itself with remote attestation before the first request.
Deriving an encryption key from the action key
The identity key is a 32-byte secp256k1 scalar. Run it through HKDF and you get keys for other purposes that are still bound to this exact code. The Keychain derives an X25519 key for HPKE so that a secret can be encrypted to a derived action in the browser, with no PKP at all:encPub safely. The derived X25519 key is not the identity key, so getLitActionPublicKey cannot return it. Instead the action returns a binding signed by its identity key, and the client verifies the signature against the identity public key obtained through discovery above:
encPub knowing only this exact code can ever open it. The full flow, including AES-GCM payload encryption with metadata as additional authenticated data, is in the Keychain’s protocol/crypto.ts and actions/secret-common.ts.
Operational rules that fall out of the pattern
- Request the private key last. Do every independent check (signatures, windows, policy) before calling
getLitActionPrivateKey. A rejected request should never have touched key material. - Zero buffers in
finally. Call.fill(0)on key and plaintextUint8Arrays. JavaScript strings cannot be erased, so keep secrets in byte arrays where practical. - Return one generic error. The Keychain actions return
{ ok: false, error: "access_denied" }for every failure so that callers cannot probe which check tripped. - Updating a secret does not rotate the action key. Encrypting a new value to the same action leaves its CID and derived key unchanged. Changing the source creates a new identity and requires an explicit migration; it does not disable the old identity or erase old ciphertext.
- Group removal is not global retirement. Removing a CID from a group revokes that group’s grant. Other permitted groups, all-actions groups, or wildcard execution keys can still authorize execution of the same source with the same derived key. Deleting your usage key likewise does not prevent another account from executing the source. If you need revocation, design the action to verify current authorization or revocation state from an authenticated source before sensitive operations. Removing a group entry alone cannot retire a derived key.
- Version the constant. Include a protocol version in the manifest so a future template can recognize and migrate old instances.
- Keep the fetch budget in mind. Actions may make up to fifty outbound requests, and responses are capped at 1 MB. See Limits.
Where the Keychain uses this
A user’s whole vault is therefore N+1 immutable actions built from three templates, and anyone can regenerate every CID from the public manifests and confirm the exact code that guards each secret.
See also
- Action-Identity Signing — the single-action version of this idea.
- A Wallet Bound to the Action, Unique Per User — the simplest derived action: a template plus an owner address.
- Signed Data in Untrusted Storage — how derived actions authorize each other through a database you do not have to trust.
- Module Imports — keeping dependency bytes stable so CIDs stay stable.
examples/action-bound-wallet— runnable per-user wallet example.