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.

A “secret” in a Lit Action is any string you want the action to use at runtime but never want exposed to callers, observers, or the public IPFS file that holds your action source. Typical examples: third-party API keys (OpenAI, Alchemy, Stripe), database connection strings, signing keys for external services, OAuth client secrets. Chipotle has no separate secrets store. Secrets are handled with the same primitive as any encrypted data: a PKP wallet acts as a vault, and any Lit Action permitted to use that PKP can call Decrypt to recover the plaintext. Security comes from controlling which actions are permitted to use the vault PKP — the immutable IPFS CID lets you (and anyone else) audit that the permitted code doesn’t return or log the plaintext.

The model

ConceptWhat it is
Vault PKPA PKP wallet dedicated to encrypting a set of related secrets. Only actions you’ve permitted to use this PKP can decrypt its ciphertexts.
CiphertextThe output of Lit.Actions.Encrypt({ pkpId, message }). Safe to store anywhere — IPFS, a database, on-chain, baked into the action source.
Decrypt actionA Lit Action permitted to use the vault PKP. It calls Lit.Actions.Decrypt to recover plaintext, uses it, and returns only the result.
The trust anchor is the on-chain configuration that says “this IPFS CID may use this PKP.” Anyone who can’t get the gateway to run that exact code against that exact PKP cannot decrypt the ciphertext, no matter what else they hold. A permitted action can exfiltrate the plaintext if its code chooses to — so what matters is that you only permit code you’ve audited.

Lifecycle: encrypt once, decrypt at runtime

1. Mint a vault PKP

Create a PKP for your secrets through the Dashboard or via createWallet. Give it a name that describes the data boundary it protects — e.g. app-secrets, user-alice-vault, oracle-api-keys.
One PKP per logical data boundary is the recommended pattern. See PKP Wallets as Data Vaults for the rationale.

2. Permit your actions to use the PKP via a group

Permissioning happens through groups: a group binds a set of PKPs, a set of permitted IPFS action CIDs, and the usage API keys that can run them together. An action can only call Encrypt or Decrypt against a PKP when both the action’s IPFS CID and the PKP are in the same group. You need to add to the group:
  • The vault PKP from step 1.
  • The IPFS CID of the encrypt action you’ll run in step 3.
  • The IPFS CID of every production action that will decrypt the secret at runtime (step 4).
  • The usage API key that will trigger these actions.
Manage groups through the Dashboard or directly via the AccountConfig contract. See the Groups guide for the full model.
If you publish a new version of your decrypt action, its IPFS CID changes — you’ll need to add the new CID to the group (and remove the old one if you want to retire it).

3. Encrypt the secret once

Run an action that returns the ciphertext. You only run this when the secret changes.
// js_params: { pkpId, secret }
async function main({ pkpId, secret }) {
  const ciphertext = await Lit.Actions.Encrypt({ pkpId, message: secret });
  return { ciphertext };
}
The returned ciphertext is opaque without the vault PKP. Store it wherever fits your app:
  • Bake it into your production action source code (ciphertext becomes part of the immutable IPFS CID)
  • Pass it via js_params from your backend
  • Store it in a database or on-chain registry alongside metadata

4. Decrypt at runtime in your production action

Your production action receives the ciphertext, decrypts, uses the plaintext, and returns only the result — never the secret itself.
// js_params: { pkpId, encryptedApiKey, city }
async function main({ pkpId, encryptedApiKey, city }) {
  const apiKey = await Lit.Actions.Decrypt({
    pkpId,
    ciphertext: encryptedApiKey,
  });

  const res = await fetch(
    `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}`
  );
  const data = await res.json();

  // Return only what the caller is allowed to see — not the API key.
  return { temp: data?.main?.temp };
}
The plaintext exists in memory only during the call, and only inside the action code you wrote and permitted. It’s on your action not to return, log, or otherwise leak it.

Where to put the ciphertext

The ciphertext is safe in the open. Pick the storage option that matches how the secret is consumed:
StorageWhen to use
Hardcoded in the action sourceThe secret rarely changes and you want it pinned to a specific action CID. Rotating the secret mints a new CID.
js_params passed by your backendMultiple secrets, or secrets that change without redeploying the action. Your backend stores the ciphertext and supplies it per call.
On-chain registry contractYou want anyone (or a permissioned set) to be able to fetch the current ciphertext.
IPFS / databaseBulk storage of many ciphertexts (e.g. one per user), addressable by some key.
In all cases, callers can hold and pass the ciphertext freely — the gating point is whether they can get your action to run against the vault PKP.

Multiple secrets

You have two natural shapes. Pick the one that matches how the secrets are used together, not how they’re stored. One secret per ciphertext. Encrypt each secret as a separate call. Pass only the ones you need.
// js_params: { pkpId, encryptedOpenAiKey, encryptedAnthropicKey, prompt }
async function main({ pkpId, encryptedOpenAiKey, encryptedAnthropicKey, prompt }) {
  const [openaiKey, anthropicKey] = await Promise.all([
    Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedOpenAiKey }),
    Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedAnthropicKey }),
  ]);
  // ... use both keys
}
Bundled JSON in one ciphertext. Useful when secrets are always used together (e.g. an OAuth client_id + client_secret pair, or an API key + endpoint URL).
// Encrypt once: JSON.stringify({ clientId, clientSecret }) → ciphertext
// Decrypt at runtime:
const bundle = JSON.parse(
  await Lit.Actions.Decrypt({ pkpId, ciphertext: encryptedBundle })
);
const { clientId, clientSecret } = bundle;
The bundled form means one decrypt call instead of N, at the cost of having to re-encrypt the whole bundle to rotate any single field.

Rotating a secret

A ciphertext is bound to the vault PKP, not to the secret value. Rotating means re-encrypting the new secret against the same PKP and replacing the old ciphertext wherever it’s stored.
1. Run the encrypt action with the new secret value.
2. Replace the stored ciphertext (in your DB, registry, or action source).
3. Old ciphertexts continue to decrypt to the old value — invalidate them
   on the upstream system (revoke the old API key, etc.).
You do not need to rotate the PKP unless you suspect the on-chain permission set has been compromised. If the ciphertext is hardcoded in the action source, rotation mints a new IPFS CID. Update any contracts or callers that pin to the old CID.

Securing an RPC URL with an embedded API key

A common case worth calling out: many RPC providers require the API key in the URL itself (e.g. https://mainnet.infura.io/v3/YOUR_KEY). Passing the full URL through js_params would leak the key. Instead, hardcode the base URL in the action (so observers can verify the target chain) and decrypt just the key at runtime. See Securing RPC URLs for the full pattern.

Common mistakes

  • Returning the plaintext secret in the action response. Whatever you return from main reaches the caller. Decrypt, use, and return only the result of using the secret.
  • Logging the plaintext via console.log. Lit Action logs are visible to the caller.
  • Passing the unencrypted secret via js_params. js_params are caller-supplied and visible in the request — exactly the wrong place for a secret. Only ciphertexts and identifiers belong there.
  • Sharing one vault PKP across unrelated apps. If an attacker convinces the on-chain config to permit a malicious action against the vault PKP, every secret in that vault is exposed. One PKP per concern keeps blast radius small.
  • Forgetting that ciphertext baked into action source is immutable. Once the action is published to IPFS, you can’t edit the embedded ciphertext — rotate by publishing a new action.

What’s enforced where

GuaranteeEnforced by
Only permitted IPFS CIDs can use the vault PKPOn-chain AccountConfig contract via group membership
The action code that runs is exactly the IPFS CID requestedIPFS content addressing + node verification
Who can call the actionYour usage API key’s group access + your action’s own gating logic
The plaintext is never exposed by a permitted actionYou — by auditing the action source against its IPFS CID before adding it to the group
The first three are network-level guarantees. The fourth — auditing what the permitted code actually does with the plaintext, and who is allowed to trigger a decrypt — is your responsibility. See Gating Logic for patterns.

See also