Skip to main content

Hostname-Pinned RPC Trust Anchors

When a Lit Action reads chain state through a caller-supplied RPC URL, do not trust a caller-supplied chainId 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:
  1. Hardcode the expected RPC hostnames in the action source.
  2. Require https:// so TLS binds the request to that hostname.
  3. Reject redirects, so a whitelisted host cannot bounce the action to attacker-controlled JSON-RPC.
  4. For multi-chain actions, map each supported chainId to its allowed host and any chain-specific safety policy, such as minimum confirmations.
Because the whitelist is part of the action code, changing providers or adding chains changes the IPFS CID and therefore the action-derived signer address. That is a feature: contracts that verify action-identity signatures are explicitly trusting this exact code and these exact external data sources.
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.
Hardcode the source list, minimum source count, and spread/agreement policy in the action. If a caller can lower MIN_SOURCES, widen a spread cap, or choose arbitrary sources via js_params, the policy is no longer the trust anchor.
For continuous values, sort the successful observations, take the median, and refuse to sign if (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:
This produces a serialized conditions array that is passed to encrypt and decrypt calls, evaluated by the Lit node network at runtime. In Chipotle, you skip the builder entirely. Your Lit Action is JavaScript — so you write the gate as code. The result is simpler, easier to read, and far more flexible: you can call external APIs, read any chain, verify signatures, or apply any conditional logic that JavaScript supports. The equivalent of the ETH-balance check above, written directly in a Lit Action:
The same pattern extends to any condition you can express in JavaScript:
Because gating is just code, you can combine conditions arbitrarily — NFT ownership AND minimum ETH balance AND a timestamp check — without learning a builder API or worrying about condition serialization.

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

To verify the output, any caller can:
  1. 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.
  2. Call ethers.utils.verifyMessage(payload, signature) and compare the recovered address to the known action address.
If the addresses match, the caller has a cryptographic proof that this specific, immutable action code produced this specific output — without trusting any intermediary.
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:
  1. API key scoping (primary). Configure which API keys have execute permission on which groups through the Dashboard. This is enforced at the API gateway before the action runs, so unauthorized callers never reach your code.
  2. 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_params are 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.
  1. 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 message should include a nonce or timestamp so that a previously captured signature cannot be replayed:
Note that someone could always copy your publicly available Lit Action source code, strip out the gating logic, and deploy it as a new action. This is completely safe from the original creator’s perspective — the modified action produces a different IPFS CID, which means a different key pair and a different identity. It cannot produce signatures that appear to come from your original action.

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 from getLitActionPrivateKey() 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:
Because 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.
Encrypt each item with its vault’s PKP:
Store the returned 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 calling Lit.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.
You can substitute any condition for the token check — an on-chain balance, NFT ownership, a subscription status API, a time window, or a combination:

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:
  1. The API key is never visible to the caller or in the transaction parameters — it only exists in plaintext inside the TEE during execution.
  2. 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.
Users can hold the 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. Every getPrivateKey, 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:
PKPRoleWho it belongs to
App-secrets PKPHolds 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 PKPSigns on behalf of one user. Its ID is passed via js_params.The user. One discrete wallet per user.
Keeping them separate means a user’s signing wallet never has any relationship to your API key, and rotating or revoking one user’s PKP has no effect on the shared secret or on any other 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_PKP is 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_params are untrusted, but the gateway only lets a caller use PKPs that belong to their account and are permitted in the group — so passing userPkpId cannot 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_PKP and every user PKP have to be members of the same group as the action’s IPFS CID, or the Decrypt / getPrivateKey call is rejected before it runs.

Grouping the PKPs

Add each user’s signing PKP to the group as you onboard them; the single 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.