Hostname-Pinned RPC Trust Anchors
When a Lit Action reads chain state through a caller-supplied RPC URL, do not trust a caller-suppliedchainId by itself. A malicious caller can point the action at a fake RPC and make that RPC consistently report whatever chain id, receipt, log, or contract state helps the attack.
Use a source-level policy instead:
- Hardcode the expected RPC hostnames in the action source.
- Require
https://so TLS binds the request to that hostname. - Reject redirects, so a whitelisted host cannot bounce the action to attacker-controlled JSON-RPC.
- For multi-chain actions, map each supported
chainIdto its allowed host and any chain-specific safety policy, such as minimum confirmations.
Helpers like
assertAllowedRpc and rpc are exactly the kind of logic worth sharing across actions. Instead of copy-pasting them into every action, publish them as a version-pinned npm package and import them — see Composability — Publish Your Own Packages.Multi-Source Consensus
When a Lit Action signs data from outside the chain it is serving, decide how independent sources should agree before any signature is produced. The right aggregation depends on the data shape:- Discrete facts such as
isSanctioned(address) -> bool, event presence, or yes/no market outcomes should usually require strict agreement across independent sources. - Continuous values such as market prices should usually use a median or trimmed mean plus a maximum-spread check, because honest sources will naturally differ by small amounts.
- Fallback availability should fail closed: if too few sources respond, return an unsigned denial instead of signing from a single source.
MIN_SOURCES, widen a spread cap, or choose arbitrary sources via js_params, the policy is no longer the trust anchor.
(max - min) / median exceeds a hardcoded spread cap.
Gating Logic — AKA Access Control Conditions
The older Datil / Naga Lit SDK offered a fluent builder for declaring access control conditions (ACCs): structured objects that describe who may decrypt ciphertext or call an action. A typical condition using the SDK looks like this:Action-Identity Signing — Immutable Proofs
Every Lit Action has a cryptographic identity derived from its IPFS CID.Lit.Actions.getLitActionPrivateKey() retrieves a private key that is deterministically derived from the content hash of the action code. There is no way to produce that key outside of that exact code running inside the Lit network.
This means any signature produced with this key carries a guarantee: the data was produced by that specific, immutable action. If the action code changes by a single byte, it gets a new IPFS CID, a new key, and a new identity. There is no way to forge the signature without controlling both the Lit network and the exact source code.
This is useful any time you want to produce a verifiable proof — a signed output that a smart contract, API, or third party can verify came from a specific computation, not an arbitrary off-chain script.
Example: Signing a Price Feed
- Retrieve the action’s public key or wallet address via
Lit.Actions.getLitActionPublicKey({ ipfsId })(callable from inside another action) or by fetching it once and caching it. - Call
ethers.utils.verifyMessage(payload, signature)and compare the recovered address to the known action address.
Use
getLitActionPrivateKey when the proof must be tied to the action code itself. Use getPrivateKey({ pkpId }) when the proof must be tied to a specific PKP wallet (e.g. an account or a user’s identity). Both patterns produce verifiable signatures; they differ in what identity the signature is bound to.What if Someone Else Runs My Action?
A common concern with Action-Identity Signing is: what happens if someone copies my Lit Action and runs it themselves? In many cases, it doesn’t matter. Because the action’s identity is tied to its IPFS CID, anyone running the same action is executing the exact same immutable code. If your action only does a single thing — like signing a price feed — then someone else running it is simply paying for the execution on your behalf. The output carries the same cryptographic guarantee regardless of who triggered it. This applies to pure, side-effect-free actions. If your action writes to an external database, calls a third-party API, or triggers transactions, unauthorized execution could cause duplicate writes or exhaust rate limits. If you need to restrict who can execute the action, there are three layers of control:-
API key scoping (primary). Configure which API keys have
executepermission on which groups through the Dashboard. This is enforced at the API gateway before the action runs, so unauthorized callers never reach your code. -
PKP address check. If the group is configured so that only a specific PKP can be used with the action, you can check the PKP address as a parameter inside the action. Although
js_paramsare caller-supplied, the ownership model makes this reliable: the caller can only use PKPs that belong to their account and are permitted within the group. An attacker cannot pass an arbitrary PKP address because the gateway already verified that the PKP is owned by the caller’s account.
- Signature verification (defense-in-depth). For additional assurance beyond the ownership model, require the caller to sign a challenge and verify the signature cryptographically inside the action. The
messageshould include a nonce or timestamp so that a previously captured signature cannot be replayed:
A Wallet Bound to the Action — and a Unique One Per User
The flip side of “edit a byte, get a new key” is that you can use the action-derived key as a wallet that can only ever be operated by this exact code. The key fromgetLitActionPrivateKey() is deterministically derived from the CID and never leaves the TEE, so the action is the only thing that can sign for that address. This is a lightweight alternative to converting an account to ChainSecured and using a contract to mint and bind PKPs to a group — you get the same “only this code can sign” property without minting a PKP.
To give each user their own such wallet, make the code differ per user by hardcoding the user’s address into the action:
OWNER_ADDRESS is part of the hashed source, two users produce two CIDs, two keys, and two wallet addresses — there is no code path from one user’s action to another’s wallet. Authorize spending by recovering a signature and comparing it to the hardcoded owner (include a nonce/deadline in the signed message for replay protection, as in the caller-signature check above). The Lit usage key that runs the action grants no spending power — it can only read the address or relay a withdrawal the real owner already signed.
A full runnable version — deposit an ERC-20 into the wallet, withdraw by signing, and a wrong-user attack the action refuses — is in examples/action-bound-wallet/ (walkthrough in Examples §13).
Encrypt / Decrypt — PKP Wallets as Data Vaults
For the full walkthrough on handling secrets — minting a vault PKP, permissioning actions via groups, encrypt/decrypt lifecycle, storage, and rotation — see the Secrets guide. This section focuses on the design pattern of using a PKP as a vault and gating decrypt access for dApp users.
One PKP per logical data boundary
The best practice for encrypting a set of related data is to create a dedicated PKP wallet for that data boundary. The PKP’s derived symmetric key is then used to encrypt everything in that boundary — user records, API keys, configuration, documents, whatever belongs together.ciphertext anywhere — IPFS, a database, a smart contract — without risk. Without the PKP’s derived key (which never leaves the TEE), the ciphertext is opaque.
Giving dApp users access through gating conditions
To let a dApp user read encrypted data, you give them access to a decrypt action that enforces your gating conditions before callingLit.Actions.Decrypt. The user calls the action with a reference to the vault PKP; if their condition is met, they receive the plaintext. If not, they receive an error.
The symmetric key is never shared. It is derived inside the TEE, used to decrypt, and discarded. The user only ever sees the plaintext result — and only if the action’s gating logic allows it.
Securing RPC URLs — Hiding API Keys with Encryption
Many RPC providers require an API key appended to the endpoint URL (e.g.https://mainnet.infura.io/v3/YOUR_API_KEY). Passing the full URL as a js_params value would expose the key to anyone who can inspect the action call. Instead, you can encrypt the secret portion of the URL and let the Lit Action decrypt and reassemble it at runtime inside the TEE.
This pattern provides two guarantees:
- The API key is never visible to the caller or in the transaction parameters — it only exists in plaintext inside the TEE during execution.
- The action is verifiably calling a specific chain — the base URL is passed in the clear (or hardcoded), so observers can confirm which network the action targets, while the confidential key portion stays hidden.
Setup: Encrypt the API key
Use a dedicated “secrets” PKP to encrypt the API key once. Store the resulting ciphertext alongside the action or in your configuration.Usage: Decrypt and connect at runtime
The production action receives the encrypted API key, decrypts it inside the TEE, and assembles the full RPC URL.You should hard-code the base URL directly in the action code rather than passing it as a parameter. Because the action is pinned to IPFS, hardcoding makes the target chain immutable — verifiable by anyone who inspects the action’s CID.
Why this is safe to expose
Sharing a PKP ID (the wallet address) with users is safe because:- The private key is only accessible inside a Lit Action running in a TEE — it is never returned to callers and never leaves the node.
- The symmetric key used for encryption is derived from the private key inside the TEE. It is also never returned.
- The ciphertext is meaningless without the derived key.
- The only way to get plaintext is to run an action that holds the right PKP and chooses to call
Decrypt— so your gating logic is the sole enforcement point.
pkpId and the ciphertext indefinitely. They gain access to the plaintext only if and when the gating logic inside the action is satisfied.
Multiple PKPs in a Single Action — Separating App Secrets from User Signing
Nothing limits a Lit Action to one PKP. EverygetPrivateKey, Encrypt, and Decrypt call names a pkpId, so a single execution can use several PKPs for different purposes — as long as each PKP and the action’s IPFS CID are in the same group. This unlocks a pattern that is often warranted in production: use one PKP as an app-owned vault that guards shared secrets like third-party API keys, and give each end user their own discrete PKP for signing.
The two roles have opposite sharing models, which is exactly why they belong in separate PKPs:
| PKP | Role | Who it belongs to |
|---|---|---|
| App-secrets PKP | Holds the ciphertext of an API key (or other credential) the app must use on every call. Its ID is hardcoded in the action. | The app. One vault, shared across all users. |
| User PKP | Signs on behalf of one user. Its ID is passed via js_params. | The user. One discrete wallet per user. |
The pattern
The action below does both jobs in one execution: it decrypts a shared API key from the app-secrets PKP, calls an external service with it, then signs the result with the caller’s own PKP.Why this is safe
- The app secret is hardcoded, not caller-supplied.
APP_SECRETS_PKPis baked into the action source, so it is part of the immutable IPFS CID. A caller cannot point the decrypt at a different vault, and because the ciphertext only decrypts against that PKP inside the TEE, the plaintext key never reaches the caller. See Securing RPC URLs and the Secrets guide for the vault lifecycle. - The user PKP is caller-supplied, but ownership-bound.
js_paramsare untrusted, but the gateway only lets a caller use PKPs that belong to their account and are permitted in the group — so passinguserPkpIdcannot reach another user’s wallet or the app-secrets vault. If you want defense-in-depth, gate the user’s request with a signature check, as in Gating Logic. - All referenced PKPs must be in the group. Both
APP_SECRETS_PKPand every user PKP have to be members of the same group as the action’s IPFS CID, or theDecrypt/getPrivateKeycall is rejected before it runs.
Grouping the PKPs
app-secrets PKP stays constant. Because the action code references the app vault by a hardcoded address and the user vault by parameter, you never republish the action to add a user — you only update group membership. See the Groups guide for how to manage membership.
This is the multi-PKP generalization of two single-PKP patterns already covered here: PKP Wallets as Data Vaults (the app-secrets side) and A Wallet Bound to the Action — and a Unique One Per User (the per-user side). Reach for it whenever one execution needs both a shared, app-controlled credential and a per-user signing identity.