What VL001 is not
Until 26 July 2026 this rule was called missing signer check. That formulation was written, implemented, measured — and thrown away. Across five audited production programs it produced 26 findings, not one of them a real bug, and it stayed silent on the one confirmed real bug in the corpus. The phrase does not describe this rule any more, and a clean VL001 run is not evidence that your authorization is correct.
The diagnosis was not a threshold that needed tuning. Every discriminator wide enough to reach the textbook case — an unvalidated authority with no init and no seeds — also fires on permissionless cranks, delegate designations and CPI forwards. Without protocol knowledge those shapes are indistinguishable from the bug, so the general rule is unfixable by construction, not by implementation.
What replaced it is a rule narrow enough to be checkable: an unproven authority that this instruction is writing into an account it is creating right now.
What it catches
A field of an Anchor #[derive(Accounts)] struct, when all three of these hold:
- It is a raw
AccountInfo<'info>orUncheckedAccount<'info>with an authority-like name, and nothing validates it — nosigner, noaddress = ..., noconstraint = ..., noseedsof its own, and nohas_oneor namespaced constraint on a sibling that names it. A name counts when it equals or ends in_plus one ofauthority,admin,owner,signer,payer,delegate,manager,governance— sopool_authorityandupdate_authoritycount, and so does a bareadmin. - Its name appears as a whole identifier inside the
seeds = [...]of a sibling account that this same instruction creates withinitorinit_if_needed. - The handler body reads that field's
key(), which is what turns an account that was merely handed over into the designated authority of the new account. The handler is found across files, through functions takingContext<S>and methods onimpl S, in the same crate.
All three are required. Together they describe an instruction anyone can call while naming an arbitrary authority.
Severity
Medium, and it never changes. VL001 does not break CI at the default --fail-on high — VL002 is the only rule that can report High.
Medium is the honest level because the finding means confirm this is intended, not you have a hole. A permissionless registration instruction and this bug are the same code; only the protocol's intent separates them, and a static analyser cannot read intent. Treat the finding as a question addressed to the person who knows the answer.
Vulnerable code
#[derive(Accounts)]
pub struct InitializeAccount<'info> {
#[account(
init,
payer = fee_payer,
space = 8 + 32,
seeds = [b"account", authority.key().as_ref()],
bump
)]
pub user_account: Account<'info, UserAccount>,
/// CHECK: only used for PDA seed derivation, no signing required
pub authority: AccountInfo<'info>,
#[account(mut)]
pub fee_payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn initialize_account(ctx: Context<InitializeAccount>) -> Result<()> {
let account = &mut ctx.accounts.user_account;
account.authority = ctx.accounts.authority.key();
Ok(())
}
The comment on authority states the author's belief exactly: the account is "only used for PDA seed derivation". The next four lines write its key into the account being created, as that account's owner of record.
Why this is dangerous
The attacker calls initialize_account and passes any public key they like as authority. They do not need that key's signature, because nothing in the struct or the handler asks for one. Two things follow, and both are exploitable:
Seizing someone else's account slot. The PDA address is a pure function of the seeds, so there is exactly one user_account per authority, and init refuses to run a second time. An attacker who front-runs a victim's first interaction creates the victim's account with initial state of the attacker's choosing — a fee recipient, a delegate, a paused flag, a stale config. The victim can never create their own; the address is taken. Every later instruction that trusts has_one = authority now operates on state the attacker chose.
Manufacturing standing. If the created account is later read as proof that its authority is registered, whitelisted, or entitled to something, an attacker mints those accounts in bulk naming parties who never agreed to be named.
This is not hypothetical. In marginfi-v2, MarginfiAccountInitializePda.authority shipped as an UncheckedAccount named in the seeds of the account being initialised. Commit 95a4c26, "Authority must now sign to init account as PDA", fixed it, and the whole diff is one line: pub authority: UncheckedAccount<'info> becomes pub authority: Signer<'info>. VL001 fires on exactly the line that commit changed, and is silent on the commit after it.
Across thirteen open-source Solana codebases — roughly 2,100 files — the rule reports four findings in total.
Fixed code
#[derive(Accounts)]
pub struct InitializeAccount<'info> {
#[account(
init,
payer = fee_payer,
space = 8 + 32,
seeds = [b"account", authority.key().as_ref()],
bump
)]
pub user_account: Account<'info, UserAccount>,
pub authority: Signer<'info>,
#[account(mut)]
pub fee_payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
One type change. Signer<'info> makes the runtime require the signature before your handler ever runs, so the only account that can be designated is one that agreed to be. Note that the payer stays separate: paying rent is not the same act as authorising the designation, which is why a bare payer = fee_payer does not silence this rule.
If the authority genuinely cannot sign — a governance PDA, an account designated by an existing owner — bind it to something already proven instead:
#[derive(Accounts)]
pub struct DesignateDelegate<'info> {
#[account(has_one = admin)]
pub config: Account<'info, Config>,
pub admin: Signer<'info>,
/// CHECK: named by the admin, who signed above
#[account(constraint = new_authority.key() != Pubkey::default())]
pub new_authority: AccountInfo<'info>,
}
Here the account that is not being created (config) proves the admin's standing, and the admin signs. The relationship was established by an earlier — and therefore signed — instruction, which makes the unsigned field a subject rather than an actor.
Why /// CHECK: is not a signal
This is the first question every Anchor developer asks, so it is worth answering directly: VL001 ignores /// CHECK: comments entirely — neither as a reason to fire nor as a reason to stay quiet.
Anchor requires that doc comment above every AccountInfo and UncheckedAccount. Without it the program does not compile. So it sits above thoroughly validated fields and completely unvalidated ones alike, and it carries no information about which is which. When the old broad rule was measured, 19 of its 27 findings sat under a /// CHECK: — and so did most of the correct code around them.
That is also why suppression comments are searched upward through the whole block of comments and attributes rather than on the single line above a finding: on an Anchor account field, the line above is always occupied.
What the rule does not flag
Explicit, because half the questions about a linter are "why is it silent" and the other half are "why is it shouting".
- Missing signer checks in general. An unvalidated authority with no
initand no seeds nearby is not reported. Neither is a privileged instruction whose authority is simply never checked, if its key is not baked into a freshly created account. - Fields that validate themselves.
#[account(signer)],#[account(address = ...)],#[account(constraint = ...)]and a field with its ownseedsall count as validation and silence the finding. - Fields bound by a settled sibling. A
has_one = authority, aconstraint = ...naming the field, or a namespaced constraint such astoken::authority = authorityon a sibling that this instruction is not creating. The exclusion ofinitsiblings is the point: ahas_oneagainst an account this handler is initialising compares against whatever this instruction just wrote, and proves nothing. - Structs with a proven signing authority. If the struct already contains an authority-named
Signerthat is itself bound to a settled sibling, that party is authorising the designation — the ordinary "existing owner names a new owner" pattern — and no field in the struct is reported. - Fields the handler proves signed. A direct
authority.is_signertest, or the field being passed into a helper that performs the test, silences the finding. Both the qualifiedctx.accounts.authorityspelling and the bare local produced by destructuring are recognised. - Accounts structs with no handler in the crate. Nothing reads the key, so condition 3 fails. This is what keeps CPI accounts structs — including Anchor's own
anchor-spl— out of the results. - Fields not named like an authority. The marker list is deliberately short. A missed unusual name costs a finding; a false positive costs your trust in the tool.
- Test, benchmark and fuzz code, which is never scanned. See what gets scanned.
Three limits worth stating plainly, because they are the shapes where VL001 can be wrong:
- Handler bodies are read one call deep. A program that verifies
is_signertwo or more calls away will still be flagged. - Accounts arriving through
remaining_accountsare invisible — they have no field declaration to inspect. #[access_control(...)]is not followed by this rule. VaultLint resolves the attribute for VL002 and VL005, but not here: VL001 reaches handler bodies through the cross-file use-site index rather than one file's syntax tree, and the same merged body that would silence it would also feed the trigger that fires it. The narrower reason is that the fact VL001 wants — that the authority signed — is one Anchor programs state withSigner<'info>or a constraint, where the rule already sees it, not in a guard function. A program that checksis_signerinside an#[access_control]function is still flagged.
Suppressing a finding
If the permissionless designation is deliberate, say so in the code:
// vaultlint:allow VL001 — registration is permissionless by design
/// CHECK: only used for PDA seed derivation
pub authority: AccountInfo<'info>,
The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines (including multi-line ones) and blank lines directly above it. The upward walk stops at the first line that is none of those. That block search is what makes suppression usable here at all, since Anchor occupies the line above the field with the mandatory /// CHECK:.
The rule id is matched on a word boundary, so vaultlint:allow VL0011 does not silence VL001. A bare vaultlint:allow with no id is intentionally not honoured — silencing every rule at once should be explicit.
References
- marginfi-v2
95a4c26— "Authority must now sign to init account as PDA", the fix this rule is calibrated against. - Sealevel Attacks — signer authorization, the textbook version of the broader class.
- Anchor documentation on account types and constraints.
- Solana docs — Program Derived Addresses.
- Common Solana Program Vulnerabilities — the wider vulnerability class in context.
- vaultlint on GitHub — the rule's source is
src/rules/init_authority.rs.
Run this check on every PR.
VaultLint is an offline security linter for Solana and Anchor programs. Five narrow rules, hand-written, no telemetry.
See all five rules