General
Program Security

Program Security

Signer Checks

Signer checks are the digital equivalent of requiring a handwritten signature, they prove that an account holder actually authorized a transaction rather than someone else acting on their behalf. In Solana's trustless environment, this cryptographic proof is the only way to verify authentic authorization.

This becomes critical when dealing with Program Derived Accounts (PDAs) and authority-gated operations. Most program accounts store an authority field that determines who can modify them, and many PDAs are derived from specific user accounts. Without signer verification, your program has no way to distinguish between legitimate owners and malicious impersonators.

The consequences of missing signer checks are devastating: any account can perform operations that should be restricted to specific authorities, leading to unauthorized access, drained accounts, and complete loss of control over program state.

Anchor

Consider this vulnerable instruction that transfers ownership of a program account:

#[program]
pub mod insecure_update{
    use super::*;
 
    //..
 
    pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
        ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
    
        Ok(())
    }
 
    //..
}
 
#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
    /// CHECK: This account will not be checked by Anchor
    pub owner: UncheckedAccount<'info>,
    /// CHECK: This account will not be checked by Anchor
    pub new_owner: UncheckedAccount<'info>,
   #[account(
        mut,
        has_one = owner
    )]
    pub program_account: Account<'info, ProgramAccount>,
 
}
 
#[account]
pub struct ProgramAccount {
    owner: Pubkey,
}

At first glance, this looks secure. The has_one = owner constraint ensures that the owner account passed to the instruction matches the owner field stored in the program_account. The data validation is perfect, but there's a fatal flaw.

Notice that owner is an UncheckedAccount, not a Signer. This means while Anchor verifies that the provided account matches the stored owner, it never checks whether that account actually signed the transaction.

An attacker can exploit this by:

  • Finding any program account they want to hijack
  • Reading the current owner's public key from the account data
  • Crafting a transaction that passes the real owner's public key as the owner parameter
  • Setting themselves as the new_owner
  • Submitting the transaction without the real owner's signature

The has_one constraint passes because the public keys match, but since there's no signer verification, the attacker successfully transfers ownership to themselves without the legitimate owner's consent. Once they control the account, they can perform any operation as the new authority.

Luckily Anchor makes it super easy to perform this check directly in the account struct by just changing UncheckedAccount to Signer like this:

#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
    pub owner: Signer<'info>,
    /// CHECK: This account will not be checked by Anchor
    pub new_owner: UncheckedAccount<'info>,
   #[account(
        mut,
        has_one = owner
    )]
    pub program_account: Account<'info, ProgramAccount>,
}

Or you could add the signer account constraint like this:

#[derive(Accounts)]
pub struct UpdateOwnership<'info> {
    #[account(signer)]
    /// CHECK: This account will not be checked by Anchor
    pub owner: UncheckedAccount<'info>,
    /// CHECK: This account will not be checked by Anchor
    pub new_owner: UncheckedAccount<'info>,
   #[account(
        mut,
        has_one = owner
    )]
    pub program_account: Account<'info, ProgramAccount>,
}

Or you could just add a signer check in the instruction using the ctx.accounts.owner.is_signer check like this:

pub fn update_ownership(ctx: Context<UpdateOwnership>) -> Result<()> {
    if !ctx.accounts.owner.is_signer {
        return Err(ProgramError::MissingRequiredSignature.into());
    }
 
    ctx.accounts.program_account.owner = ctx.accounts.new_owner.key();
 
    Ok(())
}

By adding this check, the instruction handler will only proceed if the authority account has signed the transaction. If the account is not signed, the transaction will fail.

Pinocchio

In Pinocchio, since we don't have the possibility of adding security checks directly inside of the account struct, we are forced to do so in the instruction logic.

We can do so very similarly to Anchor by using the is_signer() function like this:

if !self.accounts.owner.is_signer() {
    return Err(ProgramError::MissingRequiredSignature.into());
}
Contents
View Source
Blueshift © 2025Commit: e508535