Every quarter, the same story plays out: an audited Solana program loses millions because someone missed an account constraint. Not a novel zero-day. Not a sophisticated economic attack. Just a missing has_one, an unchecked PDA seed, or a forgotten mut validation.
The Anchor framework gives you powerful constraint primitives. The problem is that most developers learn them piecemeal, from tutorials that demonstrate what constraints do without explaining when you need each one and what happens when you skip it.
This is the checklist I wish existed when I started auditing Anchor programs. Ten patterns, each with the vulnerability it prevents, the constraint that fixes it, and the real-world exploit class it maps to.
1. Always Verify Signers — The Signer Type Isn't Optional
Vulnerability: Unauthorized instruction execution
The Pattern:
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
#[account(mut)]
pub authority: Signer<'info>,
#[account(
mut,
has_one = authority,
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump,
)]
pub vault: Account<'info, Vault>,
}
What goes wrong without it: If authority is typed as AccountInfo instead of Signer, anyone can pass any pubkey as the authority. The instruction executes without the actual key holder signing the transaction. This is the #1 vulnerability class in Solana programs — full stop.
Rule: Every account that authorizes an action must be typed as Signer<'info>. No exceptions.
2. Bind Accounts With has_one — Don't Trust Client-Supplied References
Vulnerability: Account substitution attacks
The Pattern:
#[derive(Accounts)]
pub struct ClaimReward<'info> {
pub user: Signer<'info>,
#[account(
mut,
has_one = user,
has_one = reward_mint,
)]
pub stake_account: Account<'info, StakeAccount>,
pub reward_mint: Account<'info, Mint>,
}
What goes wrong without it: An attacker creates their own StakeAccount with inflated reward balances and passes it alongside a legitimate reward_mint. Without has_one, the program trusts whatever accounts the client supplies. The attacker drains rewards they never earned.
Rule: If account B stores a reference to account A, use has_one to verify the reference matches the account actually passed.
3. Validate PDA Seeds Exhaustively — Every Seed Matters
Vulnerability: PDA spoofing / cross-user data access
The Pattern:
#[derive(Accounts)]
pub struct UpdatePosition<'info> {
pub owner: Signer<'info>,
#[account(
mut,
seeds = [
b"position",
pool.key().as_ref(),
owner.key().as_ref(),
],
bump = position.bump,
)]
pub position: Account<'info, Position>,
pub pool: Account<'info, Pool>,
}
What goes wrong without it: If you derive a PDA with seeds = [b"position"] alone (missing the owner and pool), there's only one valid PDA for that seed — shared across all users and pools. An attacker modifies someone else's position.
Rule: PDA seeds must include every discriminating factor. If two different entities should have different PDAs, their distinguishing data must be in the seeds.
4. Store and Validate Bumps — Don't Recompute
Vulnerability: Bump seed canonicalization attacks
The Pattern:
#[account]
pub struct Vault {
pub authority: Pubkey,
pub bump: u8, // Store the canonical bump
}
#[derive(Accounts)]
pub struct AccessVault<'info> {
#[account(
seeds = [b"vault", authority.key().as_ref()],
bump = vault.bump, // Use stored bump, don't find_program_address
)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
}
What goes wrong without it: Pubkey::find_program_address returns the canonical bump (highest valid bump). But there are multiple valid bumps for any seed set. If your program uses create_program_address with a client-supplied bump without validating it's the canonical one, an attacker can create a second valid PDA with a different bump — effectively a shadow account.
Rule: Store the bump at initialization. Always use bump = stored_bump in constraints. Never accept bumps from instruction data.
5. Prevent Duplicate Mutable Accounts — The Silent Overwrite
Vulnerability: State corruption via duplicate account serialization
The Pattern:
#[derive(Accounts)]
pub struct Transfer<'info> {
#[account(
mut,
constraint = source.key() != destination.key()
@ ErrorCode::DuplicateAccounts
)]
pub source: Account<'info, TokenVault>,
#[account(mut)]
pub destination: Account<'info, TokenVault>,
}
What goes wrong without it: A user passes the same account as both source and destination. The program reads source balance (1000), reads destination balance (also 1000, same account), subtracts 500 from source (now 500), adds 500 to destination (now 1500). But since both point to the same account, Anchor serializes only the last write — destination's 1500. The user just created 500 tokens from nothing.
Rule: Any instruction with two or more mutable accounts of the same type must explicitly check they're not the same account.
6. Check Account Ownership — Programs Aren't the Only Owners
Vulnerability: Fake account injection
The Pattern:
#[derive(Accounts)]
pub struct ProcessPayment<'info> {
#[account(
owner = token_program.key()
)]
pub payment_token: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
What goes wrong without it: If you use AccountInfo or UncheckedAccount (sometimes necessary for CPIs), there's no ownership validation. An attacker creates an account owned by their own program with carefully crafted data that deserializes into your expected structure.
Rule: Prefer typed accounts (Account<'info, T>) everywhere possible. When you must use UncheckedAccount, add explicit owner constraints.
7. Validate Token Accounts Completely — Mint + Authority + State
Vulnerability: Wrong-token deposits, unauthorized withdrawals
The Pattern:
#[derive(Accounts)]
pub struct Deposit<'info> {
pub depositor: Signer<'info>,
#[account(
mut,
token::mint = accepted_mint,
token::authority = depositor,
)]
pub user_token_account: Account<'info, TokenAccount>,
#[account(
mut,
token::mint = accepted_mint,
token::authority = vault_authority,
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub accepted_mint: Account<'info, Mint>,
/// CHECK: PDA authority for the vault
#[account(seeds = [b"vault_auth"], bump)]
pub vault_authority: UncheckedAccount<'info>,
}
What goes wrong without it: Without token::mint, a user deposits worthless tokens into a vault that should only accept USDC. Without token::authority, a CPI withdrawal might succeed from any token account. These are distinct checks — you need both.
Rule: Every token account constraint should specify both mint and authority. If either is missing, you have a vulnerability.
8. Use close Safely — Prevent Rent-Drain and Revival Attacks
Vulnerability: Rent extraction, account revival
The Pattern:
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(
mut,
close = receiver,
has_one = owner,
)]
pub position: Account<'info, Position>,
pub owner: Signer<'info>,
/// CHECK: Receives the rent lamports
#[account(mut)]
pub receiver: UncheckedAccount<'info>,
}
What goes wrong without it: Manually closing accounts by zeroing data and transferring lamports is error-prone. Common mistakes: (1) Forgetting to zero the discriminator, allowing the account to be "revived." (2) Transferring lamports to the wrong recipient. (3) Not checking ownership before closing.
Rule: Always use Anchor's close constraint instead of manual closing. Always pair it with authorization checks.
9. Constrain init With Proper Space and Payer
Vulnerability: Under-allocated accounts, payer manipulation
The Pattern:
#[derive(Accounts)]
pub struct CreatePool<'info> {
#[account(
init,
payer = creator,
space = 8 + Pool::INIT_SPACE,
seeds = [b"pool", creator.key().as_ref(), pool_id.as_bytes()],
bump,
)]
pub pool: Account<'info, Pool>,
#[account(mut)]
pub creator: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct Pool {
pub creator: Pubkey,
pub total_staked: u64,
#[max_len(32)]
pub name: String,
pub bump: u8,
}
What goes wrong without it: Hard-coding space values leads to buffer overflows when you add fields. Missing PDA seeds on init lets accounts be initialized multiple times.
Rule: Always use InitSpace derive for space calculation. Always use PDA seeds for init. Always include 8 + for the discriminator.
10. Use constraint for Business Logic — Not Just Account Validation
Vulnerability: Protocol invariant violations
The Pattern:
#[derive(Accounts)]
pub struct Liquidate<'info> {
pub liquidator: Signer<'info>,
#[account(
mut,
constraint = position.health_factor() < LIQUIDATION_THRESHOLD
@ ErrorCode::PositionHealthy,
constraint = position.last_updated > clock.unix_timestamp - MAX_STALENESS
@ ErrorCode::StalePosition,
)]
pub position: Account<'info, Position>,
pub clock: Sysvar<'info, Clock>,
}
What goes wrong without it: Without the health factor check in the constraint, you'd need to remember to check it in the instruction body — and instruction bodies get refactored, forked, and copied without the safety checks.
Rule: Move critical invariant checks into constraints wherever possible. They execute before your instruction body, fail fast with clear errors, and are visible where reviewers will see them.
The Meta-Pattern: Defense in Depth
No single constraint prevents all attacks. The power is in layering:
| Layer | Constraint | Prevents |
|---|---|---|
| Authentication | Signer |
Unauthorized access |
| Authorization | has_one |
Account substitution |
| Derivation |
seeds, bump
|
PDA spoofing |
| Uniqueness | constraint = a != b |
Duplicate accounts |
| Typing | Account<T> |
Fake account injection |
| Token Safety |
token::mint, token::authority
|
Wrong-token attacks |
| Lifecycle |
close, init
|
Revival, re-init attacks |
| Invariants | constraint |
Business logic violations |
Most exploited Anchor programs are missing constraints from two or more of these layers simultaneously.
Practical Checklist
Before deploying any Anchor program, verify:
- [ ] Every authorizing account uses
Signer<'info> - [ ] Every stored reference is validated with
has_one - [ ] Every PDA includes all discriminating seeds
- [ ] Bumps are stored and validated, never recomputed
- [ ] Duplicate mutable accounts are explicitly prevented
- [ ] No
UncheckedAccountwithout documented justification andownerconstraints - [ ] Token accounts validate both
mintandauthority - [ ] Account closure uses
closewith authorization checks - [ ]
initusesInitSpaceand PDA seeds - [ ] Critical business logic lives in constraints, not just instruction bodies
This isn't exhaustive — arithmetic safety, CPI validation, and re-entrancy guards matter too. But these ten patterns cover the constraint-level vulnerabilities that account for the vast majority of exploited Anchor programs.
The best security isn't clever. It's thorough.
Top comments (0)