Introduction
Cross-program invocation (CPI) is the mechanism on Solana through which one program calls another. It is used for system instruction calls, SPL token transfers, custom program execution, and even event emissions, making it a core part of writing functional programs on Solana.
That composability comes with an unforgiving security surface. Every CPI carries an implicit privilege model that most developers underestimate until they encounter it in a bug report. This article dissects that model completely: how privileges flow, what the runtime enforces, where the gaps are, and how attackers exploit them.
How CPI Works and What Privileges Are Transferred
When a transaction reaches your program, the runtime has already verified every signature attached to it. Those verified signatures translate into is_signer = true flags on the corresponding AccountInfo structs your program receives. When your program calls another program via CPI, those flags travel with the accounts.
When Program A performs a CPI, it can pass the accounts it received to Program B. Crucially, Program B receives these accounts with the original permissions intact. If an account was marked as a Signer for Program A, it remains a Signer for Program B. If an account was marked as Writable for Program A, it remains Writable for Program B.
This is the critical insight. The callee does not receive a degraded view of the world — it receives the same authority the caller had, for every account passed in. Account privileges (signer, writable) extend from caller to callee. A callee cannot escalate privileges beyond what the caller passed. This last constraint is enforced by the runtime, not by your code.
The privilege rule is simple but worth stating precisely: An account can be a signer in the callee only if (a) it was already a signer in the caller, OR (b) it is a PDA derived from the calling program’s seeds via invoke_signed. Privilege reduction is always allowed — the callee may use fewer privileges than the caller granted.
When a transaction executes the initial caller program (Program A), which invokes the callee (B), the signer privileges of that program are passed on to the called program. If the caller program has the privileges to access any particular signer or writable account at runtime, it can also invoke any instruction that uses those accounts.
The practical consequence: any account your user signed for can be used against them by a downstream callee that your program invokes carelessly.
invoke vs invoke_signed
There are two ways to perform a CPI: invoke and invoke_signed. invoke_signed is used to mark a PDA account (which must be derived from the calling program) as a signer for the CPI. The invoke function, on the other hand, does not add any signers. Both functions can forward signer accounts that are already marked as signers.
Under the hood, invoke simply calls invoke_signed with an empty signer seeds array. Use invoke when you don’t need PDA signing, and invoke_signed when the program must authorize an action on behalf of a PDA. Both functions ultimately trigger the same syscall (sol_invoke_signed_rust) and flow through the same runtime path (cpi_common).
invoke — forwarding existing signers
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program::invoke,
system_instruction,
pubkey::Pubkey,
};
pub fn transfer_sol_to_recipient(
accounts: &[AccountInfo],
lamports: u64,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let sender = next_account_info(account_iter)?; // must be is_signer = true
let recipient = next_account_info(account_iter)?;
let system_pgm = next_account_info(account_iter)?;
// Verify the system program before invoking — never skip this
if system_pgm.key != &solana_program::system_program::ID {
return Err(solana_program::program_error::ProgramError::IncorrectProgramId);
}
let ix = system_instruction::transfer(sender.key, recipient.key, lamports);
// invoke forwards the existing `sender` signer privilege
invoke(
&ix,
&[sender.clone(), recipient.clone(), system_pgm.clone()],
)
}
invoke_signed — PDA authority
use solana_program::{
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
program::invoke_signed,
system_instruction,
pubkey::Pubkey,
};
pub fn transfer_from_vault(
program_id: &Pubkey,
accounts: &[AccountInfo],
lamports: u64,
bump: u8,
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let vault = next_account_info(account_iter)?; // PDA owned by this program
let recipient = next_account_info(account_iter)?;
let system_pgm = next_account_info(account_iter)?;
if system_pgm.key != &solana_program::system_program::ID {
return Err(solana_program::program_error::ProgramError::IncorrectProgramId);
}
let signer_seeds: &[&[&[u8]]] = &[&[b"vault", &[bump]]];
let ix = system_instruction::transfer(vault.key, recipient.key, lamports);
// invoke_signed presents the PDA as a signer via seed derivation
invoke_signed(
&ix,
&[vault.clone(), recipient.clone(), system_pgm.clone()],
signer_seeds,
)
}
The bump seed is the canonical bump obtained from find_program_address. Always store it on-chain at account creation and pass it back in — never iterate on-chain.
PDA Signing and Its Security Model
Program Derived Addresses (PDAs) are 32-byte account addresses that are deterministically derived from a program ID and a set of seeds. They are guaranteed to not lie on the Ed25519 curve, which means no private key exists for them. Only the program whose ID was used in the derivation can “sign” for a PDA, and it does so through invoke_signed during cross-program invocations.
This is the core of PDA security: when Program A makes a CPI with invoke_signed, the runtime uses Program A’s ID to derive the PDA. If a malicious program tries to use Program A’s PDA, the runtime will use the malicious program’s ID in the derivation, get a different address, and reject it.
Signing is a misnomer when it comes to PDAs. There is no signature involved — just runtime-delegated authority. It is totally different from normal signing where you have signature = sign(message, private_key) and the runtime does cryptographic verification. With PDAs there’s NO cryptographic signature, just address matching.
The runtime verification path for invoke_signed is:
- Take the
signers_seedsprovided by the caller. - Call
create_program_address(seeds, caller_program_id). - Compare the derived address against the accounts list.
- Using the
signer_seedsandprogram_idof the caller, the Solana runtime internally callscreate_program_address. The PDA generated is compared to the addresses given in the instruction, and is added as a valid signer if they match.
This means a PDA’s signing authority is unforgeable by any other program. The threat is not that another program steals PDA signing rights — the threat is that your program passes the wrong seeds, or passes a PDA signer to an untrusted program that then misuses it.
// SAFE: PDA signing with validated seeds and stored bump
pub fn close_user_account(
program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
let account_iter = &mut accounts.iter();
let user_account = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
// Deserialize the stored state to retrieve the canonical bump
let state = UserAccount::try_from_slice(&user_account.data.borrow())?;
// Reconstruct the expected PDA and assert it matches
let (expected_pda, _) = Pubkey::find_program_address(
&[b"user", authority.key.as_ref()],
program_id,
);
if user_account.key != &expected_pda {
return Err(ProgramError::InvalidArgument);
}
// Use the stored canonical bump — never re-search on-chain
let signer_seeds: &[&[&[u8]]] = &[&[
b"user",
authority.key.as_ref(),
&[state.bump],
]];
invoke_signed(
&system_instruction::transfer(user_account.key, destination.key, user_account.lamports()),
&[user_account.clone(), destination.clone()],
signer_seeds,
)
}
CPI Privilege Escalation via Signer Seeds
The runtime-level privilege escalation error (PrivilegeEscalation) is triggered when a program attempts to pass an account to a CPI with higher privileges than it currently holds. The CPI privilege escalation error is overloaded, which makes it more difficult to debug what’s happening. If you get stuck thinking that the problematic account is not being signed correctly, you may not realize that the error is due to a read-only account being passed as writable.
There are two distinct escalation axes:
- Signer escalation: claiming
is_signer = truefor an account that was not already a signer and is not a valid PDA derivation from the current program. - Writable escalation: marking an account
is_writable = truein the CPI instruction when it was received as read-only.
Both are hard runtime errors. But the application-level escalation — where the program legitimately has authority and hands it to an untrusted callee — is not caught by the runtime. That is the developer’s responsibility.
When a user or program provides a signer account, they are essentially entrusting downstream programs with a piece of verified authority. The vulnerability emerges when this trust is misplaced. If an untrusted program is invoked with a signer account that possesses sensitive privileges, it can forward that signer with arbitrary arguments to exploit those privileges.
Consider: your program holds a PDA that is the mint authority of an SPL token. You perform a CPI to — what you believe is — the token program, to mint tokens. If the attacker has substituted their own program for the token program, your PDA signer (which proves mint authority) is now available to the attacker’s code. PDAs are commonly used for authentication checks. Consequently, abusing a signed PDA can have drastic program-specific consequences. Combining the signer privileges with an arbitrary CPI can have devastating consequences.
Arbitrary CPI Vulnerabilities
Arbitrary CPIs occur when a program invokes another program without verifying the target program’s identity. This vulnerability exists because the Solana runtime allows any program to call another program if the caller has the callee’s program ID and adheres to the callee’s interface.
If a program performs CPIs based on user input without validating the callee’s program ID, it could execute code in an attacker-controlled program.
How an Attacker Passes a Malicious Program
Solana’s account model requires all accounts to be provided upfront in the transaction. This includes the program being invoked. Given an attacker’s ability to pass any account into a program’s function, data validation becomes a fundamental pillar of Solana program security. Developers must ensure that their program can distinguish between legitimate and malicious inputs. This includes verifying account ownership, ensuring accounts are of an expected type, and whether an account is a signer.
The attack pattern is mechanically simple: the attacker deploys a program that implements the same instruction discriminator as the target (e.g. the SPL Token transfer discriminator). The attacker’s program’s logic returns Ok(()) without performing any real transfer. The attacker then submits a transaction to your protocol, passing their program’s address where the token_program account is expected.
Vulnerable Code
// VULNERABLE: token_program is accepted from user input with no validation
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
// No check that token_program.key == spl_token::id()
let cpi_accounts = Transfer {
from: ctx.accounts.user_token_account.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
// token_program could be attacker-controlled!
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
token::transfer(cpi_ctx, amount)?;
// Balance is incremented even if the CPI was a no-op
ctx.accounts.user_deposit_account.balance += amount;
Ok(())
}
As the token_program account is not properly validated, a malicious caller could specify a program they control as the token program. Their program would simply return successfully without any value transfer, while still incrementing their account balance in the bank program. The malicious actor could then later withdraw funds from the bank that they never deposited.
In most scenarios, it’s crucial to verify that the program being called is indeed the intended one. If this is not done, there could be a malicious attacker who inserts their own program and might exploit some funds or functionality with the added PDA signature.
Secure Equivalent
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, Transfer};
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
token::mint = vault_token_account.mint,
token::authority = user,
)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(
mut,
seeds = [b"vault"],
bump,
token::mint = user_token_account.mint,
)]
pub vault_token_account: Account<'info, TokenAccount>,
#[account(
mut,
seeds = [b"deposit", user.key().as_ref()],
bump,
)]
pub user_deposit_account: Account<'info, DepositState>,
// Anchor's `Token` type enforces token_program.key == spl_token::id()
pub token_program: Program<'info, Token>,
}
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.user_token_account.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
token::transfer(cpi_ctx, amount)?;
ctx.accounts.user_deposit_account.balance = ctx
.accounts
.user_deposit_account
.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
Anchor’s Program<'info, T> wrapper validates program.key() == T::id() automatically at account deserialization time. When using native Rust, you must perform this check manually:
// Native Rust: manual program ID validation
pub fn secure_cpi_transfer(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let source = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
let token_prog = next_account_info(account_iter)?;
// CRITICAL: hard-code the expected program ID
if token_prog.key != &spl_token::id() {
msg!("Error: token_program must be the SPL Token program");
return Err(ProgramError::IncorrectProgramId);
}
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
let ix = spl_token::instruction::transfer(
token_prog.key,
source.key,
destination.key,
authority.key,
&[],
amount,
)?;
invoke(
&ix,
&[source.clone(), destination.clone(), authority.clone(), token_prog.clone()],
)
}
Account Info Verification Requirement
The requirement to verify account identity is not limited to the program being called — it extends to every account passed into a CPI.
Unlike traditional smart contract platforms where access control is often enforced at the protocol level, Solana’s design allows any account to be passed into a program’s function. This means a malicious actor can inject unexpected or deceptive inputs, attempting to manipulate the program’s behavior. Without strict data validation and access control mechanisms, developers risk exposing their applications to severe vulnerabilities.
The necessary checks before any CPI are:
- Program ID check — does the program account’s
keymatch the expected program address? - Account ownership check — is the account owned by the program you expect?
- Signer check — for authority accounts, is
is_signeractuallytrue? - Account type check — does the account discriminator/data match the expected type?
- Writable check — are accounts that will be mutated marked as
is_writable?
Programs must include checks to verify that an account has been signed by the appropriate wallet. This can be done by checking the AccountInfo::is_signer field of the accounts involved in the transaction. The program can enforce that only authorized accounts can perform certain actions by checking whether the account executing the privileged operation has the is_signer flag set to true.
/// Validates a token account before using it in a CPI
fn validate_token_account(
token_account: &AccountInfo,
expected_mint: &Pubkey,
expected_owner: &Pubkey,
) -> ProgramResult {
// 1. Verify the account is owned by the SPL Token program
if token_account.owner != &spl_token::id() {
msg!("Error: token account not owned by SPL Token program");
return Err(ProgramError::IllegalOwner);
}
// 2. Deserialize and validate internal fields
let token_data = spl_token::state::Account::unpack(&token_account.data.borrow())
.map_err(|_| ProgramError::InvalidAccountData)?;
if &token_data.mint != expected_mint {
msg!("Error: token account mint mismatch");
return Err(ProgramError::InvalidArgument);
}
if &token_data.owner != expected_owner {
msg!("Error: token account owner mismatch");
return Err(ProgramError::IllegalOwner);
}
Ok(())
}
A subtle related risk is the missing reload pattern. After a CPI returns, the in-memory representation of a writable account may be stale. In Anchor, you must call .reload() on an account after a CPI that modifies it before reading from it again:
// After CPI that modifies vault_token_account:
ctx.accounts.vault_token_account.reload()?;
let new_balance = ctx.accounts.vault_token_account.amount;
Failing to reload means your program continues operating on the pre-CPI snapshot, which can lead to incorrect state transitions.
CPI Depth Limits
The maximum height of the program instruction invocation is called max_instruction_stack_depth and is set to the MAX_INSTRUCTION_STACK_DEPTH constant of 5. With MAX_INSTRUCTION_STACK_DEPTH_SIMD_0268 active, this increases to 9. Stack height 1 is the initial transaction instruction. Each CPI increments the height by 1. A maximum of 5 means a program can make CPIs up to 4 levels deep (8 levels deep with SIMD-0268).
Cross-program invocations allow programs to invoke other programs directly, but the depth is constrained currently to 4. When a program exceeds the allowed cross-program invocation call depth, it will receive a CallDepth error.
This means a chain like A → B → C → D → E will fail at the final invocation. You must design your architecture with this ceiling in mind. Deep chains in DeFi composability — e.g. a router that calls a DEX that calls a lending pool that calls a price oracle — can hit this limit surprisingly easily.
While Solana technically supports up to 4 levels of nested invocations, developers should consider the potential complexity and resource consumption associated with deeply nested CPI calls. Excessive nesting can lead to increased transaction costs, longer execution times, and potential resource exhaustion, impacting the performance and scalability of the application. Therefore, developers should carefully design their programs and limit the depth of CPI calls to ensure optimal performance and efficiency.
The depth limit also has a security property: direct self-recursion is allowed (A→A→A), but indirect reentrancy is not — A→B→A returns ReentrancyNotAllowed. The Solana runtime restricts CPIs to a maximum depth of four and enforces strict account rules, such as only allowing an account’s owner to modify its data. These constraints prevent reentrancy attacks by limiting direct self-recursion and ensuring a program cannot be involuntarily invoked in an intermediary state.
Additionally, a shared compute budget means a callee’s CU consumption reduces the caller’s remaining budget. Deep chains that each consume significant compute units can exhaust the budget and cause the entire transaction to fail — a denial-of-service vector when the callee is adversarial.
How to Safely Perform CPIs to Untrusted Programs
Sometimes a protocol genuinely needs to invoke an unknown or user-supplied program — think a generic execution layer, a multisig executor, or a CPI relay. These patterns are dangerous by nature and require extreme discipline.
Rule 1: Never pass privileged signers to untrusted programs
The vulnerability emerges when trust is misplaced. If an untrusted program is invoked with a signer account that possesses sensitive privileges, it can forward this signer with arbitrary arguments to exploit those privileges.
If you must invoke an untrusted program, ensure that none of the accounts you pass to it carry signing authority that can be reused. This means:
- Do not include user signers in the
AccountInfoslice passed to the untrusted CPI. - Do not include your own PDA signers unless you are certain the callee cannot reuse them.
// DANGEROUS: forwards user authority to an untrusted program
pub fn execute_arbitrary(
ctx: Context<Execute>,
instruction_data: Vec<u8>,
) -> Result<()> {
let ix = Instruction {
program_id: *ctx.accounts.target_program.key,
accounts: vec![
// WRONG: passing user as signer to arbitrary program
AccountMeta::new(*ctx.accounts.user.key, true),
],
data: instruction_data,
};
invoke(&ix, &[ctx.accounts.user.to_account_info()])?;
Ok(())
}
// SAFER: strip signer privileges when invoking untrusted programs
pub fn execute_arbitrary_safe(
ctx: Context<Execute>,
instruction_data: Vec<u8>,
) -> Result<()> {
// Pass user as non-signer to prevent signer reuse by the callee
let ix = Instruction {
program_id: *ctx.accounts.target_program.key,
accounts: vec![
AccountMeta::new(*ctx.accounts.user.key, false), // is_signer = false
],
data: instruction_data,
};
// Note: only pass account infos for accounts listed in the instruction
invoke(&ix, &[ctx.accounts.user.to_account_info()])?;
Ok(())
}
Rule 2: Allowlist known programs
To secure against arbitrary CPI, developers can add a check that verifies the target program’s identity before performing the CPI. If your program’s design requires invoking user-supplied programs, maintain an on-chain allowlist of permitted program IDs, governed by a multisig or DAO.
// On-chain allowlist check before CPI
pub fn invoke_with_allowlist(
ctx: Context<InvokeAllowlisted>,
instruction_data: Vec<u8>,
) -> Result<()> {
let registry = &ctx.accounts.program_registry;
let target_key = ctx.accounts.target_program.key();
require!(
registry.allowed_programs.contains(&target_key),
ErrorCode::ProgramNotAllowed
);
// Safe to proceed — program has been vetted
let ix = Instruction {
program_id: target_key,
accounts: vec![/* ... */],
data: instruction_data,
};
invoke(&ix, &[/* relevant account infos */])?;
Ok(())
}
Rule 3: Use Anchor’s typed CPI wrappers where possible
A program may have a publicly available CPI module if it was written using Anchor. This makes invoking the program from another Anchor program easy and secure. The Anchor CPI module automatically checks that the program’s address passed in matches the program’s address stored in the module. Alternatively, hardcoding the address can be a possible solution instead of having the user pass it in.
Anchor generates strongly-typed CPI clients from IDLs. Using anchor_spl::token::transfer(...) instead of a raw invoke call means the program ID is compiled into the binary — not supplied at runtime.
Rule 4: Verify account state after CPI
After any CPI to a partially trusted program, re-derive or reload critical accounts and assert invariants:
pub fn swap_and_verify(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
let balance_before = ctx.accounts.output_token_account.amount;
// Invoke the AMM (trusted, but we still verify post-conditions)
amm::cpi::swap(
CpiContext::new(
ctx.accounts.amm_program.to_account_info(),
amm::cpi::accounts::Swap {
pool: ctx.accounts.pool.to_account_info(),
// ...
},
),
amount_in,
0, // min_amount_out — enforced below
)?;
// Reload to get the post-CPI state
ctx.accounts.output_token_account.reload()?;
let balance_after = ctx.accounts.output_token_account.amount;
let received = balance_after
.checked_sub(balance_before)
.ok_or(ErrorCode::Overflow)?;
require!(received >= ctx.accounts.swap_params.min_out, ErrorCode::SlippageExceeded);
Ok(())
}
Rule 5: Account for the MAX_CPI_ACCOUNT_INFOS limit
The runtime validates the account info count against MAX_CPI_ACCOUNT_INFOS (128, or 255 with SIMD-0339). Passing a user-supplied list of account infos without length-bounding it is a computational DoS vector. Always cap the number of accounts you accept from user input before forwarding them into a CPI.
CPI Security Checklist
Use this checklist as a mandatory gate before deploying any Solana program that performs cross-program invocations.
Program Identity
- Hard-code or strictly compare program IDs — never accept a program address from user input without checking it against a known constant or allowlist.
- Use Anchor’s
Program<'info, T>type wherever possible; it enforcesT::id()at deserialization. - Avoid generic “execute arbitrary instruction” patterns unless governed by an on-chain allowlist and an access-controlled upgrade authority.
- Verify
check_authorized_programimplications — be aware which programs are blocked from CPI targets by the runtime.
Signer and Privilege Hygiene
- Never pass a user signer to an untrusted or user-supplied program.
- Never pass a PDA signer (via
invoke_signed) to a program you haven’t audited. The signer is proof of your program’s authority and can be forwarded by the callee. - Mark accounts as non-signer in
AccountMetawhen invoking untrusted callees, even if the account is a signer in the outer context. - Audit every writable account passed in a CPI — writable + signer is the highest privilege combination and should be scrutinized.
- Verify
is_signeron authority accounts explicitly before every privileged action, both in your program and before each CPI.
Account Validation
- Check
account.ownerbefore deserializing — ensure the account is owned by the expected program. - Validate all mints, vaults, and PDAs before passing them to a