Solana Smart Contract Security: The Complete Guide for Anchor Developers

Solana's programming model is unlike anything in the EVM world. Accounts, PDAs, CPIs, the Sealevel runtime: all of it requires a mental shift that takes time. Anchor abstracts away a lot of the complexity, and that abstraction is genuinely useful.
But abstraction is not a security guarantee. Anchor handles account deserialization, discriminator checks, and some constraint validation. The logic of your program, who can call what, what happens to funds, how accounts relate to each other, is still entirely on you.
These are the vulnerabilities that show up most often in Solana program audits.
1. Missing Signer Checks
A function that should be authorized accepts any account in the signer position without verifying it is actually the expected account.
The risk: Any user can call admin or privileged functions by passing their own account as the "admin" signer.
// Vulnerable: checks that admin signed, but not which admin
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
pub admin: Signer<'info>, // any signer passes
#[account(mut)]
pub vault: Account<'info, Vault>,
}
// Safe: constrain the signer to the expected address
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
#[account(
constraint = admin.key() == vault.admin @ ErrorCode::Unauthorized
)]
pub admin: Signer<'info>,
#[account(mut)]
pub vault: Account<'info, Vault>,
}
Or use the has_one constraint when the expected signer address is stored in another account:
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
pub admin: Signer<'info>,
#[account(
mut,
has_one = admin @ ErrorCode::Unauthorized
)]
pub vault: Account<'info, Vault>, // vault.admin must equal admin.key()
}
Every privileged instruction needs to verify not just that someone signed, but that the right someone signed.
2. Missing Ownership Checks
Solana accounts have an owner: the program that controls them. An account owned by a different program than expected can contain arbitrary data crafted to look like a valid account of your program.
The risk: An attacker creates a fake account with the expected data layout but arbitrary values, passes it to your program as if it were a legitimate program account, and exploits incorrect values (fake balances, fake admin addresses).
Anchor's Account<'info, T> type handles ownership checks automatically by checking the account discriminator and owner. If you use AccountInfo<'info> or deserialize manually, you lose this protection.
// Vulnerable: AccountInfo has no ownership check
#[derive(Accounts)]
pub struct Deposit<'info> {
pub user_stats: AccountInfo<'info>, // attacker can pass any account
}
// Safe: Account<T> checks owner == this program and discriminator
#[derive(Accounts)]
pub struct Deposit<'info> {
pub user_stats: Account<'info, UserStats>,
}
When you genuinely need to accept accounts from other programs (for CPIs), validate the program ID explicitly before using any data from those accounts.
3. Reinitialization Attacks
An account initialized with init gets a discriminator and its data set. If an attacker can call an initialization instruction again on the same account, they can overwrite the admin, reset balances, or take ownership.
The risk: An attacker resets a contract's state to a configuration they control.
// Vulnerable: no guard against re-initialization
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init, payer = payer, space = 8 + State::INIT_SPACE)]
pub state: Account<'info, State>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
Once init creates and funds the account, a second call to initialize will fail because the account already exists and is owned by your program. However, if you use init_if_needed, the initialization is silently skipped on subsequent calls, which is the correct behavior for some use cases but dangerous if the instruction also sets privileged fields.
// Dangerous pattern: init_if_needed with privileged field assignment
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init_if_needed, payer = payer, space = 8 + State::INIT_SPACE)]
pub state: Account<'info, State>,
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn initialize(ctx: Context<Initialize>, admin: Pubkey) -> Result<()> {
// If the account already exists, this overwrites the admin
ctx.accounts.state.admin = admin;
Ok(())
}
Use init_if_needed only when the idempotent case is safe. For first-time setup with privileged fields, use init and document that the instruction is not callable twice.
4. Arbitrary CPI (Cross-Program Invocation)
Your program calls another program based on an account passed by the user. If the user passes a malicious program instead of the expected one, your program executes code you did not intend.
The risk: An attacker passes a fake token program, fake oracle, or fake system program. Your program calls it, and the attacker's program executes with your program's authority.
// Vulnerable: token_program is not validated
pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> {
token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(), // could be attacker's program
token::Transfer { ... },
),
amount,
)?;
Ok(())
}
// Safe: use Anchor's Program<'info, Token> which validates the program ID
#[derive(Accounts)]
pub struct TransferTokens<'info> {
pub token_program: Program<'info, Token>, // validates == spl_token::ID
}
For any CPI target, either use Anchor's typed Program<'info, T> which validates the program ID, or add an explicit constraint: constraint = token_program.key() == spl_token::ID.
5. PDA Validation Gaps
Program Derived Addresses (PDAs) are deterministic addresses derived from seeds. If your program accepts a PDA as an account without verifying it was derived from the expected seeds, an attacker can pass a PDA derived from different seeds that happens to have the right data layout.
The risk: An attacker creates a legitimate PDA for their own context (e.g., their own user account) and passes it where your program expects a different user's account.
// Vulnerable: only checks it is a PDA, not which PDA
#[derive(Accounts)]
pub struct ClaimRewards<'info> {
#[account(seeds = [b"user"], bump)]
pub user_account: Account<'info, UserAccount>,
// No check that this is the caller's user account
}
// Safe: include the user's key in the seeds
#[derive(Accounts)]
pub struct ClaimRewards<'info> {
pub user: Signer<'info>,
#[account(
seeds = [b"user", user.key().as_ref()],
bump,
has_one = user // verify user_account.user == user.key()
)]
pub user_account: Account<'info, UserAccount>,
}
Include user-identifying seeds (the user's public key) in any PDA that belongs to a specific user. The PDA then becomes user-specific and cannot be substituted with another user's PDA.
6. Type Confusion
Your program deserializes an account into the wrong type. The bytes fit the layout of the wrong struct, producing garbage values that pass type checking but contain attacker-controlled data.
The risk: An attacker passes an account of type A where your program expects type B, gets accepted, and exploits the misread data.
Anchor's discriminator system largely prevents this: each account type has an 8-byte discriminator derived from its name, checked on deserialization. If you use Account<'info, T>, Anchor verifies the discriminator before giving you the data.
The vulnerability reappears when:
- You deserialize accounts manually with
try_from_slice - You use
AccountInfoand access.datadirectly - You have two account types with identical layouts and different discriminators that you confuse in your instruction logic
The fix: always use Anchor's typed accounts. When you must use AccountInfo, add explicit discriminator or program ownership checks before reading the data.
7. Integer Overflow and Underflow
Rust panics on overflow in debug mode. In release mode (how Solana programs are deployed), integer overflow wraps around silently unless you use checked arithmetic.
The risk: An amount calculation overflows to a small number. A user receives more tokens than they should. A fee is undercharged. A balance goes negative and wraps to a very large number.
// Vulnerable: wraps silently in release builds
let new_balance = user_balance + deposit; // overflow if sum > u64::MAX
let fee = amount * fee_rate / 10000; // overflow on large amounts
// Safe: use checked arithmetic
let new_balance = user_balance
.checked_add(deposit)
.ok_or(ErrorCode::MathOverflow)?;
let fee = (amount as u128)
.checked_mul(fee_rate as u128)
.and_then(|v| v.checked_div(10000))
.and_then(|v| u64::try_from(v).ok())
.ok_or(ErrorCode::MathOverflow)?;
For fee calculations involving multiplication before division, cast to u128 to prevent intermediate overflow before converting back to u64.
8. Missing Freeze Authority Checks
For programs that interact with SPL tokens, the freeze authority can freeze token accounts, preventing transfers. If your program does not account for frozen accounts, a frozen account can cause unexpected instruction failures.
More critically: if your program mints tokens or manages a token mint, and you do not revoke or control the freeze authority, someone with freeze authority can break your protocol's assumptions by freezing user accounts.
The risk: Unexpected instruction failures on frozen accounts, or privileged freeze/unfreeze used to manipulate protocol state.
For mint accounts your program controls, either:
- Revoke the freeze authority by setting it to
Noneafter initialization - Retain it under your program's PDA so only your program can freeze/unfreeze
The Anchor Checklist
Before deploying any Anchor program, verify each instruction:
- Every privileged instruction: does it check the specific signer identity, not just that someone signed?
- Every account that comes from user input: is ownership validated (use
Account<T>, notAccountInfo)? - Every PDA: do the seeds include a user-identifying key where the account is user-specific?
- Every CPI: is the target program's key validated before calling it?
- Every arithmetic operation on amounts: is checked arithmetic used throughout?
- Every initialization: is
init_if_neededused only where re-initialization is safe?
Anchor catches a lot. These are the gaps it leaves.
Odin Scan scans Anchor programs for all of these vulnerability patterns automatically. Every PR, every commit. Try it on your Solana program with a free trial.