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
Signer
account 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_signer
property 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 Anchor
pub new_authority: UncheckedAccount<'info>,
/// CHECK: This account will not be checked by Anchor
pub 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 Anchor
pub new_authority: UncheckedAccount<'info>,
/// CHECK: This account will not be checked by Anchor
pub 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 Anchor
pub 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 authority
msg!("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 Anchor
pub 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 check
pub 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-authorization
Error 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
UncheckedAccount
types where signer validation should be enforced. - Ensure that any accounts that should require user authorization are defined
as
Signer
in the instruction handler.
-
Patch or Report:
- If you find a bug in your own code, fix it by using the
Signer
type 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
After completing the challenge, push your code to GitHub and tell us what you thought of this lesson!