Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developer.litprotocol.com/llms.txt

Use this file to discover all available pages before exploring further.

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:
import { createAccBuilder } from '@lit-protocol/access-control-conditions';

const conditions = createAccBuilder()
  .on('ethereum')
  .requireEthBalance('1000000000000000000')  // 1 ETH in wei
  .and()
  .on('ethereum')
  .requireNftOwnership('0xContractAddress')
  .build();
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:
// js_params: { pkpId, minBalanceWei, message }
// pkpId doubles as the wallet address to check — it is an Ethereum address.
async function main({ pkpId, minBalanceWei, message }) {
  const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org");
  const balance = await provider.getBalance(pkpId);

  if (balance.lt(ethers.BigNumber.from(minBalanceWei))) {
    return {
      error: `Balance ${ethers.utils.formatEther(balance)} ETH is below the required minimum`,
    };
  }

  // Gate passed — sign the message with the PKP.
  const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }));
  const signature = await wallet.signMessage(message);

  return { message, signature, balanceEth: ethers.utils.formatEther(balance) };
}
The same pattern extends to any condition you can express in JavaScript:
// js_params: { pkpId, contractAddress, requiredAmount, message }
// Gate on ERC-20 token balance instead of ETH.
async function main({ pkpId, contractAddress, requiredAmount, message }) {
  const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org");
  const erc20 = new ethers.Contract(
    contractAddress,
    ["function balanceOf(address) view returns (uint256)"],
    provider
  );
  const balance = await erc20.balanceOf(pkpId);

  if (balance.lt(ethers.BigNumber.from(requiredAmount))) {
    return { error: "Insufficient token balance" };
  }

  const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }));
  const signature = await wallet.signMessage(message);
  return { message, signature };
}
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

// js_params: {}  — no caller-supplied parameters needed
async function main() {
  const res = await fetch(
    "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd"
  );
  const data = await res.json();
  const price = data?.ethereum?.usd;

  if (typeof price !== "number") {
    return { error: "Price fetch failed" };
  }

  // Sign with the action's own key — not a PKP.
  // The private key is derived from this action's IPFS CID.
  const actionWallet = new ethers.Wallet(
    await Lit.Actions.getLitActionPrivateKey()
  );

  const payload = `ETH/USD: ${price} at ${Date.now()}`;
  const signature = await actionWallet.signMessage(payload);

  return {
    price,
    payload,
    signature,
    signerAddress: actionWallet.address,
  };
}
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.
async function main({ pkpAddress }) {
  const ALLOWED_PKP = "0xAbc123..."; // your PKP's wallet address

  if (pkpAddress.toLowerCase() !== ALLOWED_PKP.toLowerCase()) {
    throw new Error("Unauthorized: PKP not allowed to run this action");
  }

  // ... rest of your action logic
}
  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:
async function main({ signature, message }) {
  const ALLOWED_ADDRESS = "0xAbc123..."; // the authorized caller's wallet address

  // message should contain a nonce or timestamp for replay protection
  const recovered = ethers.utils.verifyMessage(message, signature);
  if (recovered.toLowerCase() !== ALLOWED_ADDRESS.toLowerCase()) {
    throw new Error("Unauthorized: caller signature does not match allowed address");
  }

  // ... rest of your action logic
}
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.

Encrypt / Decrypt — PKP Wallets as Data Vaults

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.
Account
 └── Group
      ├── PKP: "user-alice-data-vault"    ← one vault per user
      ├── PKP: "user-bob-data-vault"
      └── PKP: "app-secrets"              ← one vault per concern
Encrypt each item with its vault’s PKP:
// js_params: { pkpId, plaintext }
// Run once to seal a piece of data into the vault.
async function main({ pkpId, plaintext }) {
  const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: plaintext });
  return { ciphertext };
}
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.
// js_params: { pkpId, ciphertext, userToken }
// userToken is a signed JWT or session token the caller supplies to prove identity.
async function main({ pkpId, ciphertext, userToken }) {
  // Verify the caller's token against your auth service.
  const authRes = await fetch("https://auth.your-app.com/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ token: userToken }),
  });
  const auth = await authRes.json();

  if (!auth.valid) {
    return { error: "Unauthorized" };
  }

  // Gate passed — decrypt and return the plaintext.
  const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext });
  return { plaintext };
}
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:
// js_params: { pkpId, ciphertext, holderAddress, nftContractAddress }
// Only NFT holders may decrypt.
async function main({ pkpId, ciphertext, holderAddress, nftContractAddress }) {
  const provider = new ethers.providers.JsonRpcProvider("https://mainnet.base.org");
  const nft = new ethers.Contract(
    nftContractAddress,
    ["function balanceOf(address) view returns (uint256)"],
    provider
  );
  const balance = await nft.balanceOf(holderAddress);

  if (balance.eq(0)) {
    return { error: "NFT not held — access denied" };
  }

  const plaintext = await Lit.Actions.Decrypt({ pkpId, ciphertext });
  return { plaintext };
}

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.
// Encrypt the API key portion once and store the ciphertext.
// js_params: { pkpId, apiKey }
async function main({ pkpId, apiKey }) {
  const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: apiKey });
  return { ciphertext };
}

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.
// js_params: { pkpId, encryptedApiKey, targetAddress }
async function main({ pkpId, encryptedApiKey, targetAddress }) {
  // Decrypt the API key inside the TEE — it never leaves the node.
  const apiKey = await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedApiKey });

  // Assemble the full RPC URL.
  const provider = new ethers.providers.JsonRpcProvider(
    `https://sepolia.infura.io/v3/${apiKey}`
  );

  // Use the provider as normal.
  const balance = await provider.getBalance(targetAddress);

  return { balance: ethers.utils.formatEther(balance) };
}
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.