SIGHASH Is a Security Parameter, Not a Construction Detail
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.
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.
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, andSIGHASH_DEFAULTon 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
- Enforce
SIGHASH_ALL(orSIGHASH_DEFAULTon Taproot) at the signing boundary; fail closed on everything else. - Treat any other flag as a named, separately approved exception bound to a specific flow, never a request parameter.
- Log the sighash flag with every signature you ever produce.
- Prefer segwit and Taproot spend paths so signatures commit to amounts; treat remaining legacy inputs as the higher-risk path they are.
- If you must use
SIGHASH_SINGLEanywhere, validate that the matching output index exists before signing, every time. - 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.