PDAs as signers
A PDA has no private key. Nobody can sign a transaction with it the normal way. But programs need PDAs to be able to act on the chain, to authorize transfers out of vaults, to mint tokens from pools, to approve withdrawals from treasuries. The runtime's resolution is to let a program sign on behalf of any PDA derived under its own program ID, by submitting the seeds. The seeds are the signature. Once that one idea clicks, every pattern in real Solana code involving program-controlled funds works the same way.
The seeds are the signature
A normal account signs by producing a cryptographic signature with its private key. The runtime verifies the signature, sees that it matches the account's public key, and marks the account as a signer for the transaction. This is what a wallet does every time a user clicks "Approve."
A PDA cannot do this. Its address is a hash that lands off the secp256k1 curve, which means no private key exists that would produce that address. There is nothing to sign with.
What the runtime accepts instead is a re-derivation. When a program calls invoke_signed, it passes a list of seed sets along with the inner call. The runtime takes each seed set, appends the calling program's ID, runs the PDA derivation, and gets an address back. If that address matches an account in the call, the runtime marks it as a signer in the inner frame, exactly as if a real signature had been provided.
The key property: the calling program's ID is mixed into the derivation. A different program calling with the same seeds would derive a different PDA. So only the program whose ID was used at PDA creation can produce the right address by replaying the seeds. The seeds aren't a secret. The program ID is what makes the seeds work for one specific program and nobody else.
The mental model lines up with a corporate stamp. Anyone can describe what the stamp says. The seeds are public. But only an Acme employee can apply the Acme stamp, because they're the only ones who run under the Acme identity. The program ID is the identity. The seeds are the description of which stamp.
The whole mechanism comes down to those four steps. Your program promises "I am these seeds." The runtime checks the math, accepts the promise, and the inner program gets a signer it can verify like any other.
The signing pattern in Anchor
The raw invoke_signed syscall is workable but verbose. In practice you use Anchor's CpiContext::new_with_signer, which takes the signer seeds as a third argument and otherwise looks like the plain CpiContext::new used for non-PDA CPIs.
The full pattern for signing as a Vault PDA fits into six small pieces.
Three details in this code earn special attention.
The bump goes last in the seeds array, always. When find_program_address originally derived the canonical PDA, the algorithm appended the bump byte after the rest of the seeds and ran the hash. To re-derive that same address, you must replay the exact same input, in the exact same order. Move the bump to the front, or leave it out, and you'll derive a different address. The runtime will tell you so by rejecting the CPI, but the error message isn't always clear about which seed was wrong. Make a habit of putting the bump last and you save yourself the debugging.
The double-reference shape &[&[&[u8]]] is unusual enough that it's worth saying out loud. The outer slice contains one entry per PDA you're signing for. Each entry is itself a slice, and that inner slice is a list of byte slices, the actual seeds. Almost every program signs for exactly one PDA at a time, so the outer slice contains exactly one entry. The shape supports multiple PDAs because a single instruction can act on behalf of more than one program-derived account, but it's rare.
The lifetime gymnastics on mint_key are a Rust-specific gotcha. Writing ctx.accounts.mint.key().as_ref() inline creates a Pubkey temporary and immediately calls as_ref on it. The resulting &[u8] borrows from a value that's already going out of scope by the time you try to use it. Bind the Pubkey to a local variable first, and the slice can borrow from the local. The error you'll see if you forget is "temporary value dropped while borrowed."
A worked example: the Vault transfer
The canonical use case for PDA signing is a vault that holds tokens on behalf of a program. The setup is: a Vault PDA is derived from some seeds. A token account is created with the Vault PDA as its owner. To move tokens out of that account, the Token Program needs the owner to sign. The owner is a PDA, with no key. The program signs on its behalf via invoke_signed.
A few things are worth pointing out about this pattern.
First, the Vault PDA itself doesn't hold the tokens. The tokens live in a token account, which is a separate account with the mint, owner, and balance fields the SPL Token Program understands. The Vault PDA is the owner of that token account, in the same way a user's wallet would be the owner of their personal token account. The runtime knows which PDA owns which token account because that owner field was set when the token account was created, usually during the program's initialization.
Second, the Vault PDA is what gets passed as the authority field in the Transfer instruction. The Token Program reads the authority field, looks at the source token account's owner field, confirms they match, and checks the runtime signer set to confirm the authority signed. From the Token Program's perspective, this looks identical to a transfer authorized by a normal wallet. The Token Program has no concept of PDAs. It just sees a signed authority that owns the source account.
Third, the only thing your program needed to provide that a wallet-signed transfer would not provide is the seeds. Everything else is identical, including the instruction shape, the accounts list, and the amount. Once you internalize that the seeds replace a signature for accounts you control, every program-owned funds pattern in Solana follows the same template.
What can go wrong
There are a handful of failure modes specific to PDA signing that account for most of the questions developers ask in their first week. Worth naming them.
Forgetting the bump. The most common bug. Your seeds derive a different address, the runtime doesn't find a match in the call's account list, and you get an unauthorized-signer error. The fix is mechanical: put the bump byte at the end of the seeds array, every time.
Using the wrong bump. If you stored a bump on initialization and your account-fetching logic returns a stale or incorrect value, you'll derive a different PDA. Always read the bump from the account itself, never compute it fresh in a signing path. Re-running find_program_address would also work but costs significant compute units, so use the stored bump.
Lifetime errors. The &[&[&[u8]]] shape is fragile in Rust. Inline as_ref calls fail to compile. Bind your Pubkeys to locals before using them in seeds.
Seeds matching the wrong PDA. If your program manages many PDAs of the same kind, such as per-user vaults, per-pool authorities, or per-proposal escrows, the seeds in your CPI must match the specific PDA you're trying to act on behalf of. Mixing seeds for one user's vault into a transaction acting on another user's vault is a real bug that the compiler can't catch. Test these paths carefully.
Wrong program ID at derivation. If a PDA was derived under one program but you try to sign for it from a different program, the derivation produces a different address. This usually only happens during program upgrades where the program ID changed, but it's worth being aware of.
When PDA signing fails, the error is usually Cross-program invocation with unauthorized signer or writable account. The runtime is telling you that an account in the inner call needed to be a signer but wasn't, or needed to be writable but wasn't. For PDA signing failures, signer is almost always the missing flag. Walk back through the seeds, confirm the bump is right and last, and check that the PDA address you're trying to sign for is actually in your accounts list.
Why this design is the whole point
A program that can't hold authority on its own state is barely a program. It would have to ask a human to sign every action, the way a contract on a normal smart-contract platform would call out to a privileged caller. Solana's PDA signing model lets programs own state without any human in the loop. Vaults that auto-rebalance, escrows that release on time triggers, lending pools that liquidate undercollateralized positions, governance contracts that execute approved proposals, all of these need the program to act on behalf of accounts it controls. PDA signing is the mechanism that makes any of it possible.
Once you've signed your first transfer this way, every subsequent program you write that holds funds reuses the same six lines, with different seeds. The pattern is small. The implications are large.