Core Insight
API mode and ChainSecured mode use the same contracts, the same permission model, and the same execution path. Three things vary:- Who is the Account Owner (a Lit-managed credential vs a SAFE or EOA you control)
- Whether the TEE may write for you. Every account has a
managedflag in the AccountConfig contract. In API mode (managed = true), the TEE relays your management calls: it submits the transaction after your account key or a usage key with the right scope authorizes the call. In ChainSecured mode (managed = false), the contract accepts writes only from the owner wallet. The TEE still reads the account’s rules to authorize every execution, but it can’t change them. Converting an account to ChainSecured mode clears the flag, and it can’t be set again. - What scopes the API keys have (everything vs execute-only vs somewhere in between)
managed = false, API keys = execute-only. An “API-mode” setup is: owner = a Lit-managed credential, managed = true, API keys = as broad as you need.
Entities
Account
The top-level identity. Just an address on Base. This address is the owner — it can do everything, and it’s the only thing that can do certain structural and destructive operations. The owner address can be:- An EOA (simple, but no recovery)
- A Lit-managed credential (API mode — the fast path: Lit holds the owner key and relays your writes)
- A SAFE or any governance contract (ChainSecured mode — multisig, voting, timelocks, whatever)
managed flag: for an API-mode account the contract also accepts writes from the TEE’s relay wallets, and for a ChainSecured account it accepts writes only from the owner.
In API mode the admin wallet is the Account Master Wallet that POST /new_account creates, and the account key is that wallet’s private key. The wallet also appears among the account’s PKPs, but Lit Actions can never export its key: Lit.Actions.getPrivateKey refuses the account’s admin and billing wallets, so a usage key cannot recover the account key or sign as the owner. This still holds after the account converts to ChainSecured, when the Account Master Wallet remains the billing wallet. See The Account Master Wallet.
API Key
A secret registered on-chain under an Account. The server issues it as a base64 string: the private key of a wallet the TEE derives for the key. The user holds the key and sends it to the TEE over HTTPS. The chain stores only the keccak256 hash of the key string, so the TEE hashes the key it receives and looks up that hash to find the account and the key’s scopes. API keys have scopes. A scope is a permission granted by the Account Owner when the key is registered (or updated). There are seven scopes:
A key can have any combination of scopes. The owner grants scopes at registration time and can update them later.
Scopes and API fields
The REST API names each scope by its request field.list_api_keys returns the same fields with a can_ prefix on the group fields. In the examples on this page, execute(*) means the execution wildcard [0]. Management scopes require explicit group IDs; [0] grants no management access.
execute(*) is execute_in_groups: [0]. It lets a key run any code without a group, but using a PKP still needs a group that lists both the action and the PKP.
Four of the seven scopes are per-group. When you grant an API key execute, group:manageActions, group:addPkp, or group:removePkp, you specify which groups it applies to. This is the key security property — a leaked onboarding key that has group:addPkp(group_1) can only add PKPs to group_1, not to some new insecure group that someone just created. And because it only has group:addPkp (not group:removePkp), it can’t pull existing PKPs out of the group either.
Three scopes are account-wide. pkp:create is account-wide because PKPs are created in the account’s registry before being assigned to any group. Creating a PKP doesn’t grant access to anything by itself — the PKP still has to be added to a group (which requires group:addPkp on that specific group) and someone has to have execute on that group to actually use it. group:create and group:delete are account-wide because groups aren’t scoped to other groups. group:create alone creates an empty group: listing wallets or actions when creating it needs group:addPkp or group:manageActions on the new group, as adding them afterwards does. In API mode, developers need these to build their app through the API. In ChainSecured mode, the TEE can’t write to the account, so these scopes have no effect there: only the SAFE can create or delete groups.
Everything else is owner-only: adding/revoking API keys, updating scopes, transferring ownership. These are structural operations that affect the trust boundary itself.
PKP Registry (Account-level)
An on-chain list of PKP derivation path IDs owned by the Account. PKPs are created here, then referenced by Groups. The actual key material (signing key + symmetric encryption key) only exists transiently inside the TEE, derived on-demand from the root key using the derivation path ID. It’s never persisted, never leaves the TEE boundary.Lit Action
Immutable JS code identified by its IPFS CID, which is a hash of the code. You can pin an action to IPFS so others can read it, but the TEE doesn’t fetch it from there; the code arrives with the request. Actions are not owned by anyone — they’re public, content-addressed code. Any account can reference any CID in its groups. There is no action registry. This is intentional. Actions are meant to be reusable across companies and users. An ecosystem of audited, well-known action CIDs that people drop into their groups is more valuable than everyone registering private copies. Think of them like npm packages — you reference them, you don’t own them. Every action also has an identity key that the TEE derives from its CID (Lit.Actions.getLitActionPrivateKey()). It needs no PKP and no group entry beyond the CID itself, and only that exact code can use it. Because the identity is a function of the bytes, stamping a small config constant into a template yields a new action with a new key per user or per secret. See Derived Actions. The group still decides who may run the action; the identity key decides what can be proven about the result.
Group
The core authorization primitive. A Group is a permission policy that binds together:- A set of PKP references (derivation path IDs from the account’s PKP registry)
- A set of Action CIDs (any valid IPFS CID — no registration required)
{PKPs, Actions}. “Who can execute” is not a property of the group — it’s a property of API key scopes. The owner decides which API keys get execute on which groups when they configure the keys.
A Group answers the question: “Can this action use this key?” The API key scope answers: “Can this caller use this group?”
Groups are owned by the Account. The owner can create or delete them, and in API mode so can an API key with the group:create or group:delete scope from the table above. PKPs can be referenced by multiple groups. The same action CID can appear in groups across completely unrelated accounts.
Root Key
The master secret managed by Phala’s KMS. Access is governed by on-chain governance at the infrastructure level — only approved TEE build images can derive from it. This is the Phala/dstack layer, separate from the per-account auth model described here.How the Pieces Fit Together
execute scope on the API keys, managed by the owner.
Execution Flow (inside the TEE)
- User sends HTTP request: API key (private key) + “run action QmABC with pkp_001”. The request carries the action’s code, and the TEE hashes it to get the CID
QmABC. - TEE hashes the API key (keccak256 of the key string)
- TEE reads on-chain: which account does this hash belong to, and on which groups does the key have
executescope? - TEE checks: is there a group this key can execute on where QmABC is a listed action AND pkp_001 is a listed PKP?
- If yes → run the QmABC code from the request in the sandbox → derive pkp_001 key material from root key when the action asks for it → return result
- If no → reject
Management Flow (two paths to the same contracts)
Path A: Via TEE (convenience relay)
User sends HTTP + API key to the TEE. TEE checks the key’s scopes (including which groups the scope applies to), then signs and submits a transaction to the permissions contracts on Base on the user’s behalf. This path works only for API-mode accounts: the contract rejects a write from the TEE to a ChainSecured account.Path B: Direct to chain
The Account Owner (SAFE, EOA, whatever) submits transactions directly to the permissions contracts. The TEE is not involved in the mutation at all.accountExistsAndIsMutable in AppStorage.sol), then a scope check when the call names a usage key instead of the account key:
Permission Matrix
The API Key column applies to API-mode accounts, where the TEE relays the call. On a ChainSecured account an API key can only invoke actions; every other row is an owner transaction.Why ChainSecured Mode Emerges from Configuration
Consider two setups:Setup A: “API mode”
- Owner: a Lit-managed credential (the fast path)
- API key
dev_key: scopes =[execute(*), pkp:create, group:create, group:delete, group:manageActions(group_1), group:addPkp(group_1), group:removePkp(group_1)] - Effect: The developer can execute actions, create PKPs and empty groups, and manage membership in group 1 via HTTP. The owner must grant explicit management permissions for each additional group before the key can fill it. API key management and ownership transfer remain owner-only. Fast iteration, no multisig overhead. Recovery is re-authenticating to the dashboard. When the app is production-ready, the developer can revoke the broad-scoped
dev_keyand replace it with purpose-built keys that have narrower scopes.
Setup B: “ChainSecured mode”
- Owner: A 3-of-5 SAFE
- API key
server_key: scopes =[execute(group_1)]— can only run actions on group_1 - No management scopes on any key. The account is ChainSecured (
managed = false), so the contract rejects writes from the TEE and a management scope would have no effect. - Effect: Day-to-day execution flows through
server_key. Every change to the account — registering PKPs, adding them to groups, creating or deleting groups, adding or swapping action CIDs, managing API keys — is a SAFE transaction, because that’s the whole point: transparent, auditable governance over what code your PKPs can run and how your groups are organized. Ifserver_keyleaks, the attacker can invoke the existing actions on group_1 and nothing else. An evil admin withserver_keycan invoke existing actions but cannot change the rules.
Upgrade Flow Example: Swapping Actions in a Group
In API mode (Setup A)
- Pin new Lit Action JS to IPFS → get
QmNEW. Pinning is optional, because the CID is just the hash of the code;POST /core/v1/get_lit_action_ipfs_idcomputes it without IPFS. POST /core/v1/add_action_to_groupwith{"group_id": 1, "action_ipfs_cid": "QmNEW"}, thenPOST /core/v1/remove_action_from_groupwith{"group_id": 1, "hashed_cid": "0x…"}(the hashed CID ofQmOLD, fromlist_actions) → TEE submits a tx for each to update the group- Done. Next execution request uses new permissions.
dev_keyhasgroup:manageActions(group_1), so this is allowed on group 1.
In ChainSecured mode (Setup B)
-
Pin new Lit Action JS to IPFS → get
QmNEW -
Propose a SAFE transaction batch:
group.addAction(groupId, "QmNEW")group.removeAction(groupId, "QmOLD")
addActionToGroup(apiKeyHash, groupId, cidHash)andremoveActionFromGroup(apiKeyHash, groupId, cidHash), wherecidHashis the keccak256 hash of the CID. - SAFE signers review the new code (CID is deterministic — they can fetch and audit it from IPFS)
- 3-of-5 signers approve → SAFE executes batch tx directly on the permissions contracts
- Done. Next execution request reads updated on-chain state. TEE was not involved in the upgrade at all.
Onboarding Flow Example (Setup B)
-
POST /core/v1/prepare_wallet→ the TEE derives a new PKP and returns itswallet_addressandderivation_path. The call needs no API key and registers nothing. -
Propose a SAFE transaction batch on the AccountConfig contract:
registerWalletDerivation(adminHash, wallet_address, derivation_path, name, description)adds the PKP to the account’s PKP registryaddPkpToGroup(adminHash, groupId, wallet_address)adds it to group_1
adminHashis the account’sapiKeyHash, its stable on-chain ID. -
Signers approve → SAFE executes the batch. New customer has a PKP in group_1. The existing
server_key(which hasexecute(group_1)) can now execute actions with this PKP on the customer’s behalf.
server_key executes for all customers.
Account Lifecycle
Onboarding (API mode)
- User signs up through the dashboard
- The account is created with a Lit-managed owner credential → this becomes the Account Owner address
- System registers this on-chain as a new Account
- Owner creates a first group and a first API key with broad scopes
- User uses this API key for all HTTP interactions
Graduating to ChainSecured mode
- User deploys a SAFE on Base with their desired signer set
- User converts the account with
POST /core/v1/convert_to_chain_secured_account(or the dashboard), naming the SAFE as the new admin wallet — authorized by the current owner. See Converting an API account to ChainSecured. - Account Owner is now the SAFE
- SAFE creates execute-only API keys for the groups it runs (e.g.
server_key):POST /core/v1/add_usage_api_key_with_signaturemints the key, then the SAFE registers it on-chain withregisterWalletDerivationandsetUsageApiKey - SAFE revokes the old broad-scoped API key
- The managed credential is fully out of the loop — the SAFE is the sole on-chain owner