VL001 — Unproven authority on initialization

VL001 is not a general missing-signer-check detector. It reports one narrow shape, calibrated against a real Solana security fix, and deliberately leaves everything else alone.

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:

  1. It is a raw AccountInfo<'info> or UncheckedAccount<'info> with an authority-like name, and nothing validates it — no signer, no address = ..., no constraint = ..., no seeds of its own, and no has_one or namespaced constraint on a sibling that names it. A name counts when it equals or ends in _ plus one of authority, admin, owner, signer, payer, delegate, manager, governance — so pool_authority and update_authority count, and so does a bare admin.
  2. Its name appears as a whole identifier inside the seeds = [...] of a sibling account that this same instruction creates with init or init_if_needed.
  3. 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 taking Context<S> and methods on impl 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 highVL002 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

Vulnerable — anyone can create this account naming any authority
#[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

Fixed — the authority must sign for its own account to exist
#[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:

Also fixed — an already-proven party authorises the designation
#[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 init and 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 own seeds all count as validation and silence the finding.
  • Fields bound by a settled sibling. A has_one = authority, a constraint = ... naming the field, or a namespaced constraint such as token::authority = authority on a sibling that this instruction is not creating. The exclusion of init siblings is the point: a has_one against 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 Signer that 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_signer test, or the field being passed into a helper that performs the test, silences the finding. Both the qualified ctx.accounts.authority spelling 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_signer two or more calls away will still be flagged.
  • Accounts arriving through remaining_accounts are 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 with Signer<'info> or a constraint, where the rule already sees it, not in a guard function. A program that checks is_signer inside an #[access_control] function is still flagged.

Suppressing a finding

If the permissionless designation is deliberate, say so in the code:

Suppress with a reason
// 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

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