Core Insight
ChainSecured mode and API mode aren’t a toggle in the code — they’re an emergent property of how you configure the same system. The only things that vary are:- Who is the Account Owner (a Lit-managed credential vs a SAFE or EOA you control)
- What scopes the API keys have (everything vs execute-only vs somewhere in between)
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)
msg.sender == owner is msg.sender == owner.
API Key
A wallet keypair registered on-chain under an Account. The user holds the private key and sends it to the TEE over HTTPS. The TEE derives the address and looks up what scopes it has. 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:| Scope | What it allows | Scoped to |
|---|---|---|
execute | Invoke Lit Actions with PKPs | Per-group |
pkp:create | Create new PKPs in the account’s PKP registry | Account-wide |
group:create | Create new groups | Account-wide |
group:delete | Delete groups | Account-wide |
group:manageActions | Add and remove action CIDs in a group | Per-group |
group:addPkp | Add PKP references to a group | Per-group |
group:removePkp | Remove PKP references from a group | Per-group |
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. In API mode, developers need these to build their app through the API. In ChainSecured mode, you simply don’t grant these scopes to any API key, and then 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 pinned to IPFS, identified by its CID. 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.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. Only the owner can create or delete them. 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”
- TEE derives address from private key
- TEE reads on-chain: does this address have an API key with
executescope? On which groups? - 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 → derive pkp_001 key material from root key → fetch QmABC from IPFS → execute in sandbox with access to key material → 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.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.Permission Matrix
| Operation | Owner | API Key | Scope required |
|---|---|---|---|
| Invoke action + PKP | ✓ | ✓ | execute(group_id) |
| Create PKP | ✓ | ✓ | pkp:create |
| Create group | ✓ | ✓ | group:create |
| Delete group | ✓ | ✓ | group:delete |
| Add/remove action CIDs in group | ✓ | ✓ | group:manageActions(group_id) |
| Add PKPs to group | ✓ | ✓ | group:addPkp(group_id) |
| Remove PKPs from group | ✓ | ✓ | group:removePkp(group_id) |
| Add API key | ✓ | ✗ | — (owner only) |
| Revoke API key | ✓ | ✗ | — (owner only) |
| Update API key scopes | ✓ | ✗ | — (owner only) |
| Transfer ownership | ✓ | ✗ | — (owner only) |
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:addPkp(*), group:removePkp(*)] - Effect: The developer can do everything via HTTP calls except manage API keys and transfer ownership (those are owner-only, done through the dashboard). This is the full development experience — create groups, add actions, create PKPs, execute, iterate. 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 - API key
onboard_key: scopes =[pkp:create, group:addPkp(group_1)]— can create PKPs and add them to group_1 only - No API keys with
group:create,group:delete,group:manageActions, orgroup:removePkpscopes - Effect: Day-to-day operations flow through purpose-built API keys. The server can execute actions. The onboarding server can set up new customers without a SAFE vote. But everything structural — creating or deleting groups, adding or swapping action CIDs, removing PKPs — always requires a SAFE multisig vote, because that’s the whole point: transparent, auditable governance over what code your PKPs can run and how your groups are organized. If
onboard_keyleaks, the attacker can create PKPs and add them to group_1 (which is annoying but not catastrophic — they’re just creating new key material that uses the same already-audited actions). They can’t remove existing PKPs from the group, can’t add PKPs to any other group, can’t change actions, can’t create or delete groups, can’t execute anything. 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 POST /groups/:id/actionswith{add: ["QmNEW"], remove: ["QmOLD"]}→ TEE submits tx to update group- Done. Next execution request uses new permissions.
dev_keyhasgroup:manageActions(*), so this is allowed on any group.
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")
- 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.
group:manageActions in Setup B, so the only path to change actions is the owner going direct to chain. Every action upgrade is a visible, auditable SAFE proposal.
Onboarding Flow Example (Setup B)
POST /pkps/createusingonboard_key→ TEE submits tx, new PKP registered in account’s PKP registryPOST /groups/group_1/pkpswith{add: ["pkp_new"]}usingonboard_key→ TEE submits tx- Done. 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.
onboard_key exists in Setup B — you don’t want to hit the SAFE every time you onboard a new user. Creating PKPs and adding them to a pre-configured group is a high-volume, low-risk operation. The API keys are per-purpose, not per-customer: one server_key executes for all customers, one onboard_key handles all onboarding. The SAFE only needs to be involved for structural changes: which actions are trusted, which groups exist, and who gets API keys.
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 calls
transferOwnership(safeAddress)— authorized by the current owner through the dashboard - Account Owner is now the SAFE
- SAFE creates new API keys with restricted scopes, locked to specific groups (e.g.
server_key,onboard_key) - 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