SIGHASH Is a Security Parameter, Not a Construction Detail

Maciej Lewandowski · August 2026 · Field notes from custody & wallet infrastructure

Every Bitcoin signature ends with one byte that most transaction builders treat as boilerplate: the sighash flag. Set it to SIGHASH_ALL, move on, never think about it again. That instinct is correct right up until the day someone, or something, sets it to anything else. Because that byte does not tweak how the signature is encoded. It decides what the signature means: which parts of the transaction the key holder actually vouched for, and which parts anyone on the internet is free to rewrite after the fact.

In a custody system, that makes it a security parameter. It deserves the same treatment as a withdrawal limit or a destination allowlist, and in most codebases it gets the same treatment as a serialization constant. This post is about closing that gap.

What the flag actually decides

A signature never covers the raw transaction. It covers a hash of a reconstructed transaction, and the sighash flag controls what goes into that reconstruction:

  • SIGHASH_ALL (0x01). The signature commits to all inputs and all outputs. Nobody can add, remove or redirect anything without invalidating it. This is the default, and outside of a handful of deliberate protocols, the only one you ever want to see.
  • SIGHASH_NONE (0x02). Commits to the inputs but to no outputs at all. Whoever holds the signed transaction can send the coins anywhere they like. It is a signed blank cheque, and it looks exactly like a normal signature to anyone not checking the flag.
  • SIGHASH_SINGLE (0x03). Commits to the inputs and to exactly one output: the one at the same index as the input being signed. Every other output is up for grabs.
  • ANYONECANPAY (0x80). A modifier that combines with the above. It shrinks the input commitment down to only your own input, so other parties can add theirs later without breaking your signature.

Read those again as an attacker would. Three of the four are, by design, permission for someone else to modify a transaction you signed. That is sometimes exactly what you want. It is never something you want by accident.

INPUTS OUTPUTS SIGHASH_ALL every input signed every output signed SIGHASH_NONE every input signed anyone may rewrite SIGHASH_SINGLE every input signed only output at same index + ANYONECANPAY only your own input per the base flag solid = committed by the signature dashed = still rewritable by anyone

The footguns are not theoretical

The legacy sighash algorithm has a famous bug. If you sign an input with SIGHASH_SINGLE and there is no output at that input's index, the algorithm does not fail. It quietly returns the constant 0x0100...00 (the value 1 as a 32-byte hash) as the digest to sign. Sign that, and you have produced a signature over a fixed, publicly known value. It is valid for that input in any transaction whatsoever. Anyone who sees it can build a transaction sweeping that coin wherever they please, and your signature blesses it. A bounds check that should have been an error became a universal skeleton key, and it sat in consensus code for years because consensus bugs are forever.

The combinations are their own hazard class. SIGHASH_NONE | ANYONECANPAY commits to your one input and to nothing else: no other inputs, no outputs. The practical semantics are "here is my coin, take it". There are almost no legitimate reasons for a custody signer to ever produce that signature, and exactly one signature of that shape leaking is enough to lose the input.

The uncomfortable property of every non-ALL sighash is that the resulting signature is indistinguishable from a safe one unless you inspect the flag. The transaction in your database looks fine. The transaction that eventually confirms is the one your counterparty preferred.

So why do the flags exist at all?

Because collaborative transaction construction is real and useful. The flags are not a design mistake; they are an API for multi-party flows.

SIGHASH_ALL | ANYONECANPAY lets you sign your input and your intended outputs while leaving the door open for more inputs. That is the primitive behind crowdfunding-style flows and one form of fee bumping: someone can attach an extra input to pay for a fee increase without asking you to re-sign. PayJoin-style constructions lean on the same idea, both parties contribute inputs to a payment, and nobody's signature has to die when the other side's contribution changes. SIGHASH_SINGLE pairs an input with an output, which is the building block for certain swap and exchange-offer patterns.

All legitimate. All narrow. Each one is a specific protocol where the flexibility is the point, negotiated in advance, with both sides knowing exactly which commitments are open. That is very different from a general-purpose signing service that will put its key on whatever digest the request implies.

BIP-143 changed the threat model, not just the performance

Segwit's sighash algorithm (BIP-143) is usually described as a performance fix, and it is one: legacy sighashing re-hashed the whole transaction per input, so a transaction with many inputs did quadratic work, and adversarially shaped transactions made validation crawl. BIP-143 hashes linear amounts of data. But the more interesting change is what the signature now commits to: the amount of the input being spent.

Under legacy rules, a signer cannot see input values; it signs whatever prevout references it is given and trusts the caller about amounts. That enabled a genuinely nasty attack on hardware signers: lie to the device about the value of its own inputs, and the difference between reality and the lie is silently burned as fees. The device displays "sending 0.1, fee 0.0001" while the real transaction pays a miner a fortune. Combine that with a colluding or bribable miner and "fee" becomes "theft with extra steps". Because BIP-143 folds the input amount into the digest, a signature produced over a lied-about amount is simply invalid. The attack dies at the signing boundary, which is where attacks should die.

Taproot (BIP-341) finishes the thought. It introduces SIGHASH_DEFAULT (0x00) with the semantics of SIGHASH_ALL, and its digest commits to the amounts and scriptPubKeys of all inputs, not just the one being signed. The signer no longer needs to trust anyone's description of the transaction it is signing; the commitment structure verifies it. The historical arc is one-directional: every revision of the sighash algorithm commits to more, because every gap between what the signer saw and what the signature bound turned out to be exploitable.

The custody thesis: sighash is policy, not plumbing

Here is the argument in one sentence: the sighash flag decides what your key vouches for, and deciding what your keys vouch for is the job of the policy engine, not the transaction builder.

In practice that means the flag moves out of the code path that assembles transactions and into the layer that already holds your limits, your destination allowlists, your approval quorums. And the policy fails closed:

  • The signing service accepts SIGHASH_ALL, and SIGHASH_DEFAULT on Taproot inputs. Everything else is rejected before a key is touched, regardless of what the request says or which internal service sent it.
  • A non-ALL sighash is not a parameter, it is an exception: explicitly approved, tied to a specific named flow (a fee-bump path, a PayJoin integration), scoped to the wallets and templates that flow uses, and reviewed like any other policy change.
  • Every signature is audit-logged with its sighash flag. If you cannot answer "have we ever produced a non-ALL signature, and for what" from your logs, you do not know what your keys have promised.

The objection writes itself: "our builder only ever sets ALL, why add a check?" Because the builder is one code path among many, today. Libraries default sensibly until a refactor, a new integration, or a compromised dependency does not. A policy check at the signing boundary costs one comparison and turns a whole class of silent key-misuse bugs into loud, immediate rejections. That is the cheapest trade in security engineering.

A checklist, if you are holding keys

  1. Enforce SIGHASH_ALL (or SIGHASH_DEFAULT on Taproot) at the signing boundary; fail closed on everything else.
  2. Treat any other flag as a named, separately approved exception bound to a specific flow, never a request parameter.
  3. Log the sighash flag with every signature you ever produce.
  4. Prefer segwit and Taproot spend paths so signatures commit to amounts; treat remaining legacy inputs as the higher-risk path they are.
  5. If you must use SIGHASH_SINGLE anywhere, validate that the matching output index exists before signing, every time.
  6. Alert on any rejected non-ALL request: it is either a bug or a probe, and both deserve a human.

A signature is only as safe as the thing it commits to.