Summary
-
Signer Checks are essential to verify that specific accounts have signed a transaction. Without proper signer checks, unauthorized accounts may execute instructions they shouldn't be allowed to perform.
-
In Anchor, you can use the
Signeraccount type in your account validation struct to automatically perform a signer check on a given account. -
Anchor also provides the
#[account(signer)]constraint, which automatically verifies that a specified account has signed the transaction. -
In native Rust, implement a signer check by verifying that an account's
is_signerproperty istrue:if !ctx.accounts.authority.is_signer {return Err(ProgramError::MissingRequiredSignature.into());}
Lesson
Signer checks ensure that only authorized accounts can execute specific instructions. Without these checks, any account might perform operations that should be restricted, potentially leading to severe security vulnerabilities, such as unauthorized access and control over program accounts.
Missing Signer Check
Below is an oversimplified instruction handler that updates the authority
field on a program account. Notice that the authority field in the
UpdateAuthority account validation struct is of type UncheckedAccount. In
Anchor, the
UncheckedAccount
type indicates that no checks are performed on the account before executing the
instruction handler.
Although the has_one constraint ensures that the authority account passed to
the instruction handler matches the authority field on the vault account,
there is no verification that the authority account actually authorized the
transaction.
This omission allows an attacker to pass in the authority account's public key
and their own public key as the new_authority account, effectively reassigning
themselves as the new authority of the vault account. Once they have control,
they can interact with the program as the new authority.
use anchor_lang::prelude::*;declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");#[program]pub mod insecure_update{use super::*;...pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {ctx.accounts.vault.authority = ctx.accounts.new_authority.key();Ok(())}}#[derive(Accounts)]pub struct UpdateAuthority<'info> {#[account(mut,has_one = authority)]pub vault: Account<'info, Vault>,/// CHECK: This account will not be checked by Anchorpub new_authority: UncheckedAccount<'info>,/// CHECK: This account will not be checked by Anchorpub authority: UncheckedAccount<'info>,}#[account]pub struct Vault {token_account: Pubkey,authority: Pubkey,}
Adding Signer Authorization Checks
To validate that the authority account signed the transaction, add a signer
check within the instruction handler:
if !ctx.accounts.authority.is_signer {return Err(ProgramError::MissingRequiredSignature.into());}
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.
use anchor_lang::prelude::*;declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");#[program]pub mod secure_update{use super::*;...pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {if !ctx.accounts.authority.is_signer {return Err(ProgramError::MissingRequiredSignature.into());}ctx.accounts.vault.authority = ctx.accounts.new_authority.key();Ok(())}}#[derive(Accounts)]pub struct UpdateAuthority<'info> {#[account(mut,has_one = authority)]pub vault: Account<'info, Vault>,/// CHECK: This account will not be checked by Anchorpub new_authority: UncheckedAccount<'info>,/// CHECK: This account will not be checked by Anchorpub authority: UncheckedAccount<'info>,}#[account]pub struct Vault {token_account: Pubkey,authority: Pubkey,}
Use Anchor's Signer Account Type
Incorporating the
signer
check directly within the instruction handler logic can blur the separation
between account validation and instruction handler execution. To maintain this
separation, use Anchor's Signer account type. By changing the authority
account's type to Signer in the validation struct, Anchor automatically checks
at runtime that the specified account signed the transaction.
use anchor_lang::prelude::*;declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");#[program]pub mod secure_update{use super::*;...pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {ctx.accounts.vault.authority = ctx.accounts.new_authority.key();Ok(())}}#[derive(Accounts)]pub struct UpdateAuthority<'info> {#[account(mut,has_one = authority)]pub vault: Account<'info, Vault>,/// CHECK: This account will not be checked by Anchorpub new_authority: UncheckedAccount<'info>,pub authority: Signer<'info>,}#[account]pub struct Vault {token_account: Pubkey,authority: Pubkey,}
When you use the Signer type, no other ownership or type checks are
performed.
Using Anchor's #[account(signer)] Constraint
While the Signer account type is useful, it doesn't perform other ownership or
type checks, limiting its use in instruction handler logic.
Anchor's #[account(signer)]
constraint addresses this by verifying that the account signed the transaction
while allowing access to its underlying data.
For example, if you expect an account to be both a signer and a data source,
using the Signer type would require manual deserialization, and you wouldn't
benefit from automatic ownership and type checking. Instead, the
#[account(signer)] constraint allows you to access the data and ensure the
account signed the transaction.
In this example, you can safely interact with the data stored in the authority
account while ensuring that it signed the transaction.
use anchor_lang::prelude::*;declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");#[program]pub mod secure_update{use super::*;...pub fn update_authority(ctx: Context<UpdateAuthority>) -> Result<()> {ctx.accounts.vault.authority = ctx.accounts.new_authority.key();// access the data stored in authoritymsg!("Total number of depositors: {}", ctx.accounts.authority.num_depositors);Ok(())}}#[derive(Accounts)]pub struct UpdateAuthority<'info> {#[account(mut,has_one = authority)]pub vault: Account<'info, Vault>,/// CHECK: This account will not be checked by Anchorpub new_authority: UncheckedAccount<'info>,#[account(signer)]pub authority: Account<'info, AuthState>}#[account]pub struct Vault {token_account: Pubkey,authority: Pubkey,}#[account]pub struct AuthState{amount: u64,num_depositors: u64,num_vaults: u64}
Lab
In this lab, we'll create a simple program to demonstrate how a missing signer
check can allow an attacker to withdraw tokens that don't belong to them. This
program initializes a simplified token vault account and shows how the absence
of a signer check could result in the vault being drained.
1. Starter
To get started, download the starter code from the
starter branch of this repository.
The starter code includes a program with two instruction handlers and the
boilerplate setup for the test file.
The initialize_vault instruction handler sets up two new accounts: Vault and
TokenAccount. The Vault account is initialized using a Program Derived
Address (PDA) and stores the address of a token account and the vault's
authority. The vault PDA will be the authority of the token account, enabling
the program to sign off on token transfers.
The insecure_withdraw instruction handler transfers tokens from the vault
account's token account to a withdraw_destination token account. However, the
authority account in the InsecureWithdraw struct is of type
UncheckedAccount, a wrapper around AccountInfo that explicitly indicates the
account is unchecked.
Without a signer check, anyone can provide the public key of the authority
account that matches the authority stored on the vault account, and the
insecure_withdraw instruction handler will continue processing.
Although this example is somewhat contrived, as any DeFi program with a vault would be more sophisticated, it effectively illustrates how the lack of a signer check can lead to unauthorized token withdrawals.
use anchor_lang::prelude::*;use anchor_spl::token::{self, Mint, Token, TokenAccount};declare_id!("FeKh59XMh6BcN6UdekHnaFHsNH9NVE121GgDzSyYPKKS");pub const DISCRIMINATOR_SIZE: usize = 8;#[program]pub mod signer_authorization {use super::*;pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {ctx.accounts.vault.token_account = ctx.accounts.token_account.key();ctx.accounts.vault.authority = ctx.accounts.authority.key();Ok(())}pub fn insecure_withdraw(ctx: Context<InsecureWithdraw>) -> Result<()> {let amount = ctx.accounts.token_account.amount;let seeds = &[b"vault".as_ref(), &[ctx.bumps.vault]];let signer = [&seeds[..]];let cpi_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(),token::Transfer {from: ctx.accounts.token_account.to_account_info(),authority: ctx.accounts.vault.to_account_info(),to: ctx.accounts.withdraw_destination.to_account_info(),},&signer,);token::transfer(cpi_ctx, amount)?;Ok(())}}#[derive(Accounts)]pub struct InitializeVault<'info> {#[account(init,payer = authority,space = DISCRIMINATOR_SIZE + Vault::INIT_SPACE,seeds = [b"vault"],bump)]pub vault: Account<'info, Vault>,#[account(init,payer = authority,token::mint = mint,token::authority = vault,)]pub token_account: Account<'info, TokenAccount>,pub mint: Account<'info, Mint>,#[account(mut)]pub authority: Signer<'info>,pub token_program: Program<'info, Token>,pub system_program: Program<'info, System>,pub rent: Sysvar<'info, Rent>,}#[derive(Accounts)]pub struct InsecureWithdraw<'info> {#[account(seeds = [b"vault"],bump,has_one = token_account,has_one = authority)]pub vault: Account<'info, Vault>,#[account(mut)]pub token_account: Account<'info, TokenAccount>,#[account(mut)]pub withdraw_destination: Account<'info, TokenAccount>,pub token_program: Program<'info, Token>,/// CHECK: demo missing signer checkpub authority: UncheckedAccount<'info>,}#[account]#[derive(Default, InitSpace)]pub struct Vault {token_account: Pubkey,authority: Pubkey,}
2. Test insecure_withdraw Instruction Handler
The test file includes code to invoke the initialize_vault instruction
handler, using walletAuthority as the authority on the vault. The code then
mints 100 tokens to the vaultTokenAccount token account. Ideally, only the
walletAuthority key should be able to withdraw these 100 tokens from the
vault.
Next, we'll add a test to invoke insecure_withdraw on the program to
demonstrate that the current version allows a third party to withdraw those 100
tokens.
In the test, we'll use the walletAuthority public key as the authority
account but sign and send the transaction with a different keypair.
describe("Signer Authorization", () => {...it("performs insecure withdraw", async () => {try {const transaction = await program.methods.insecureWithdraw().accounts({vault: vaultPDA,tokenAccount: vaultTokenAccount.publicKey,withdrawDestination: unauthorizedWithdrawDestination,authority: walletAuthority.publicKey,}).transaction();await anchor.web3.sendAndConfirmTransaction(connection, transaction, [unauthorizedWallet,]);const tokenAccountInfo = await getAccount(connection,vaultTokenAccount.publicKey);expect(Number(tokenAccountInfo.amount)).to.equal(0);} catch (error) {console.error("Insecure withdraw failed:", error);throw error;}});})
Run anchor test to confirm that both transactions will be completed
successfully.
Signer Authorization✔ initializes vault and mints tokens (882ms)✔ performs insecure withdraw (435ms)
The insecure_withdraw instruction handler demonstrates a security
vulnerability. Since there is no signer check for the authority account, this
handler will transfer tokens from the vaultTokenAccount to the
unauthorizedWithdrawDestination, as long as the public key of the authority
account matches the walletAuthority.publicKey stored in the vault account's
authority field.
In the test, we use the unauthorizedWallet to sign the transaction, while
still specifying the walletAuthority.publicKey as the authority in the
instruction accounts. This mismatch between the signer and the specified
authority would normally cause a transaction to fail. However, due to the lack
of a proper signer check in the insecure_withdraw handler, the transaction
succeeds.
3. Add secure_withdraw Instruction Handler
To fix this issue, we'll create a new instruction handler called
secure_withdraw. This instruction handler will be identical to
insecure_withdraw, but we'll use the Signer type in the Accounts struct to
validate the authority account in the SecureWithdraw struct. If the
authority account isn't a signer on the transaction, the transaction should
fail with an error.
use anchor_lang::prelude::*;use anchor_spl::token::{self, Mint, Token, TokenAccount};declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");#[program]pub mod signer_authorization {use super::*;...pub fn secure_withdraw(ctx: Context<SecureWithdraw>) -> Result<()> {let amount = ctx.accounts.token_account.amount;let seeds = &[b"vault".as_ref(), &[ctx.bumps.vault]];let signer = [&seeds[..]];let cpi_ctx = CpiContext::new_with_signer(ctx.accounts.token_program.to_account_info(),token::Transfer {from: ctx.accounts.token_account.to_account_info(),authority: ctx.accounts.vault.to_account_info(),to: ctx.accounts.withdraw_destination.to_account_info(),},&signer,);token::transfer(cpi_ctx, amount)?;Ok(())}}#[derive(Accounts)]pub struct SecureWithdraw<'info> {#[account(seeds = [b"vault"],bump,has_one = token_account,has_one = authority)]pub vault: Account<'info, Vault>,#[account(mut)]pub token_account: Account<'info, TokenAccount>,#[account(mut)]pub withdraw_destination: Account<'info, TokenAccount>,pub token_program: Program<'info, Token>,pub authority: Signer<'info>,}
4. Test secure_withdraw Instruction Handler
With the new instruction handler in place, return to the test file to test the
secureWithdraw instruction handler. Invoke the secureWithdraw instruction
handler, using the walletAuthority.publicKey as the authority account, and
use the unauthorizedWallet keypair as the signer. Set the
unauthorizedWithdrawDestination as the withdraw destination.
Since the authority account is validated using the Signer type, the
transaction should fail with a signature verification error. This is because the
unauthorizedWallet is attempting to sign the transaction, but it doesn't match
the authority specified in the instruction (which is
walletAuthority.publicKey).
The test expects this transaction to fail, demonstrating that the secure withdraw function properly validates the signer. If the transaction unexpectedly succeeds, the test will throw an error indicating that the expected security check did not occur.
describe("Signer Authorization", () => {...it("fails to perform secure withdraw with incorrect signer", async () => {try {const transaction = await program.methods.secureWithdraw().accounts({vault: vaultPDA,tokenAccount: vaultTokenAccount.publicKey,withdrawDestination: unauthorizedWithdrawDestination,authority: walletAuthority.publicKey,}).transaction();await anchor.web3.sendAndConfirmTransaction(connection, transaction, [unauthorizedWallet,]);throw new Error("Expected transaction to fail, but it succeeded");} catch (error) {expect(error).to.be.an("error");console.log("Error message:", error.message);}});})
Run anchor test to see that the transaction now returns a signature
verification error.
signer-authorizationError message: Signature verification failed.Missing signature for public key [`GprrWv9r8BMxQiWea9MrbCyK7ig7Mj8CcseEbJhDDZXM`].✔ fails to perform secure withdraw with incorrect signer
This example shows how important it is to think through who should authorize instructions and ensure that each is a signer on the transaction.
To review the final solution code, you can find it on the
solution branch of the repository.
Challenge
Now that you've worked through the labs and challenges in this course, it's time to apply your knowledge in a practical setting. For this challenge and those that follow on security vulnerabilities, audit your own programs for the specific vulnerability discussed in each lesson.
Steps
-
Audit Your Program or Find an Open Source Project:
- Begin by auditing your own code for missing signer checks, or find an open source Solana program to audit. A great place to start is with the program examples repository.
-
Look for Signer Check Issues:
- Focus on instruction handlers where signer authorization is crucial, especially those that transfer tokens or modify sensitive account data.
- Review the program for any
UncheckedAccounttypes where signer validation should be enforced. - Ensure that any accounts that should require user authorization are defined
as
Signerin the instruction handler.
-
Patch or Report:
- If you find a bug in your own code, fix it by using the
Signertype for accounts that require signer validation. - If the issue exists in an open source project, notify the project maintainers or submit a pull request.
- If you find a bug in your own code, fix it by using the
Completed the lab?
After completing the challenge, push your code to GitHub and tell us what you thought of this lesson!