Good to see you again. In the previous lesson, you used Anchor to create a program-owned Profile account at a deterministic PDA and stored its canonical bump. That solved identity and initialization: the program can reliably locate state without any user managing a new keypair.
This lesson adds the capability that makes PDAs operationally powerful: a program can authorize an inner instruction as a PDA, despite the PDA having no private key. You will learn the native Rust invoke_signed mechanism directly, then connect it to Anchor’s .with_signer(...) abstraction. The concrete example is a SOL vault PDA that sends SOL through a System Program CPI.
A PDA signature is runtime authorization, not cryptography
A normal account proves signer status with an Ed25519 signature from its private key, included in the transaction by a wallet or keypair. A PDA is deliberately off curve, so no corresponding private key exists and no browser wallet can sign as it.
Instead, while your program is executing, it can provide the PDA’s complete derivation inputs to the runtime:
- the original seed bytes, in their exact order;
- the one-byte bump seed;
- implicitly, the ID of the currently executing program.
The runtime derives an address from those values. If it matches a PDA in your program’s derivation namespace, the runtime temporarily treats that PDA as a valid signer for the inner CPI only.
This distinction is essential:
| Capability | Who provides it? | Scope |
|---|---|---|
| Wallet signature | A user’s private key | The outer transaction |
| PDA signer privilege | Solana runtime after seed verification | One cross-program invocation |
| Permission to modify account data | The account’s owner program | During program execution |
A PDA signer is therefore not a hidden keypair and not a general-purpose signature. No client can ask the network to sign a transaction as your PDA. Only code running under the matching program ID can request this signer privilege, and only by supplying the exact seeds and bump.
The official Solana documentation gives both Anchor and native Rust versions of this pattern. Read it now with the goal of recognizing the same three elements in each version: the PDA validation, the signer-seed array, and the CPI itself.
CPIs with PDA Signers | Solana
Read the official Solana documentation’s explanation and examples. It shows exactly how the runtime turns verified PDA seeds into signer privilege for a CPI, first in Anchor and then in native Rust.
In the opening subsection, read the complete introductory explanation of invoke_signed; focus especially on the runtime rule. Then read the “Anchor” example under “CPIs with PDA signers” to see the framework-level form. Next, read the entire “Rust” example, from the comment native implementation through its invoke_signed call. Notice that it derives and verifies the expected PDA before using the same seeds plus bump as signer seeds.
The motivating vault: why a CPI needs a PDA signer
Consider a simple protocol-controlled SOL vault. Its address is derived for a particular authority:
["vault", authority public key bytes]
An authority first sends SOL into this vault PDA using an ordinary wallet-signed System Program transfer. Funding the vault does not require the PDA to sign; the user is simply sending SOL to an address.
Later, the authority asks your program to release some SOL from the vault to a recipient. The program must call the System Program’s transfer instruction. That instruction requires the source account, the vault, to be a signer.
The vault cannot sign conventionally. This is the precise moment for invoke_signed.
There is a useful subtlety here. This particular vault is a system-owned account with no custom data. Yet the PDA was derived using your program ID, so your program can supply its PDA signer privilege during a CPI. This shows why two ideas must not be conflated:
- The PDA derivation namespace determines which executing program can request PDA signing.
- The account’s owner field determines which program may edit that account’s data.
Your previous Profile PDA is program-owned and holds serialized state. This vault PDA is system-owned and holds SOL. In later token work, you will commonly use a PDA as the authority of an SPL token account rather than as the token account itself.
The three layers of a signed CPI
Before writing code, separate the three layers involved in a withdrawal.
-
Your program validates the request.
It checks that the submitted vault is the PDA expected for the supplied authority and that the authority signed the outer transaction. Real applications may also check a stored configuration account, a withdrawal limit, an escrow state, or a multisig policy. -
Your program constructs the inner instruction.
Here, it uses the System Program’s transfer instruction, whose source account is marked as writable and signer. -
invoke_signedsupplies PDA signer seeds.
The runtime reconstructs the PDA from those seed bytes and the current program ID. If the reconstruction corresponds to the supplied vault, the System Program sees the vault as a signer and can execute its normal transfer logic.
If you used ordinary invoke in step 3, the System Program would reject the inner transfer because its source account did not sign. invoke_signed does not bypass the System Program’s rules; it gives the System Program the signer privilege it explicitly requires.
Calling invoke_signed in native Rust
Here is a focused native Rust handler for the vault withdrawal. Assume the instruction processor has already decoded an amount: u64 and has received accounts in this order:
vaultauthorityrecipientsystem_program
use solana_program::{
account_info::AccountInfo,
program::invoke_signed,
program_error::ProgramError,
pubkey::Pubkey,
system_instruction,
system_program,
entrypoint::ProgramResult,
};
fn withdraw_from_vault(
program_id: &Pubkey,
vault_info: &AccountInfo,
authority_info: &AccountInfo,
recipient_info: &AccountInfo,
system_program_info: &AccountInfo,
amount: u64,
) -> ProgramResult {
// Application-level authorization and basic account checks.
if !authority_info.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
if !vault_info.is_writable || !recipient_info.is_writable {
return Err(ProgramError::InvalidAccountData);
}
if *system_program_info.key != system_program::ID {
return Err(ProgramError::IncorrectProgramId);
}
// Derive and validate the vault PDA expected by this program.
let authority_key = authority_info.key;
let vault_seeds: &[&[u8]] = &[
b"vault",
authority_key.as_ref(),
];
let (expected_vault, bump) =
Pubkey::find_program_address(vault_seeds, program_id);
if expected_vault != *vault_info.key {
return Err(ProgramError::InvalidArgument);
}
// Construct the instruction for the program being called.
let transfer_ix = system_instruction::transfer(
vault_info.key,
recipient_info.key,
amount,
);
// Reconstruct the exact PDA derivation inputs, including the bump.
let bump_seed = [bump];
let vault_signer_seed_group: &[&[u8]] = &[
b"vault",
authority_key.as_ref(),
&bump_seed,
];
let signer_seeds: &[&[&[u8]]] = &[
vault_signer_seed_group,
];
// Invoke the System Program. The vault is now a signer for this CPI.
invoke_signed(
&transfer_ix,
&[
vault_info.clone(),
recipient_info.clone(),
system_program_info.clone(),
],
signer_seeds,
)?;
Ok(())
}
The handler has deliberately separated authorization, PDA validation, and CPI execution. Keeping those stages distinct makes reviews much easier: a future change to the transfer mechanism should not accidentally remove the authority check.
Validate the PDA before asking it to sign
This part rebuilds the expected vault address:
let vault_seeds: &[&[u8]] = &[
b"vault",
authority_key.as_ref(),
];
let (expected_vault, bump) =
Pubkey::find_program_address(vault_seeds, program_id);
if expected_vault != *vault_info.key {
return Err(ProgramError::InvalidArgument);
}
The client is free to submit arbitrary accounts. It might provide a different writable system account in the vault position. Your program must establish that the supplied account is the address your protocol intended to use.
The authority public key is used as its raw 32-byte representation through authority_key.as_ref(). It is not its Base58 display string. As in the previous lesson, seed order and byte encoding are part of the protocol’s address schema.
The authority’s outer-transaction signature is a separate check:
if !authority_info.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
The PDA signature authorizes the inner source account for the System Program. The authority signature tells your program that this user is allowed to request a withdrawal. A secure design generally needs both.
Build the signer-seed structure
The most initially confusing line is usually this:
let signer_seeds: &[&[&[u8]]] = &[
vault_signer_seed_group,
];
It contains three nested levels because invoke_signed can sign for more than one PDA in one CPI:
| Layer | Meaning in this example |
|---|---|
&[u8] | One seed’s bytes, such as b"vault" or the bump byte |
&[&[u8]] | Every seed required to derive one PDA |
&[&[&[u8]]] | One or more PDA signer seed groups |
For this vault, the complete group is:
let bump_seed = [bump];
let vault_signer_seed_group: &[&[u8]] = &[
b"vault",
authority_key.as_ref(),
&bump_seed,
];
The bump is not a decimal string and not a multi-byte integer encoding. It is a single byte, represented by the one-element array bump_seed.
Most PDA signer failures come from one of these mismatches:
- omitting the bump;
- using a different static prefix, such as
b"Vault"instead ofb"vault"; - reversing seed order;
- converting a public key to Base58 text instead of using its bytes;
- deriving the PDA under a different program ID.
The seed group used in invoke_signed must reconstruct exactly the PDA you validated.
What the runtime sees at the call boundary
The final call is compact:
invoke_signed(
&transfer_ix,
&[
vault_info.clone(),
recipient_info.clone(),
system_program_info.clone(),
],
signer_seeds,
)?;
But several checks happen around it.
First, transfer_ix identifies the System Program and declares the vault as the transfer source. The System Program’s transfer instruction requires that source to be writable and signer.
Second, invoke_signed receives the account infos needed by the inner instruction, plus the System Program account itself. The order should correspond to what the callee expects.
Third, the runtime derives a PDA using:
b"vault",- the authority public key bytes,
- the bump byte,
- the ID of the program currently executing
withdraw_from_vault.
Only if that derived address corresponds to the vault account does the runtime grant signer privilege for this one inner call. Then the System Program checks its usual conditions, including account writability and sufficient source lamports, before moving the SOL.
The PDA’s temporary signer privilege does not persist after the CPI returns. Nor can the System Program pass that privilege onward to an unrelated nested call unless the normal Solana privilege rules permit it.
The Anchor form: .with_signer(...) is the same mechanism
The previous lesson used Anchor constraints, so you will usually write the same design in a more compact form. Anchor’s CpiContext::with_signer wraps the native invoke_signed mechanism rather than replacing it.
A corresponding handler looks like this:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
pub fn withdraw_from_vault(
ctx: Context<WithdrawFromVault>,
amount: u64,
) -> Result<()> {
let authority_key = ctx.accounts.authority.key();
let bump = ctx.bumps.vault;
let bump_seed = [bump];
let signer_seed_group: &[&[u8]] = &[
b"vault",
authority_key.as_ref(),
&bump_seed,
];
let signer_seeds: &[&[&[u8]]] = &[
signer_seed_group,
];
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.recipient.to_account_info(),
},
)
.with_signer(signer_seeds);
transfer(cpi_context, amount)
}
The account context carries the validation responsibilities:
#[derive(Accounts)]
pub struct WithdrawFromVault<'info> {
#[account(
mut,
seeds = [b"vault", authority.key().as_ref()],
bump,
)]
pub vault: SystemAccount<'info>,
pub authority: Signer<'info>,
#[account(mut)]
pub recipient: SystemAccount<'info>,
pub system_program: Program<'info, System>,
}
Anchor performs the PDA address validation expressed in seeds and bump before entering the handler. ctx.bumps.vault gives the canonical bump that matched that validation. The with_signer call provides the same seed group to the eventual inner invocation.
One important limitation remains: the constraints shown establish that authority signed and that vault is derived from that authority. They do not automatically express every protocol policy. If your vault belongs to a profile, escrow, DAO, or token sale configuration, add explicit constraints and state checks that connect the requested withdrawal to that policy.
A controlled local test sequence
When you implement this, use a small amount of SOL on localnet or devnet and observe the balances before and after each transaction.
- Derive the vault on the client using
[Buffer.from("vault"), authority.publicKey.toBuffer()]and your program ID. - Fund that PDA from the authority wallet with a normal System Program transfer.
- Call your program’s withdrawal instruction with a recipient and a smaller amount.
- Confirm that the vault balance decreased and the recipient balance increased by the requested amount, apart from network fees paid by the outer transaction signer.
Then deliberately change one seed prefix on the client or in the program. The PDA validation should fail. This is a useful confirmation that the seed schema is functioning as an access boundary rather than merely as an address-generation convention.
Diagnosing common failures
| Symptom | Likely cause | First thing to inspect |
|---|---|---|
| Missing required signature from the inner program | Used invoke rather than invoke_signed, or supplied incorrect signer seeds | Static seeds, dynamic seed bytes, bump, and calling program ID |
| PDA constraint failure in Anchor | Client derived a different address | Program ID, seed order, and text encoding |
| Invalid argument from native validation | Submitted vault does not equal the expected PDA | Account order and client-side PDA derivation |
| Insufficient funds | The vault lacks lamports for the requested transfer | Vault balance and requested amount |
| Writable-account error | Vault or recipient was not marked writable | Account metas, Anchor mut, and client instruction construction |
| Withdrawal succeeds for an unintended caller | PDA signing is correct but authorization policy is incomplete | Your authority or state-based permission checks |
Key takeaways
invoke_signedlets a currently executing program provide a PDA signer for a CPI by supplying the PDA’s exact seeds and bump.- The runtime derives the PDA using the current program ID, adds that PDA to the inner invocation’s valid signers, and then lets the called program perform its ordinary privilege checks.
- A PDA signature is temporary CPI-scoped authorization, not a private key or a client-side transaction signature.
- Validate the expected PDA and enforce the caller’s authorization policy before invoking another program.
- In Anchor,
.with_signer(signer_seeds)is the ergonomic equivalent of nativeinvoke_signed. - System-owned SOL vault PDAs and program-owned state PDAs serve different purposes; derivation authority and account-data ownership are related but distinct concepts.
Next, you will move from SOL vaults to the SPL Token architecture: token mints, token accounts, and authorities. The same PDA-signing pattern will become central when a program controls a token mint or token vault.
Can't find a good explanation? Sign up and we'll make it for you
Sign up