14 KiB
Security Best Practices for Solana Development
Essential security principles and defensive programming patterns for building secure Solana programs with Anchor or native Rust.
Note: This guide focuses on defensive programming during development. For comprehensive security audits, vulnerability analysis, and attack vectors, use the
solana-securityskill.
Table of Contents
- Security Mindset
- Core Security Rules
- Account Validation
- Arithmetic Safety
- PDA Security
- CPI Security
- Common Pitfalls
- Pre-Deployment Checklist
Security Mindset
Think Like an Attacker
Fundamental principle: Attackers control everything they send to your program.
- ❌ Don't assume: "Users won't do that"
- ❌ Don't assume: "The client validates this"
- ❌ Don't assume: "This account must be correct"
- ✅ Do validate: Every account, every parameter, every assumption
You Control Nothing
Once deployed, your program:
- Cannot control which accounts are passed in
- Cannot control instruction data
- Cannot control timing or ordering
- Cannot prevent malicious clients
Your only control: How your program validates and handles inputs.
Core Security Rules
Rule 1: Validate Every Account
Always verify:
Anchor:
#[derive(Accounts)]
pub struct SecureInstruction<'info> {
// ✅ Signer required
pub authority: Signer<'info>,
// ✅ Owner validation + relationship
#[account(
mut,
has_one = authority, // vault.authority == authority.key()
)]
pub vault: Account<'info, Vault>,
// ✅ Program ID validation
pub token_program: Program<'info, Token>,
}
Native Rust:
// ✅ Signer check
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ✅ Owner check
if vault.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// ✅ Program ID check
if *token_program.key != spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
Rule 2: Use Checked Arithmetic
Never use:
+,-,*,/operators directlysaturating_*methods (hide errors)unwrap()orexpect()on arithmetic
Always use:
// ✅ Checked operations
let total = balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
let remaining = total
.checked_sub(withdrawal)
.ok_or(ErrorCode::InsufficientFunds)?;
let product = price
.checked_mul(quantity)
.ok_or(ErrorCode::Overflow)?;
let share = total
.checked_div(parts)
.ok_or(ErrorCode::DivisionByZero)?;
Rule 3: Validate PDAs Properly
Anchor:
#[derive(Accounts)]
pub struct SecurePDA<'info> {
// ✅ Use canonical bump
#[account(
seeds = [b"vault", user.key().as_ref()],
bump, // Automatically validates canonical bump
)]
pub vault: Account<'info, Vault>,
}
Native Rust:
// ✅ Find canonical bump
let (expected_pda, bump) = Pubkey::find_program_address(
&[b"vault", user.key.as_ref()],
program_id,
);
// ✅ Validate PDA matches
if expected_pda != *vault.key {
return Err(ProgramError::InvalidSeeds);
}
// Store bump for future use with create_program_address
Rule 4: Secure Cross-Program Invocations
Anchor:
// ✅ Program type validation
pub token_program: Program<'info, Token>,
// ✅ Use CpiContext
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
transfer_accounts,
);
token::transfer(cpi_ctx, amount)?;
Native Rust:
// ✅ Validate program ID before CPI
if *token_program.key != spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
// ✅ Build instruction safely
let ix = spl_token::instruction::transfer(
token_program.key,
source.key,
destination.key,
authority.key,
&[],
amount,
)?;
invoke(&ix, &[source, destination, authority, token_program])?;
Rule 5: Handle Errors Gracefully
Never:
// ❌ Don't panic or unwrap
let value = some_operation().unwrap();
// ❌ Don't ignore errors
some_operation();
Always:
// ✅ Propagate errors
let value = some_operation()
.ok_or(ErrorCode::OperationFailed)?;
// ✅ Or handle explicitly
let value = match some_operation() {
Some(v) => v,
None => return Err(ErrorCode::OperationFailed.into()),
};
Account Validation
Essential Checks
For every account, verify:
- Signer - Does this account need to sign?
- Owner - Who owns this account? Is it our program?
- Writable - Does this need
mut? - Type - Is this the right account type?
- Relationships - Do related accounts match?
Validation Pattern
// Native Rust comprehensive validation
pub fn validate_account(
account: &AccountInfo,
expected_owner: &Pubkey,
must_be_signer: bool,
must_be_writable: bool,
) -> ProgramResult {
// Check signer
if must_be_signer && !account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// Check owner
if account.owner != expected_owner {
return Err(ProgramError::IllegalOwner);
}
// Check writable
if must_be_writable && !account.is_writable {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}
Arithmetic Safety
Common Vulnerabilities
Overflow example:
// ❌ VULNERABLE: Can overflow
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
ctx.accounts.vault.balance = ctx.accounts.vault.balance + amount;
Ok(())
}
// If vault.balance = u64::MAX - 100 and amount = 200
// Result wraps to 99, losing 18.4 quintillion tokens!
Fix:
// ✅ SECURE: Checked arithmetic
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
ctx.accounts.vault.balance = ctx.accounts.vault.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
Precision Loss
Multiply before divide:
// ❌ WRONG: Loses precision
let fee = amount / 100; // 1.5% becomes 1%
// ✅ CORRECT: Multiply first
let fee = amount
.checked_mul(15)
.and_then(|v| v.checked_div(1000))
.ok_or(ErrorCode::Overflow)?; // Exact 1.5%
PDA Security
Use Canonical Bumps
Always find the canonical bump:
// ✅ Find canonical bump
let (pda, bump) = Pubkey::find_program_address(
&[b"vault", user.key.as_ref()],
program_id,
);
// Store bump in account for later use
vault.bump = bump;
Never hardcode or accept bumps from clients:
// ❌ VULNERABLE: Accepts any bump
#[derive(Accounts)]
pub struct BadPDA<'info> {
#[account(seeds = [b"vault"], bump = user_provided_bump)]
pub vault: Account<'info, Vault>,
}
Unique Seeds
Ensure seeds create unique PDAs:
// ✅ GOOD: Unique per user
seeds = [b"vault", user.key().as_ref()]
// ❌ BAD: Same PDA for everyone
seeds = [b"vault"]
CPI Security
Validate Target Programs
Never accept arbitrary program IDs:
// ❌ VULNERABLE
pub fn bad_cpi(ctx: Context<BadCPI>) -> Result<()> {
// Attacker can pass any program!
let cpi_ctx = CpiContext::new(
ctx.accounts.any_program.to_account_info(),
accounts,
);
// ... make CPI
}
// ✅ SECURE
#[derive(Accounts)]
pub struct SecureCPI<'info> {
pub token_program: Program<'info, Token>, // Type-checked!
}
Reload Accounts After CPIs
If a CPI might modify an account you're using:
// ✅ Reload account after external call
let balance_before = token_account.amount;
// Make CPI that might change the account
token::transfer(cpi_ctx, amount)?;
// Reload to get fresh data
token_account.reload()?;
let balance_after = token_account.amount;
Common Pitfalls
1. init_if_needed (Anchor)
Dangerous pattern:
// ❌ Can be exploited
#[account(init_if_needed, payer = user, space = 8 + 32)]
pub config: Account<'info, Config>,
Problem: Attacker creates the account first with malicious data.
Fix:
// ✅ Use init or check if exists
#[account(init, payer = user, space = 8 + 32)]
pub config: Account<'info, Config>,
// Or explicitly check
if config.is_initialized {
return Err(ErrorCode::AlreadyInitialized.into());
}
2. Missing Signer Checks
// ❌ Anyone can withdraw!
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.vault.balance -= amount;
Ok(())
}
// ✅ Authority must sign
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>, // Required!
}
3. Account Confusion
// ❌ No validation - any accounts work!
pub struct Transfer<'info> {
pub from: Account<'info, TokenAccount>,
pub to: Account<'info, TokenAccount>,
}
// ✅ Validate relationships
pub struct Transfer<'info> {
#[account(
mut,
constraint = from.owner == authority.key(),
constraint = from.mint == to.mint,
)]
pub from: Account<'info, TokenAccount>,
#[account(mut)]
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
}
4. Unchecked Account Types
// ❌ Uses raw AccountInfo - no type safety
pub fn bad(ctx: Context<Bad>) -> Result<()> {
let data = ctx.accounts.account.try_borrow_data()?;
// What if attacker passes wrong account type?
}
// ✅ Use typed Account
pub fn good(ctx: Context<Good>) -> Result<()> {
// Anchor verifies discriminator automatically
let vault = &ctx.accounts.vault;
}
Pre-Deployment Checklist
Before deploying to mainnet:
Code Review
- All accounts validated (signer, owner, writable)
- All arithmetic uses
checked_*methods - All PDAs use canonical bumps
- All CPIs validate target programs
- No
unwrap()orexpect()in production code - No
init_if_neededwithout additional checks - All error cases handled gracefully
Testing
- Unit tests cover all instructions
- Integration tests cover instruction interactions
- Edge cases tested (zero amounts, max values, overflow)
- Error conditions tested (invalid accounts, unauthorized access)
- Fuzz testing with Trident (if possible)
Security Audit
- Internal code review completed
- External security audit (recommended for >$100k TVL)
- Use
solana-securityskill for systematic review - All critical/high severity findings resolved
- Medium findings assessed and documented
Documentation
- Account structures documented
- Instruction requirements documented
- Known limitations documented
- Upgrade strategy documented
- Emergency procedures documented
Deployment
- Tested on devnet extensively
- Tested on mainnet-beta with small amounts
- Upgrade authority secured (multisig recommended)
- Monitoring and alerts configured
- Emergency pause mechanism (if applicable)
When to Use the Security Skill
Use the solana-security skill for:
- 🔍 Comprehensive security audits - Systematic review of entire codebase
- 🐛 Vulnerability analysis - Identifying exploit scenarios
- 📋 Security checklists - Category-by-category validation
- ⚠️ Attack vectors - Understanding how programs can be exploited
- 🛡️ Framework-specific patterns - Anchor vs native Rust security
- 📚 Vulnerability databases - Learning from past exploits
Use this skill (solana-development) for:
- 💻 Building programs - Implementation guidance
- ✅ Defensive programming - Secure coding patterns
- 🏗️ Development workflows - Testing, deployment, optimization
- 📖 Framework learning - Anchor and native Rust how-tos
Quick Security Reference
Anchor Security Checklist
#[derive(Accounts)]
pub struct Secure<'info> {
// ✅ Signer
pub authority: Signer<'info>,
// ✅ Validation + relationships
#[account(
mut,
has_one = authority,
seeds = [b"vault", user.key().as_ref()],
bump,
)]
pub vault: Account<'info, Vault>,
// ✅ Program validation
pub token_program: Program<'info, Token>,
}
pub fn secure_fn(ctx: Context<Secure>, amount: u64) -> Result<()> {
// ✅ Checked arithmetic
ctx.accounts.vault.balance = ctx.accounts.vault.balance
.checked_add(amount)
.ok_or(ErrorCode::Overflow)?;
Ok(())
}
Native Rust Security Checklist
pub fn secure_fn(
program_id: &Pubkey,
accounts: &[AccountInfo],
amount: u64,
) -> ProgramResult {
let accounts = &mut accounts.iter();
let authority = next_account_info(accounts)?;
let vault = next_account_info(accounts)?;
// ✅ Signer check
if !authority.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
// ✅ Owner check
if vault.owner != program_id {
return Err(ProgramError::IllegalOwner);
}
// ✅ PDA validation
let (expected_pda, _) = Pubkey::find_program_address(
&[b"vault", authority.key.as_ref()],
program_id,
);
if *vault.key != expected_pda {
return Err(ProgramError::InvalidSeeds);
}
// ✅ Deserialize
let mut vault_data = Vault::try_from_slice(&vault.data.borrow())?;
// ✅ Checked arithmetic
vault_data.balance = vault_data.balance
.checked_add(amount)
.ok_or(ProgramError::ArithmeticOverflow)?;
// ✅ Serialize back
vault_data.serialize(&mut &mut vault.data.borrow_mut()[..])?;
Ok(())
}
Remember
Security is not optional. Every line of code is a potential vulnerability. Validate everything, trust nothing, and when in doubt, use the solana-security skill for a comprehensive audit.