When you write a normal backend, you mostly trust your own database. A row you wrote is a row you can read back, and the data is what you put there. Solana works differently, and the difference is the single most important thing to understand about writing safe programs.
On Solana, your program is handed a list of accounts with every instruction, and any of those accounts can be anything the caller wants. The caller picks them. An account is just an address, a balance, some bytes, and a field saying which program owns it, and a caller is free to hand your program an account they built themselves, filled with whatever bytes serve them. Your program has no trusted edge. Every account is attacker-controlled until your code proves otherwise.
That sounds alarming, but it collapses into something manageable. Most account security on Solana comes down to two questions you ask about every account in every instruction. Learn to ask them and a whole class of expensive bugs stops being mysterious.
The shift: from "what did I mean" to "what did I forget to forbid"
When you're building a feature, you think about the happy path: a user calls this, the program does that, everyone's content. Security asks a different question. Not "what did I intend this to do," but "what did I forget to forbid." Those are not the same question, and the gap between them is where exploits live.
A useful way to hold it: when you write a program, you imagine the user you designed for. An attacker is every user you didn't. They will pass the account you didn't expect, sign with a key you didn't intend, and send the number you assumed no one would. Your job is to make the program say no to all of them, out loud, before it touches anything.
The good news is you don't need to imagine every attack. Two questions cover most of the ground.
Question one: who owns this account?
If your program reads data off an account and trusts it, the program needs to know which program owns that account.
Here's why it matters. Say your program has a Config account that stores an admin's public key, and your withdraw logic reads config.admin to decide who's allowed. If you read those bytes without checking who owns the account, an attacker can create their own account, write a Config into it that names themselves as admin, and hand it to your program. The bytes deserialize perfectly. The struct says what they want it to say. Your check passes, and they walk out with the funds.
The defense is an owner check: confirm the account is owned by the program that's supposed to own it. A real Config your program created is owned by your program. The attacker's forgery is owned by the System Program (or whatever they chose), so an owner check rejects it on sight.
This exact gap, an account trusted without verifying what it was, is behind some of the largest losses in Solana's history. It isn't exotic cryptography. It's a missing question.
Question two: did the authority actually sign?
The second question is about permission. If an account decides whether an action is allowed, an account named authority or admin or owner, the program has to confirm that account actually signed the transaction.
The trap here is subtle and worth slowing down for. It's tempting to check authority by comparing public keys: "does the admin field on this account equal the admin pubkey I expect?" But a public key is public. Anyone can put anyone's pubkey in a transaction. Comparing pubkeys only proves that someone knew a public key, which is no proof at all, since they're visible on chain to everyone.
What you actually need to know is that the holder of the matching private key approved this transaction. That's what a signature proves and a pubkey comparison doesn't. So the rule is: an authority must sign, not just match. Checking the pubkey without checking the signature is the precise mistake behind one of the most famous bridge exploits, where the program compared an account but never confirmed it had signed.
How Anchor answers both questions for you
If you're using Anchor, the reassuring part is that the account types answer these questions automatically. You often get the checks for free, as long as you reach for the right type.
Account<'info, T> answers the owner question. When you type an account as Account<'info, Config>, Anchor verifies the account is owned by your program and that its first eight bytes match the Config type's discriminator, before your handler runs. The forged account from question one never makes it through.
Signer<'info> answers the signer question. Typing an account as Signer<'info> makes Anchor confirm it actually signed the transaction. No pubkey-comparison trap, because you're checking the signature itself.
#[derive(Accounts)]
pub struct Withdraw<'info> {
// owner + discriminator checked automatically
pub config: Account<'info, Config>,
// signature checked automatically
pub authority: Signer<'info>,
}
The danger lives in the escape hatch. When you reach for UncheckedAccount<'info> or AccountInfo<'info>, Anchor checks nothing: no owner, no signer, no type. Sometimes you need that, but every time you use it you've quietly promised to do the validation by hand. Anchor even makes you write a /// CHECK: comment above it to acknowledge the promise. The bugs tend to be the moments someone reached for an unchecked account to make something compile, then forgot they'd taken on that promise.
So a lot of Anchor security is simply: use the typed account instead of the raw one, and let the framework ask the two questions for you.
Binding accounts together: the constraints
The types answer "is this a real account my program owns" and "did this account sign." One question they can't answer on their own is whether two accounts belong together. Does this config belong to this authority? For that, Anchor gives you constraints that live right on the account.
#[derive(Accounts)]
pub struct Withdraw<'info> {
pub authority: Signer<'info>,
#[account(
mut,
has_one = authority,
)]
pub config: Account<'info, Config>,
}
has_one = authority checks that the authority field stored inside config equals the authority account that signed. It binds the on-chain record to the live signer, so a valid signer can't operate on a config that isn't theirs. There are more of these (seeds/bump to confirm a PDA, address to pin an exact key, constraint for any boolean you like), but they all share one idea: declare the rule next to the account, and the runtime enforces it before your handler runs. A rule on the struct can't be forgotten in a refactor the way a check buried in handler logic can.
The mental model to carry in
Here's the whole thing in one frame. A Web2 API often validates once at the edge and trusts the request afterward. A Solana program has no edge. Every account on every instruction is untrusted input, every time. Security is the practice of declaring your assumptions in a place the runtime checks them, so the program refuses bad input by construction rather than by you remembering to write an if.
When you read an instruction with that lens, the checklist is short:
- For every account whose data you trust, ask: is its owner verified? (
Account<'info, T>does this.) - For every account that authorizes an action, ask: did it actually sign? (
Signer<'info>does this.) - For accounts that must belong together, ask: are they bound? (
has_one,seeds,constraint.) - For every
UncheckedAccount, ask: did I really mean to skip all of that, and did I validate it by hand?
That's most of account security, and none of it is clever. It's a posture: assume nothing the caller hands you is what it claims, and make the program prove each piece before it acts.
Going further
The Anchor account types reference documents exactly what each type checks, and the account constraints reference lists has_one, seeds, address, constraint, and the rest. For the attack patterns these defend against, coral-xyz/sealevel-attacks pairs a deliberately vulnerable program with its Anchor fix for each common exploit class, owner checks, signer checks, account substitution, and more. It's the clearest way to see each mistake and its correction side by side.
If you're doing 100 Days of Solana, the next arc turns this posture into practice: you'll audit a program for these exact gaps, close them with constraints, and write tests that prove the locks hold. Reading this first means the two questions will already feel like second nature. Not joined yet? It's not too late to build alongside everyone: mlh.link/solana-100.
Top comments (0)