Missing Signer and Owner Checks: Solana's #1 Audit Finding
Solana's #1 audit finding: missing signer/owner checks in Anchor programs. Why linters miss it and the has_one constraint that fixes it.
Why This Bug Keeps Showing Up in Anchor Programs
A validator wallet passes a Signer check. A vault account passes an Account<'info, TokenAccount> deserialization check. The instruction executes, funds move, and three weeks later someone realizes any wallet on Solana could have called that instruction and pointed the authority field at whatever account they wanted. This is not a hypothetical. Missing signer and owner checks account for the largest single category of findings across every public Solana audit report I've read in the last two years, and it's the first thing we grep for on every engagement at TierZero.
The reason it's so persistent isn't ignorance. It's that Anchor's constraint system is powerful enough to make people think the framework is checking things it isn't. Signer<'info> proves a private key signed the transaction. It says nothing about whether that key is the right key. Account<'info, T> deserializes data and checks the discriminator. It says nothing about whether the account's owner field matches who you think owns it. Those are two separate guarantees, and conflating them is where the vulnerability class lives.
The Anatomy of the Bug
Here's a stripped-down withdraw instruction that looks reasonable on a first read:
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub vault_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.vault_token_account.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.vault.to_account_info(),
};
// ... CPI transfer with vault as signer via PDA seeds
Ok(())
}
authority is a Signer, so the instruction requires someone's signature. But nothing ties authority to vault.owner. Anyone can call this instruction, pass in the real vault account, pass in their own wallet as authority, and the program will still execute the transfer using the vault's PDA signing seeds — because the code never checks that the caller is actually the account's owner. The signer check exists; the authorization check doesn't. That distinction is the whole bug.
The fix is a has_one constraint:
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, has_one = authority)]
pub vault: Account<'info, Vault>,
#[account(mut)]
pub vault_token_account: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
One line. Anchor now generates a runtime check that vault.authority == authority.key(), and it fails with a constraint violation before your handler body runs. This is the fix in probably 70% of the missing-check findings we write up. The other 30% are the variants that linters routinely miss, and those are worth understanding on their own.
Why Static Linters Miss the Real Variants
cargo-audit, sec3's X-ray, and Anchor's own build-time lints are good at catching the textbook case: a Signer field that's never referenced anywhere in a has_one or manual constraint. But three variants slip past pattern-matching tools consistently:
Owner checks on non-Anchor accounts. The moment you use AccountInfo<'info> or UncheckedAccount<'info> instead of a typed Account<'info, T>, Anchor stops validating the owner program for you entirely. If your handler then reads raw bytes out of that account and trusts them, you've reintroduced the whole class of bug that Account<'info, T> exists to prevent, and no static linter can prove your manual deserialization is safe — that requires understanding what you're using the data for.
Cross-program invocation targets. A CPI to an external program is only as safe as the account you're invoking against. If your instruction accepts a token_program: AccountInfo<'info> without constraining it to the actual SPL Token program ID, someone can pass in a malicious program with the same interface and hijack the CPI. Anchor's Program<'info, Token> type closes this, but plenty of older or hand-rolled code still uses raw AccountInfo for program accounts because it was faster to write.
Multi-signer instructions where only one signer is checked. Instructions that take an authority and a fee_payer, or a proposer and an approver, frequently check that one of the two is a valid Signer and forget to verify the relationship between them. The code compiles, both fields look correctly typed, and a linter sees two Signer accounts and moves on. It has no way to know your business logic requires both to be validated against stored state.
None of these trip a compiler warning. They require someone reading the instruction and asking "who is allowed to call this, and does the code actually enforce that" — which is manual review, not tooling. This is also why relying solely on cargo clippy plus a linter pass before mainnet is a mistake we see repeatedly; see our breakdown on whether you need a Solana program audit before launch if you're weighing that tradeoff for your own timeline.
The Constraint Checklist We Actually Use
On every account struct during audit, we ask four questions per account:
- Is this a
Signer, and if so, is its identity checked against stored program state viahas_oneor a manualconstraint =? - Is this account's owner program verified, either implicitly through
Account<'info, T>or explicitly viaconstraint = account.owner == expected_program? - If this account is a PDA, are its seeds derived and checked, not just assumed?
- If this account is passed into a CPI, is the target program ID constrained rather than accepted as arbitrary
AccountInfo?
Run those four questions against every account in every instruction and you'll catch the overwhelming majority of what shows up as critical or high severity in audit reports. It's mechanical once you're doing it deliberately — the failure mode is doing it only for the "obvious" admin instructions and skipping it for user-facing ones, which is exactly backwards, since user-facing instructions are what attackers actually call.
What This Costs You If You Skip It
We've seen this exact bug pattern in vault withdrawals, staking reward claims, and governance vote instructions, and in each case the fix was under five lines. The audits that find it before mainnet cost a few thousand dollars and a week of turnaround. The ones that find it after are measured in drained TVL and post-mortems. If you're deciding how to vet an auditor before you commit to an engagement, our guide on how to choose a Solana smart contract auditor covers what separates a firm that actually reads your instruction handlers from one running an automated scanner and calling it a day. And if a finding like this does turn up in your own review, the fix pattern above is step one of a broader process — laid out in our post-audit remediation guide — of patching, re-testing, and re-verifying before you redeploy.
If you want a second set of eyes on your account constraints before you ship, our Solana smart contract audit service starts with exactly this checklist, account by account, instruction by instruction.
Need a bot like this built?
We design, build and run trading bots on Solana, Hyperliquid and Polymarket.
Start a projectMore from the blog
Public vs Paid RPC: How to Run a Fair Benchmark
A fair comparison of public and paid RPC endpoints must account for quotas, workload, freshness and support guarantees.
Read articleRPC Benchmark Percentiles Explained: p50, p95 and p99
Why averages hide the latency spikes that break trading bots, wallets and production blockchain applications.
Read articleHyperliquid REST vs WebSocket Benchmark: What to Measure
A benchmark plan for Hyperliquid market data that separates request latency from streaming freshness and recovery.
Read article