Solana Program Security Checklist: 14 Critical Checks Before You Deploy to Mainnet
A battle-tested checklist distilled from $500M+ in Solana exploits — from missing signer checks to PDA seed collisions, with Anchor and native Rust code examples for every fix.
Why Another Solana Security Checklist?
Between Wormhole's $325M signer validation failure, Mango Markets' $115M oracle manipulation, and a steady stream of smaller exploits draining Solana DeFi protocols throughout 2025, one pattern is painfully clear: most Solana exploits are caused by a handful of recurring mistakes.
The Solana programming model is fundamentally different from EVM. Accounts are passed in by callers. Programs are stateless. There's no msg.sender equivalent baked into the runtime. Every validation must be explicit, and every missing check is a potential exploit vector.
This checklist isn't theoretical. Each item maps to a real exploit or a pattern we've seen fail in production audits. Use it before every mainnet deployment.
The Checklist
1. ✅ Signer Verification
The Bug: Not checking AccountInfo::is_signer on accounts that authorize privileged operations.
Why It Kills: Without signer verification, anyone can call admin functions, transfer funds, or modify protocol state by simply passing the right account addresses — without proving they control those accounts.
The Fix (Anchor):
#[derive(Accounts)]
pub struct UpdateConfig<'info> {
#[account(mut)]
pub config: Account<'info, ProtocolConfig>,
// Anchor enforces is_signer automatically
pub authority: Signer<'info>,
#[account(
constraint = config.authority == authority.key()
@ ErrorCode::Unauthorized
)]
pub _authority_check: Account<'info, ProtocolConfig>,
}
The Fix (Native):
if !authority_info.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}
Real-World Failure: The Wormhole bridge exploit ($325M) exploited a missing signer check that allowed forged guardian signatures.
2. ✅ Account Owner Verification
The Bug: Not validating AccountInfo::owner matches the expected program ID.
Why It Kills: Attackers can create accounts with arbitrary data under a program they control, then pass those spoofed accounts to your program. Without owner verification, your program deserializes attacker-controlled data as trusted state.
The Fix (Anchor):
// Anchor's Account<'info, T> auto-verifies owner
#[account(
constraint = vault.owner == expected_program.key()
@ ErrorCode::InvalidOwner
)]
pub vault: Account<'info, TokenAccount>,
The Fix (Native):
if token_account_info.owner != &spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
3. ✅ PDA Seed Validation and Bump Canonicalization
The Bug: Using arbitrary bumps or failing to constrain PDA seeds, allowing attackers to derive alternative valid PDAs.
Why It Kills: If you accept any valid bump instead of the canonical bump, attackers may find a different (seed, bump) pair that passes create_program_address but maps to a different — potentially attacker-controlled — account.
The Fix:
#[derive(Accounts)]
pub struct ClaimReward<'info> {
#[account(
mut,
seeds = [b"reward", user.key().as_ref()],
bump = reward_account.bump, // stored canonical bump
)]
pub reward_account: Account<'info, RewardState>,
pub user: Signer<'info>,
}
Best Practice: Always store the canonical bump at initialization using find_program_address, and verify it on every subsequent access. Never let callers pass in arbitrary bump values.
4. ✅ Account Type Cosplay Prevention
The Bug: Deserializing an account without verifying its type discriminator, allowing one account type to be interpreted as another.
Why It Kills: Different account structs may have compatible memory layouts. An attacker could pass a UserProfile account where a VaultConfig is expected if both start with similar fields.
The Fix:
// Anchor handles this with 8-byte discriminators automatically.
// For native programs, add an explicit type tag:
#[repr(u8)]
pub enum AccountType {
Uninitialized = 0,
VaultConfig = 1,
UserProfile = 2,
RewardState = 3,
}
// Check at deserialization:
let account_type = data[0];
if account_type != AccountType::VaultConfig as u8 {
return Err(ProgramError::InvalidAccountData);
}
5. ✅ Reinitialization Protection
The Bug: Allowing an already-initialized account to be initialized again, overwriting existing state.
Why It Kills: An attacker reinitializes a vault account, setting themselves as the new authority while the vault still holds funds.
The Fix (Anchor):
#[account(
init, // Anchor's init checks account is empty
payer = payer,
space = 8 + VaultState::INIT_SPACE,
seeds = [b"vault", authority.key().as_ref()],
bump,
)]
pub vault: Account<'info, VaultState>,
The Fix (Native):
let vault_data = vault_info.try_borrow_data()?;
if vault_data[0] != AccountType::Uninitialized as u8 {
return Err(ProgramError::AccountAlreadyInitialized);
}
6. ✅ Integer Overflow/Underflow Guards
The Bug: Arithmetic operations that silently wrap around, producing incorrect values.
Why It Kills: Wrapping subtraction can turn a small balance into u64::MAX. Wrapping addition can reset accumulated rewards to near-zero.
The Fix:
// Use checked arithmetic EVERYWHERE
let new_balance = balance
.checked_add(deposit_amount)
.ok_or(ErrorCode::MathOverflow)?;
let fee = amount
.checked_mul(fee_bps as u64)
.ok_or(ErrorCode::MathOverflow)?
.checked_div(10_000)
.ok_or(ErrorCode::MathOverflow)?;
// Or use Anchor's require! macro
require!(
vault.total_deposits.checked_add(amount).is_some(),
ErrorCode::MathOverflow
);
Rust Note: Rust panics on overflow in debug builds but wraps in release builds. Solana programs are compiled in release mode. Always use checked math.
7. ✅ Closing Account Cleanup (Rent Reclaim Attacks)
The Bug: When closing an account, not zeroing data and not handling the case where an account can be "revived" in the same transaction.
Why It Kills: An attacker closes an account (reclaiming rent), then re-creates it in the same transaction with different data, bypassing initialization checks. The original lamports are gone but the attacker now controls the account state.
The Fix:
#[derive(Accounts)]
pub struct CloseVault<'info> {
#[account(
mut,
close = authority, // Anchor zeros data + transfers lamports
has_one = authority,
)]
pub vault: Account<'info, VaultState>,
#[account(mut)]
pub authority: Signer<'info>,
}
For native programs, manually zero the data, transfer all lamports, and set the account owner to the system program:
// Zero the data
let mut data = account_info.try_borrow_mut_data()?;
data.fill(0);
// Transfer lamports
let dest_lamports = dest_info.lamports();
**dest_info.lamports.borrow_mut() = dest_lamports
.checked_add(account_info.lamports())
.ok_or(ProgramError::ArithmeticOverflow)?;
**account_info.lamports.borrow_mut() = 0;
8. ✅ CPI (Cross-Program Invocation) Privilege Escalation
The Bug: Not validating the target program ID in CPI calls, or allowing arbitrary programs to be passed in the accounts list.
Why It Kills: Attackers pass their own malicious program where your program expects the SPL Token program. Your program invokes the attacker's code with PDA-signed authority.
The Fix:
#[derive(Accounts)]
pub struct Transfer<'info> {
// Anchor validates this IS the token program
pub token_program: Program<'info, Token>,
// For native:
// Always hardcode or validate program IDs
}
// In CPI calls, verify explicitly:
if token_program.key() != &spl_token::id() {
return Err(ProgramError::IncorrectProgramId);
}
9. ✅ Remaining Accounts Validation
The Bug: Using ctx.remaining_accounts without validating each account's owner, signer status, or expected address.
Why It Kills: remaining_accounts is a common pattern for variable-length account lists (multi-hop swaps, batch operations). Without validation, each unverified account is an injection point.
The Fix:
for account in ctx.remaining_accounts.iter() {
// Verify owner
require!(
account.owner == &spl_token::id(),
ErrorCode::InvalidTokenAccount
);
// Verify it's a known/expected account
let token_account = TokenAccount::try_deserialize(
&mut &account.data.borrow()[..]
)?;
require!(
token_account.mint == expected_mint.key(),
ErrorCode::InvalidMint
);
}
10. ✅ Duplicate Account Detection
The Bug: Not checking that accounts in the instruction are distinct when they should be.
Why It Kills: If a user passes the same account as both source and destination in a transfer, or the same account as both user_a and user_b in a swap, the logic may credit them twice or produce undefined behavior.
The Fix:
#[derive(Accounts)]
pub struct Swap<'info> {
#[account(mut)]
pub token_account_a: Account<'info, TokenAccount>,
#[account(
mut,
constraint = token_account_a.key() != token_account_b.key()
@ ErrorCode::DuplicateAccounts
)]
pub token_account_b: Account<'info, TokenAccount>,
}
11. ✅ Oracle Price Staleness and Manipulation Checks
The Bug: Using oracle prices without checking freshness, confidence intervals, or status.
Why It Kills: Stale prices during network congestion can be wildly different from actual market prices. An attacker exploits the discrepancy for favorable liquidations or swaps.
The Fix (Pyth):
let price_feed = load_price_feed_from_account_info(
&price_account
)?;
let current_price = price_feed
.get_price_no_older_than(
clock.unix_timestamp,
60 // max 60 seconds stale
)
.ok_or(ErrorCode::StaleOracle)?;
// Check confidence interval
let conf_pct = current_price.conf as f64
/ current_price.price as f64 * 100.0;
require!(conf_pct < 5.0, ErrorCode::OracleConfidenceTooWide);
// Check oracle status
require!(
current_price.price > 0,
ErrorCode::InvalidOraclePrice
);
12. ✅ Token-2022 Extension Awareness
The Bug: Not accounting for Token-2022 extensions (transfer fees, transfer hooks, permanent delegates) when integrating tokens.
Why It Kills: A token with a 50% transfer fee means your vault receives half of what the user sent. A permanent delegate can drain any token account at will. A transfer hook can revert or inject logic during transfers.
The Fix:
// Always check if a mint uses Token-2022
let is_token_2022 = token_account_info.owner == &spl_token_2022::id();
if is_token_2022 {
let mint_data = mint_info.try_borrow_data()?;
let mint = StateWithExtensionsMut::<Mint>::unpack(&mint_data)?;
// Check for transfer fee
if let Ok(fee_config) = mint.get_extension::<TransferFeeConfig>() {
let fee = fee_config
.calculate_epoch_fee(clock.epoch, amount)
.ok_or(ErrorCode::FeeCalculationFailed)?;
// Adjust your logic for the fee
let actual_received = amount - fee;
}
// Check for permanent delegate (HIGH RISK)
if mint.get_extension::<PermanentDelegate>().is_ok() {
return Err(ErrorCode::UnsupportedTokenExtension.into());
}
}
(For deeper coverage, see our companion article: Solana Token-2022 Security: The Hidden Attack Surface in Token Extensions)
13. ✅ Anchor Constraint Completeness Audit
The Bug: Relying on Anchor's magic without understanding which checks are automatic and which you must add manually.
What Anchor Does Automatically:
| Check | Automatic? |
|-------|-----------|
| Account discriminator | ✅ Yes (Account<'info, T>) |
| Owner verification | ✅ Yes (Account<'info, T>) |
| Signer verification | ✅ Yes (Signer<'info>) |
| PDA derivation | ✅ Yes (with seeds + bump) |
| Program ID verification | ✅ Yes (Program<'info, T>) |
| Initialization check | ✅ Yes (init) |
| Reinitialization protection | ✅ Yes (init) |
What Anchor Does NOT Check:
- Business logic constraints (has_one, constraint)
- Duplicate accounts
- Remaining accounts validation
- Token-2022 extension handling
- Oracle data freshness
- Integer overflow in your logic
- Correct seed composition
Best Practice: Don't trust Anchor blindly. For every account in your Accounts struct, ask: "What happens if an attacker passes a different but structurally valid account here?"
14. ✅ Transaction Simulation and Frontrunning Awareness
The Bug: Not considering that Solana transactions are visible in the mempool before execution, and that validators can reorder transactions within a slot.
Why It Kills: Attackers can frontrun your users' trades, sandwich their swaps, or time their transactions to exploit state changes between a user's read and write.
The Fix:
// Implement slippage protection
require!(
output_amount >= minimum_amount_out,
ErrorCode::SlippageExceeded
);
// Use deadline checks
require!(
clock.unix_timestamp <= instruction_deadline,
ErrorCode::TransactionExpired
);
// For sensitive operations, use commit-reveal schemes
// or Jito bundles for atomic execution
The Pre-Deployment Ritual
Before you send that solana program deploy command, walk through this:
□ Every privileged operation checks is_signer
□ Every deserialized account verifies owner
□ PDAs use canonical bumps, stored and re-verified
□ Account types use discriminators (Anchor) or explicit tags
□ No account can be initialized twice
□ All arithmetic uses checked_* operations
□ Closed accounts are zeroed and lamports fully drained
□ CPI calls verify target program ID
□ remaining_accounts are individually validated
□ Duplicate account detection where needed
□ Oracle prices check staleness + confidence
□ Token-2022 extensions are detected and handled
□ Anchor constraints cover all business logic
□ Slippage and deadline protections on swaps/trades
Print this out. Tape it to your monitor. Run through it for every program update.
Closing Thoughts
Solana's programming model gives you extraordinary power and speed — but it also gives you extraordinary responsibility. The runtime won't save you from missing a signer check. The compiler won't warn you about a PDA seed collision. The framework won't prevent you from deserializing spoofed account data.
The good news: every exploit on this list is preventable. Not with exotic cryptography or complex architectural patterns — just with disciplined, methodical validation of every account, every value, every assumption.
The $500M+ lost to these patterns wasn't inevitable. It was a checklist that nobody followed.
Follow the checklist.
DreamWork Security publishes daily research on smart contract vulnerabilities, audit methodologies, and DeFi security best practices. Follow us for the patterns that keep protocols safe.
Related reading:
- Solana Token-2022 Security: The Hidden Attack Surface in Token Extensions
- Transient Storage Security: How EIP-1153 Created DeFi's Newest Attack Surface
Tags: solana, security, smart-contracts, defi, blockchain, web3, rust, anchor
Top comments (0)