DEV Community

ohmygod
ohmygod

Posted on

The Anchor Constraint Security Checklist: 10 Validation Patterns That Prevent 90% of Solana Program Exploits

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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 UncheckedAccount without documented justification and owner constraints
  • [ ] Token accounts validate both mint and authority
  • [ ] Account closure uses close with authorization checks
  • [ ] init uses InitSpace and 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)