Solana Program Security Best Practices: The Complete Developer Guide
Secure your Solana programs with battle-tested patterns that prevent exploits. Missing a single signer check can drain millions from your protocol. This comprehensive guide covers the 10 most critical security vulnerabilities in Solana programs and provides working code examples to prevent each one.
TL;DR
- **Always validate signers** - Missing signer checks are the #1 cause of exploits
- **Verify account ownership** - Check that accounts belong to expected programs
- **Use PDAs correctly** - Validate seeds and bump seeds to prevent account spoofing
- **Implement proper access control** - Role-based permissions prevent unauthorized actions
- **Handle arithmetic safely** - Use checked math to prevent overflow/underflow attacks
- **Close accounts properly** - Prevent reinitialization attacks with proper cleanup
Prerequisites
Before diving into security patterns, you should have:
- Intermediate Rust knowledge
- Experience building Solana programs with Anchor
- Understanding of Solana's account model
- Familiarity with PDAs (Program Derived Addresses)
Package versions used in this guide:
- `anchor-lang`: 0.32.1
- `solana-program`: 2.1.0
- `@solana/web3.js`: 1.98.4
Why Solana Security Matters
Solana's unique architecture creates security challenges that differ from EVM chains. The account model, parallel execution, and rent system all introduce attack vectors that developers must understand. In 2024 alone, over $100M was lost to Solana program exploits, with most traced to preventable vulnerabilities.
The 10 Critical Security Vulnerabilities
1. Missing Signer Verification
The Problem: Failing to verify that required accounts have signed the transaction allows anyone to execute privileged operations.
Vulnerable Code:
// VULNERABLE: No signer check!
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub authority: AccountInfo<'info>, // Missing Signer constraint
#[account(mut)]
pub destination: AccountInfo<'info>,
}Secure Code:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
#[account(
mut,
has_one = authority @ ErrorCode::UnauthorizedAuthority
)]
pub vault: Account<'info, Vault>,
// SECURE: Signer constraint ensures this account signed the tx
#[account(mut)]
pub authority: Signer<'info>,
/// CHECK: Validated as destination for funds
#[account(mut)]
pub destination: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct Vault {
pub authority: Pubkey,
pub balance: u64,
}
#[error_code]
pub enum ErrorCode {
#[msg("Unauthorized authority for this vault")]
UnauthorizedAuthority,
}2. Missing Owner Validation
The Problem: Not verifying an account's owner allows attackers to pass fake accounts controlled by malicious programs.
Vulnerable Code:
// VULNERABLE: No owner check!
pub fn process_payment(ctx: Context<ProcessPayment>) -> Result<()> {
let token_account = &ctx.accounts.user_token_account;
// Attacker can pass an account owned by a malicious program
// that mimics token account structure
let balance = token_account.amount; // Could be fake data!
Ok(())
}Secure Code:
use anchor_lang::prelude::*;
use anchor_spl::token::{Token, TokenAccount};
#[derive(Accounts)]
pub struct ProcessPayment<'info> {
// SECURE: Account<'info, TokenAccount> automatically validates
// the account is owned by the Token program
#[account(
mut,
constraint = user_token_account.owner == user.key() @ ErrorCode::InvalidTokenOwner
)]
pub user_token_account: Account<'info, TokenAccount>,
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[error_code]
pub enum ErrorCode {
#[msg("Token account owner mismatch")]
InvalidTokenOwner,
}3. PDA Validation Failures
The Problem: PDAs must be validated with their seeds and bump to prevent attackers from substituting different accounts.
Vulnerable Code:
// VULNERABLE: PDA not validated against seeds!
#[derive(Accounts)]
pub struct ClaimReward<'info> {
#[account(mut)]
pub reward_account: Account<'info, RewardState>, // No seed validation
pub user: Signer<'info>,
}Secure Code:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct ClaimReward<'info> {
// SECURE: Seeds constraint validates the PDA derivation
#[account(
mut,
seeds = [b"reward", user.key().as_ref()],
bump = reward_account.bump,
constraint = !reward_account.claimed @ ErrorCode::AlreadyClaimed
)]
pub reward_account: Account<'info, RewardState>,
pub user: Signer<'info>,
}
#[account]
pub struct RewardState {
pub user: Pubkey,
pub amount: u64,
pub claimed: bool,
pub bump: u8,
}
#[error_code]
pub enum ErrorCode {
#[msg("Reward already claimed")]
AlreadyClaimed,
}4. Integer Overflow/Underflow
The Problem: Arithmetic operations can overflow or underflow, leading to incorrect calculations that attackers exploit.
Vulnerable Code:
// VULNERABLE: Unchecked arithmetic!
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
vault.total_deposits = vault.total_deposits + amount; // Can overflow!
vault.user_shares = vault.total_deposits * 100 / vault.total_supply; // Division by zero possible!
Ok(())
}Secure Code:
use anchor_lang::prelude::*;
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// SECURE: Use checked arithmetic
vault.total_deposits = vault.total_deposits
.checked_add(amount)
.ok_or(ErrorCode::ArithmeticOverflow)?;
// SECURE: Check for division by zero
require!(vault.total_supply > 0, ErrorCode::DivisionByZero);
vault.user_shares = vault.total_deposits
.checked_mul(100)
.ok_or(ErrorCode::ArithmeticOverflow)?
.checked_div(vault.total_supply)
.ok_or(ErrorCode::DivisionByZero)?;
Ok(())
}
#[error_code]
pub enum ErrorCode {
#[msg("Arithmetic overflow detected")]
ArithmeticOverflow,
#[msg("Division by zero")]
DivisionByZero,
}5. Reinitialization Attacks
The Problem: Allowing accounts to be reinitialized lets attackers reset state to bypass security checks.
Vulnerable Code:
// VULNERABLE: Can be called multiple times!
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let state = &mut ctx.accounts.state;
state.authority = ctx.accounts.authority.key();
state.is_initialized = true;
Ok(())
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub state: Account<'info, State>, // No init check!
pub authority: Signer<'info>,
}Secure Code:
use anchor_lang::prelude::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let state = &mut ctx.accounts.state;
state.authority = ctx.accounts.authority.key();
state.bump = ctx.bumps.state;
Ok(())
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// SECURE: `init` constraint ensures account is created fresh
#[account(
init,
payer = authority,
space = 8 + State::INIT_SPACE,
seeds = [b"state", authority.key().as_ref()],
bump
)]
pub state: Account<'info, State>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct State {
pub authority: Pubkey,
pub bump: u8,
}6. Missing Account Close Cleanup
The Problem: Improperly closing accounts leaves data in memory that can be exploited through revival attacks.
Vulnerable Code:
// VULNERABLE: Account data persists after close!
pub fn close_account(ctx: Context<CloseAccount>) -> Result<()> {
let lamports = ctx.accounts.account_to_close.to_account_info().lamports();
**ctx.accounts.account_to_close.to_account_info().lamports.borrow_mut() = 0;
**ctx.accounts.destination.lamports.borrow_mut() += lamports;
// Data not zeroed - can be revived in same transaction!
Ok(())
}Secure Code:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct CloseAccount<'info> {
// SECURE: `close` constraint properly zeroes data and transfers lamports
#[account(
mut,
close = destination,
has_one = authority @ ErrorCode::UnauthorizedClose
)]
pub account_to_close: Account<'info, UserState>,
pub authority: Signer<'info>,
/// CHECK: Destination for rent refund
#[account(mut)]
pub destination: AccountInfo<'info>,
}
#[account]
pub struct UserState {
pub authority: Pubkey,
pub data: u64,
}
#[error_code]
pub enum ErrorCode {
#[msg("Unauthorized to close this account")]
UnauthorizedClose,
}7. Type Cosplay Attacks
The Problem: Different account types with similar data layouts can be confused, allowing attackers to substitute malicious accounts.
Vulnerable Code:
// VULNERABLE: No discriminator check!
#[account]
pub struct UserAccount {
pub balance: u64, // offset 0
pub authority: Pubkey, // offset 8
}
#[account]
pub struct AdminAccount {
pub balance: u64, // offset 0 - same as UserAccount!
pub authority: Pubkey, // offset 8 - same as UserAccount!
pub is_admin: bool,
}Secure Code:
use anchor_lang::prelude::*;
// SECURE: Anchor automatically adds 8-byte discriminators
// based on account type name hash
#[account]
#[derive(InitSpace)]
pub struct UserAccount {
pub balance: u64,
pub authority: Pubkey,
pub account_type: AccountType, // Explicit type marker
}
#[account]
#[derive(InitSpace)]
pub struct AdminAccount {
pub balance: u64,
pub authority: Pubkey,
pub is_admin: bool,
pub account_type: AccountType, // Explicit type marker
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, InitSpace)]
pub enum AccountType {
User,
Admin,
}
// In your instruction handler:
pub fn process_admin_action(ctx: Context<AdminAction>) -> Result<()> {
require!(
ctx.accounts.admin.account_type == AccountType::Admin,
ErrorCode::NotAdminAccount
);
// ... admin logic
Ok(())
}8. Missing Rent Exemption Checks
The Problem: Accounts below rent-exempt threshold can be garbage collected, leading to unexpected state loss.
Secure Code:
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct CreateAccount<'info> {
#[account(
init,
payer = payer,
// SECURE: Anchor calculates rent-exempt minimum automatically
space = 8 + DataAccount::INIT_SPACE
)]
pub data_account: Account<'info, DataAccount>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
// For manual account creation, always check rent exemption:
pub fn manual_create(ctx: Context<ManualCreate>, lamports: u64) -> Result<()> {
let rent = Rent::get()?;
let required_lamports = rent.minimum_balance(DataAccount::INIT_SPACE + 8);
require!(
lamports >= required_lamports,
ErrorCode::InsufficientRentExemption
);
Ok(())
}9. Cross-Program Invocation Security
The Problem: CPIs can be exploited if the callee program is not validated or if signer seeds are leaked.
Secure Code:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
pub fn secure_cpi_transfer(ctx: Context<SecureCpiTransfer>, amount: u64) -> Result<()> {
// SECURE: Validate the program being called
require_keys_eq!(
ctx.accounts.system_program.key(),
anchor_lang::system_program::ID,
ErrorCode::InvalidProgram
);
// SECURE: Use PDA signer with validated seeds
let authority_seeds = &[
b"vault_authority".as_ref(),
&[ctx.accounts.vault.authority_bump],
];
let signer_seeds = &[&authority_seeds[..]];
let cpi_context = CpiContext::new_with_signer(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.vault_authority.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
},
signer_seeds,
);
transfer(cpi_context, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct SecureCpiTransfer<'info> {
#[account(
seeds = [b"vault"],
bump = vault.bump
)]
pub vault: Account<'info, Vault>,
/// CHECK: PDA authority for vault
#[account(
mut,
seeds = [b"vault_authority"],
bump = vault.authority_bump
)]
pub vault_authority: AccountInfo<'info>,
/// CHECK: Destination for transfer
#[account(mut)]
pub destination: AccountInfo<'info>,
pub admin: Signer<'info>,
pub system_program: Program<'info, System>,
}10. Arbitrary CPI Vulnerability
The Problem: Allowing users to specify target programs enables calling malicious programs that impersonate legitimate ones.
Vulnerable Code:
// VULNERABLE: Arbitrary program execution!
pub fn call_program(ctx: Context<CallProgram>, data: Vec<u8>) -> Result<()> {
let ix = Instruction {
program_id: ctx.accounts.target_program.key(), // User-controlled!
accounts: vec![],
data,
};
invoke(&ix, &[])?;
Ok(())
}Secure Code:
use anchor_lang::prelude::*;
use anchor_spl::token::{Token, Transfer, transfer};
// SECURE: Only call known, validated programs
pub fn secure_token_transfer(ctx: Context<SecureTransfer>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
// SECURE: Program<'info, Token> validates the program ID
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
transfer(cpi_ctx, amount)?;
Ok(())
}
#[derive(Accounts)]
pub struct SecureTransfer<'info> {
#[account(mut)]
pub from: Account<'info, anchor_spl::token::TokenAccount>,
#[account(mut)]
pub to: Account<'info, anchor_spl::token::TokenAccount>,
pub authority: Signer<'info>,
// SECURE: Type-safe program reference
pub token_program: Program<'info, Token>,
}Security Checklist for Audits
Use this checklist before deploying any Solana program:
## Pre-Deployment Security Checklist
### Account Validation
- [ ] All required signers have `Signer<'info>` type
- [ ] Account ownership validated with `Account<'info, T>` or manual checks
- [ ] PDAs validated with `seeds` and `bump` constraints
- [ ] `has_one` constraints used for authority checks
### Arithmetic Safety
- [ ] All arithmetic uses `checked_*` methods
- [ ] Division by zero checks in place
- [ ] Casting between types is safe (u64 to u128 before multiplication)
### State Management
- [ ] `init` constraint used for initialization
- [ ] `close` constraint used for account closure
- [ ] No reinitialization vulnerabilities
- [ ] Proper discriminators on all account types
### CPI Security
- [ ] Target programs explicitly validated
- [ ] PDA signer seeds derived securely
- [ ] No arbitrary CPI execution
### Access Control
- [ ] Role-based permissions implemented
- [ ] Admin functions properly protected
- [ ] Time-based restrictions where needed
### Additional
- [ ] Rent exemption verified for all accounts
- [ ] Error codes are descriptive and unique
- [ ] security.txt implemented for bug bountyImplementing security.txt
Add a security.txt to your program for responsible disclosure:
#[cfg(not(feature = "no-entrypoint"))]
use solana_security_txt::security_txt;
#[cfg(not(feature = "no-entrypoint"))]
security_txt! {
name: "Your Protocol Name",
project_url: "https://yourprotocol.com",
contacts: "email:security@yourprotocol.com,discord:security#1234",
policy: "https://yourprotocol.com/security-policy",
preferred_languages: "en",
source_code: "https://github.com/yourorg/yourprotocol",
auditors: "OtterSec, Neodyme"
}Common Pitfalls to Avoid
- **Trusting client-side validation** - Always re-validate on-chain
- **Assuming account order** - Validate accounts by key, not position
- **Hardcoding mainnet addresses** - Use environment-specific configs
- **Ignoring upgrade authority** - Consider immutable programs for trustless protocols
- **Missing event emissions** - Log critical state changes for monitoring
Next Steps and Resources
Immediate Actions:
- Audit your existing programs against this checklist
- Implement security.txt for responsible disclosure
- Set up monitoring for your program's accounts
Further Reading:
- [Sealevel Attacks Repository](https://github.com/coral-xyz/sealevel-attacks)
- [Solana Security Course](https://solana.com/courses/program-security)
- [Anchor Security Best Practices](https://www.anchor-lang.com/docs/security)
Professional Audits:
For production deployments handling significant value, engage professional auditors like OtterSec, Neodyme, or Halborn.
Need help securing your Solana program? Builderz specializes in security-focused Solana development. Contact us for a security review.



