Program Security Key Points
Solana program security is account security. Callers choose which accounts and programs enter an instruction; Sealevel enforces locks, ownership write rules, and compute budgets, not your vault balance, admin list, or mint binding.
This page is the conceptual map for Program Security & Auditing - Sealevel attack surface, account validation, signer and owner checks, PDA and seed attacks, reinitialization, arithmetic, CPI and reentrancy risk, and a practical audit workflow on Agave 4.1.1.
Summary
- Every instruction is an adversarial interface: prove who signed, who owns each account, which PDA and type you touch, then mutate state with checked math and safe CPI order.
- Insight: Exploits rarely need a novel crypto break. They substitute a wrong vault, skip a signer, re-init an admin, wrap a balance, or CPI to a malicious program that returns "success."
- Key Concepts: account validation, signer / owner checks, PDA seeds and bump, reinitialization guards, checked arithmetic, CPI allowlists, checks-effects-interactions (CEI), fail-closed design, audit workflow.
- When to Use This Model: Designing new instructions, reviewing
#[derive(Accounts)], prep for external audit, triage of findings, or teaching the Solana threat model. - Limitations/Trade-offs: Anchor constraints catch many classes but not business rules;
UncheckedAccountandremaining_accountsreintroduce risk; security reviews do not replace fuzzing or economic design review. - Related Topics: Account Validation Attacks, Signer & Owner Checks, PDA & Seed Attacks, Reinitialization Attacks, Arithmetic & Overflow, CPI & Reentrancy Risks, Audit Workflow.
Foundations
Sealevel runs programs as stateless executables. Durable state lives in accounts the transaction lists up front. Clients (or attackers) supply those accounts, signer flags, and writable flags. The runtime checks that declared signers actually signed and that only the owning program may write account data. It does not know that "this TokenAccount must match vault.mint" or "only authority may withdraw."
That gap is the attack surface:
Client / attacker supplies:
program_id | instruction data | AccountMeta[] (keys, is_signer, is_writable)
Runtime guarantees:
signatures match is_signer | locks from is_writable | owner write rules | CU budget
Your program must guarantee:
right identity signed | right owners and types | right PDAs | right amounts | safe CPIsFail closed is the default posture: if validation is incomplete, reject with a typed error. Never assume "honest clients" or "our frontend only builds good txs." Frontends and indexers are not part of the trust boundary; on-chain checks are.
Anchor 0.32.1 encodes common checks (Signer, Account<T>, seeds, has_one, Program<T>). That is not a full review. Gaps appear with AccountInfo, UncheckedAccount, remaining_accounts, unguarded init_if_needed, and business rules constraints cannot express.
Threat model for a typical vault or market program:
| Asset at risk | Typical failure | First-line control |
|---|---|---|
| Tokens / lamports | Missing signer or wrong token account | Signer, mint/owner constraints |
| Admin / config authority | Reinit or unset has_one | One-time init, stored authority + signer |
| Protocol accounting | Overflow, bad rounding | checked_*, u128 intermediates |
| Composability | CPI to impostor program | Program ID allowlist, CEI |
| State identity | Wrong PDA or type confusion | seeds + bump, discriminators |
Mechanics & Interactions
Account validation and type confusion
Attackers pass accounts that look usable: same size, owned by your program, or deserializable into the wrong type. Validation means more than "deserializes."
Checklist per account role:
- Owner - expected program (
Account<T>for your types;require_keys_eq!for SPL Token, Sysvar, etc.). - Type / discriminator - Anchor account disc or native constant so Vault is never User.
- Relational constraints - mint matches vault, token account owner is the vault PDA, config matches market.
- Mutability - only accounts you intend to change are
mut. - Canonical PDA -
seeds+ storedbumpso the address is derived, not chosen freely.
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut, has_one = authority, has_one = mint)]
pub vault: Account<'info, Vault>,
#[account(
mut,
constraint = user_ata.mint == vault.mint @ ErrorCode::MintMismatch,
constraint = user_ata.owner == user.key() @ ErrorCode::WrongAtaOwner,
)]
pub user_ata: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}UncheckedAccount and raw AccountInfo skip owner and disc checks until you add them. Treat every such field as a finding until proven validated.
Signer and owner checks
Signer: any path that moves funds, changes authority, closes accounts, or upgrades-sensitive config needs a cryptographic signature from the right key (or a PDA signed via invoke_signed with program-controlled seeds).
Owner: only the owning program may rewrite data. Reading an account your program does not own without checking owner is how type confusion and fake mints enter. For token accounts, owner must be the Token or Token-2022 program you intend; for your state, owner must be your program_id.
Common critical pattern: vault stores authority: Pubkey but the instruction never requires authority as Signer or never ties it with has_one.
PDA and seed attacks
PDAs are authority and address identity. Weak or ambiguous seeds let an attacker present a different PDA that your program still "accepts."
Rules of thumb:
- Include all identity dimensions in seeds (user, mint, market id, role prefix).
- Store the canonical bump at init; reuse
bump = account.bumplater (no ambiguous bump search on hot paths). - Sign CPIs only with the same seeds used to create the PDA.
- Do not let users supply arbitrary seed bytes without domain separation (
b"vault", version tags).
#[account(
seeds = [b"vault", authority.key().as_ref(), mint.key().as_ref()],
bump = vault.bump,
)]
pub vault: Account<'info, Vault>,Reinitialization
Init writes discriminators, authorities, and balances. Re-running init (or init_if_needed without a flag) can reset admin to the attacker or revive a closed account shape.
Prefer one-time init for permanent state. If you need init_if_needed, require !is_initialized (or equivalent) before writing authority fields, and never re-open closed accounts without a deliberate, audited path. On close, zero data / disc and transfer lamports so the account cannot be treated as live state.
Arithmetic and overflow
BPF release builds wrap on plain +, -, *. Vault math, share minting, fee accrual, and AMM formulas need checked_* (or saturating ops where intentional) and often u128 intermediates before casting back to u64.
Rounding should favor the protocol on user-facing mints and redemptions so dust cannot be farmed into free value. Division by zero and empty-pool edge cases are security bugs, not mere UX.
CPI and reentrancy-style risk
Cross-program invocation extends trust to another program. Risks:
- Wrong program ID - impostor "token program" no-ops or steals.
- Unvalidated remaining accounts - attacker-controlled callback accounts.
- State after CPI - callee re-enters your program (or a peer) while balances still look pre-update.
Mitigations: allowlist callees with Program<'info, T> or require_keys_eq!; update program state before external CPI (CEI); avoid untrusted callbacks unless you design explicit flash-loan style debt checks; pass only accounts the callee needs.
vault.balance = vault.balance.checked_sub(amount).ok_or(ErrorCode::Overflow)?;
// effects first, then interaction
token::transfer(cpi_ctx, amount)?;Solana is not identical to EVM reentrancy, but bad call order and untrusted CPI targets still lose funds.
Audit workflow (spine)
Security work is process as much as code:
- Freeze an RC tag, lock dependencies (Anchor 0.32.1, Agave/CLI pins), document threat model.
- Internal map every instruction to Sealevel Attacks Catalog classes (signer, owner, substitution, PDA, reinit, math, CPI, close, oracle).
- Negative tests in LiteSVM / Anchor tests: wrong signer, wrong mint, wrong program, double init, overflow amounts.
- Fuzz parsing and math-heavy paths when complexity warrants it.
- Verifiable build so auditors match source to on-chain hash.
- External audit + fix cycle; re-diff after every
Accountsor arithmetic change. - Bug bounty and upgrade/rollback plan for residual risk.
Advanced Considerations & Applications
| Control layer | What it catches | What it misses | When to lean harder |
|---|---|---|---|
| Anchor account types | Owner + disc for Account<T>, many PDA constraints | Business rules, Unchecked paths | Default for all state accounts |
Manual require_* | Relational and economic invariants | Easy to omit on new ixs | Every new handler review |
| CEI + program allowlist | Classic CPI theft / impostor programs | Economic design bugs | Any token move or callback |
| Checked math + property tests | Overflow and share math edges | Oracle and MEV games | Vaults, AMMs, lending |
| External audit + bounty | Fresh eyes, known catalog classes | Infinite budget attacks, future upgrades | Pre-mainnet and major upgrades |
Oracles are untrusted inputs: check feed owner, staleness, and confidence; fail closed on bad data.
Token-2022 extensions (hooks, permanent delegates) change who can move tokens. Pin the token program ID; do not assume classic SPL Token semantics.
Close and rent: require the right signer and destination; zero data so accounts cannot revive under weak checks. Prefer Anchor close = with constraints.
Upgrade authority is root. Multisig or governance, verifiable builds, and optional immutability after maturity are operational security.
Clients (@solana/kit 7.0.0, wallets) help honest users only. Programs defend against everyone else.
On Agave 4.1.1 (CLI 3.0.10, Anchor 0.32.1, Rust 1.91.1), exercise adversarial account sets in LiteSVM or local validators. Treat every new instruction as a new attack surface.
Common Misconceptions
- "The runtime validates my vault rules." It validates signatures, locks, owners for writes, and CU. Protocol rules are yours alone.
- "Anchor means we are secure." Anchor encodes common checks; Unchecked accounts, bad constraints, and math bugs still ship.
- "If it deserializes, the account is fine." Deserialization is not mint binding, authority, or PDA identity.
- "PDAs cannot be faked." Wrong or incomplete seeds produce a different valid PDA under your program. Seeds define the security policy.
- "init_if_needed is always safe." Without an initialized flag (or equivalent), reinit resets critical fields.
- "Rust prevents overflow on-chain." Release BPF wrapping is real; use checked math for value paths.
- "CPI to Token is always safe." Only if the program ID is the real Token/Token-2022 program and accounts match mint and authority constraints.
- "We will fix security in the audit." Audits are sampling. Design fail-closed first; audits catch residual bugs.
- "Readonly accounts cannot hurt us." Fake configs, oracles, and mints are often readonly yet fully control economic outcomes.
- "Closing an account ends all risk." Incomplete close or wrong destination creates rent theft and revival issues.
FAQs
What is the Sealevel attack surface for a program?
Everything the caller can choose: which accounts and programs appear, who signs, instruction data, and CPI targets. The runtime does not enforce your mint, authority, or balance invariants. See Sealevel Attacks Catalog.
Why are missing signer checks so common and severe?
Without a required Signer (or PDA invoke_signed path), any account key can be named as "authority" while only the fee payer actually signs. That enables unauthorized withdrawals and admin takeover. See Signer & Owner Checks.
What is account substitution?
Passing a different vault, token account, mint, or config than the protocol intends, often still owned by a plausible program. Fix with relational constraints (has_one, mint equality, seeds). See Account Validation Attacks.
How do PDA seed attacks work?
If seeds omit identity fields or bumps are not canonical, attackers derive alternate PDAs your instruction still accepts, impersonating vaults or escrows. Store bumps and pin full seed lists. See PDA & Seed Attacks.
When is reinitialization possible?
When init can run again on live state: init_if_needed without guards, missing discriminators, or closed accounts that are recreated under weak checks. See Reinitialization Attacks.
Does Solana need checked arithmetic?
Yes for value paths. Plain arithmetic can wrap on-chain; share and swap formulas should use checked_* and careful rounding. See Arithmetic & Overflow.
What is CEI in a Solana CPI context?
Checks-effects-interactions: validate, update your program-owned state, then call out. It limits reentrancy-style reads of stale balances during untrusted or recursive CPIs. See CPI & Reentrancy Risks.
How do I validate a CPI target program?
Use Program<'info, Token> (or your known type) or require_keys_eq! against a constant ID. Never CPI to a user-supplied program key without an explicit allowlist.
Are Token and Token-2022 interchangeable for security?
No. Different program IDs and extension behaviors (hooks, transfer fees, delegates) change who can move funds. Pin the program and test extension-enabled mints.
What should an internal pre-audit cover?
Every instruction mapped to catalog classes, negative unit tests, dependency pins, verifiable build notes, and known residual risks. See Audit Workflow.
Does fail-closed mean reject all unknown accounts?
It means reject when required proof is missing or inconsistent: wrong signer, owner, mint, PDA, stale oracle, or unallowlisted program. Optional accounts still need explicit rules if present.
How does security interact with Sealevel parallelism?
Security is per-instruction correctness; parallelism is about lock sets. Do not weaken validation just to pass fewer accounts.
Should upgrade authority be a single hot key?
Avoid for production value at risk. Prefer multisig or governance, monitor upgrades, and consider freezing authority after maturity.
Where do oracles fit in program security?
As untrusted inputs. Check feed owner, staleness, and confidence before using prices. Fail closed on missing or stale data.
What is the fastest way to review a new instruction?
Read Accounts (signers, owners, seeds, relations, init vs mut, program IDs), then handler order (math, writes, CPI), then negative tests for each failure mode.
Related
- Security Basics - threat model and starter patterns
- Signer & Owner Checks - access control essentials
- Account Validation Attacks - substitution and type confusion
- PDA & Seed Attacks - seeds, bumps, impersonation
- Reinitialization Attacks - init and init_if_needed guards
- Arithmetic & Overflow - checked math and rounding
- CPI & Reentrancy Risks - allowlists and CEI
- Sealevel Attacks Catalog - checklist of classic classes
- Audit Workflow - freeze, test, audit, bounty
- Best Practices - section checklist
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, Rust 1.91.1, and @solana/kit 7.0.0.