Skip to content
ecdsa.com
← All articles

Bitcoin message signing: BIP-137 and BIP-322 explained

"Sign this message with your address" sounds like one operation. It is two incompatible formats, three header conventions and a standard that changed its encoding this year. Here is what each format signs, which wallets produce what, and verifiers checked against Bitcoin Core and the BIP's own test vectors.

Short answer. There are two formats. The legacy one, specified after the fact as BIP-137, is a 65-byte recoverable ECDSA signature over "\x18Bitcoin Signed Message:\n" ‖ length ‖ message; it only proves control of a key, and its first byte (27 to 42) says which address type the signer meant. Bitcoin Core signs it only for legacy P2PKH addresses, and wallets disagree on the header for SegWit addresses. BIP-322 (status Complete) proves control of an address: it builds a virtual transaction that spends from the address and the signature is the witness of that spend, so it works for P2WPKH, P2TR, multisig and scripts. Since version 2.0.0 of the BIP, signatures carry a prefix: smp (simple), ful (full) or pof (proof of funds). Verify with a library that understands the prefix, and accept unprefixed simple signatures from older wallets.

The legacy format (BIP-137)

The digest is double SHA-256 of a fixed prefix, the message length as a Bitcoin varint, and the message bytes. The signature is header ‖ r ‖ s, 65 bytes, usually base64-encoded. The header carries the recovery ID (0 to 3), which lets the verifier compute the public key from the signature, plus a constant for the key and address type (BIP-137, status Deployed):

HeaderMeaning
27-30P2PKH, uncompressed public key
31-34P2PKH, compressed public key
35-38P2SH-P2WPKH (nested SegWit)
39-42P2WPKH (native SegWit, bc1q…)

Verification recovers the public key, derives the address of the type the header names, and compares. There is no transaction, no script: the format cannot express "this multisig address" or "this Taproot address", only "the holder of this key". BIP-137 itself points to BIP-322 for anything beyond that.

The module below implements the legacy format and, further down, BIP-322 simple signatures for P2WPKH, with @noble/curves and @scure/base:

btcmsg.mjs (@noble/curves 2.4.0, @noble/hashes 2.4.0, @scure/base 2.4.0)
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bech32, bech32m, createBase58check } from "@scure/base";

const b58 = createBase58check(sha256);
const sha256d = (b) => sha256(sha256(Uint8Array.from(b)));
const hash160 = (b) => ripemd160(sha256(b));
const utf8 = (s) => new TextEncoder().encode(s);
const cat = (...parts) => Uint8Array.from(parts.flatMap((p) => [...p]));
const varint = (n) => (n < 0xfd ? [n] : [0xfd, n & 0xff, n >> 8]); // messages under 64 KiB
export const NETWORKS = {
  mainnet: { p2pkh: 0x00, p2sh: 0x05, hrp: "bc" },
  regtest: { p2pkh: 0x6f, p2sh: 0xc4, hrp: "bcrt" },
};

/** Addresses of one compressed public key. */
export function addressesOf(pubkey, net = NETWORKS.mainnet) {
  const h = hash160(pubkey);
  const redeem = cat([0x00, 0x14], h); // P2SH-P2WPKH redeem script
  return {
    p2pkh: b58.encode(cat([net.p2pkh], h)),
    "p2sh-p2wpkh": b58.encode(cat([net.p2sh], hash160(redeem))),
    p2wpkh: bech32.encode(net.hrp, [0, ...bech32.toWords(h)]),
  };
}

/** scriptPubKey of an address, for BIP-322's message_challenge. */
export function scriptPubKey(address, net = NETWORKS.mainnet) {
  if (address.toLowerCase().startsWith(`${net.hrp}1`)) {
    const isV0 = address.toLowerCase().startsWith(`${net.hrp}1q`);
    const { words } = (isV0 ? bech32 : bech32m).decode(address);
    const program = (isV0 ? bech32 : bech32m).fromWords(words.slice(1));
    return cat([words[0] === 0 ? 0x00 : 0x50 + words[0], program.length], program);
  }
  const raw = b58.decode(address);
  if (raw[0] === net.p2pkh) return cat([0x76, 0xa9, 0x14], raw.slice(1), [0x88, 0xac]);
  if (raw[0] === net.p2sh) return cat([0xa9, 0x14], raw.slice(1), [0x87]);
  throw new Error(`unsupported address ${address}`);
}

// ---------------------------------------------------------------------------- BIP-137 (legacy)
/** The digest every legacy message signature covers. */
export const legacyDigest = (message) => {
  const m = utf8(message);
  return sha256d(cat([0x18], utf8("Bitcoin Signed Message:\n"), varint(m.length), m));
};
const HEADER_BASE = { p2pkh: 31, "p2sh-p2wpkh": 35, p2wpkh: 39 }; // compressed keys

/** 65-byte compact signature, base64. headerFor "electrum" keeps 31-34 for every address type. */
export function signLegacy(message, privateKey, type = "p2pkh", headerFor = "bip137") {
  const sig = secp256k1.Signature.fromBytes(secp256k1.sign(legacyDigest(message), privateKey, { prehash: false, format: "recovered" }), "recovered");
  const base = headerFor === "electrum" ? 31 : HEADER_BASE[type];
  return Buffer.from(cat([base + sig.recovery], sig.toBytes("compact"))).toString("base64");
}

/** Recovers the key and checks it against the address; strict mode also checks the header type. */
export function verifyLegacy(address, message, signatureBase64, { net = NETWORKS.mainnet, strict = false } = {}) {
  const raw = Buffer.from(signatureBase64, "base64");
  if (raw.length !== 65) throw new Error(`expected 65 bytes, got ${raw.length}`);
  const header = raw[0];
  if (header < 27 || header > 42) throw new Error(`header byte ${header} out of range`);
  const recovery = (header - 27) % 4;
  const typeFromHeader = header >= 39 ? "p2wpkh" : header >= 35 ? "p2sh-p2wpkh" : "p2pkh";
  const compressed = header >= 31;
  const sig = secp256k1.Signature.fromBytes(raw.subarray(1), "compact").addRecoveryBit(recovery);
  const pubkey = sig.recoverPublicKey(legacyDigest(message)).toBytes(compressed);
  const candidates = addressesOf(pubkey, net);
  const match = Object.entries(candidates).find(([, a]) => a === address)?.[0];
  if (!match) return { valid: false };
  if (strict && match !== typeFromHeader) return { valid: false, reason: `header says ${typeFromHeader}, address is ${match}` };
  return { valid: true, type: match, header };
}

// ---------------------------------------------------------------------------- BIP-322 (simple, P2WPKH)
const tag = sha256(utf8("BIP0322-signed-message"));
/** sha256(tag || tag || m), a BIP-340 tagged hash of the raw message. */
export const messageHash = (message) => sha256(cat(tag, tag, utf8(message)));
const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, n >>> 24];
const zero64 = new Array(8).fill(0);

/** The two virtual transactions of BIP-322, serialized without witness. Returns txids too. */
export function virtualTransactions(address, message, net = NETWORKS.mainnet) {
  const challenge = scriptPubKey(address, net);
  const toSpend = cat(
    le32(0), [1], new Array(32).fill(0), le32(0xffffffff), // version 0, one input spending 000…000:FFFFFFFF
    [34, 0x00, 0x20], messageHash(message), le32(0), // scriptSig OP_0 PUSH32[message_hash], nSequence 0
    [1], zero64, [challenge.length], challenge, le32(0), // one output, value 0, scriptPubKey = challenge
  );
  const toSpendId = sha256d(toSpend); // internal byte order
  const toSign = cat(le32(0), [1], toSpendId, le32(0), [0], le32(0), [1], zero64, [1, 0x6a], le32(0));
  const display = (h) => Buffer.from(h).reverse().toString("hex");
  return { challenge, toSpendId, toSpendTxid: display(toSpendId), toSignTxid: display(sha256d(toSign)) };
}

/** Verifies a BIP-322 simple signature for a P2WPKH address. Accepts the "smp" prefix or none. */
export function verifySimpleP2wpkh(address, message, signature, net = NETWORKS.mainnet) {
  const b64 = signature.startsWith("smp") ? signature.slice(3) : signature;
  const w = Buffer.from(b64, "base64");
  // Witness stack: count, then <len><DER sig + sighash byte>, <len><33-byte pubkey>.
  if (w[0] !== 2) throw new Error("expected a two-item witness stack");
  const sigWithType = w.subarray(2, 2 + w[1]);
  const pubkey = w.subarray(3 + w[1], 3 + w[1] + w[2 + w[1]]);
  const { challenge, toSpendId } = virtualTransactions(address, message, net);
  if (challenge[0] !== 0x00 || challenge[1] !== 20) throw new Error("not a P2WPKH address");
  if (Buffer.compare(Buffer.from(hash160(pubkey)), Buffer.from(challenge.subarray(2))) !== 0) return false;
  if (sigWithType.at(-1) !== 0x01) throw new Error("only SIGHASH_ALL is allowed");
  // BIP-143 digest of to_sign input 0, spending to_spend:0 worth 0 sat.
  const scriptCode = cat([0x19, 0x76, 0xa9, 0x14], challenge.subarray(2), [0x88, 0xac]);
  const outputs = cat(zero64, [1, 0x6a]);
  const preimage = cat(
    le32(0), sha256d(cat(toSpendId, le32(0))), sha256d(le32(0)), toSpendId, le32(0),
    scriptCode, zero64, le32(0), sha256d(outputs), le32(0), le32(1),
  );
  const der = sigWithType.subarray(0, -1);
  return secp256k1.verify(der, sha256d(preimage), pubkey, { prehash: false, format: "der", lowS: true });
}

What Bitcoin Core supports

Bitcoin Core's signmessage and verifymessage RPCs implement the legacy format, and only for legacy addresses. A regtest node, one address of each type:

run.sh (bitcoin/bitcoin:31.1)
#!/bin/sh
# Bitcoin Core in regtest: which address types can sign and verify messages, and the exact errors.
set -e
DATA=/tmp/regtest
mkdir -p "$DATA"
bitcoind -regtest -datadir="$DATA" -daemonwait -fallbackfee=0.0001 >/dev/null
cli() { bitcoin-cli -regtest -datadir="$DATA" "$@"; }
cli -named createwallet wallet_name=lab >/dev/null
echo "$(bitcoind -version | head -1)"

MSG="ecdsa.com lab, 2026-10-20"
for type in legacy p2sh-segwit bech32 bech32m; do
  addr=$(cli getnewaddress "" "$type")
  printf '%-12s %s\n' "$type" "$addr"
  if sig=$(cli signmessage "$addr" "$MSG" 2>/tmp/err); then
    echo "             signmessage: $sig"
    echo "             verifymessage: $(cli verifymessage "$addr" "$sig" "$MSG")"
    echo "$addr $sig" > "/w/core-$type.txt"
  else
    echo "             signmessage: $(tr '\n' ' ' < /tmp/err)"
  fi
done

# A signature made elsewhere for a segwit address: Core cannot even attempt it.
seg=$(cli getnewaddress "" bech32)
leg=$(cut -d' ' -f2 /w/core-legacy.txt)
echo "verifymessage with a bech32 address: $(cli verifymessage "$seg" "$leg" "$MSG" 2>&1 | tr '\n' ' ')"
cli stop >/dev/null
Output
Bitcoin Core daemon version v31.1.0 bitcoind
legacy       mnaZRqP42LwTsVNQGfSb1eeTMvVzkaMoWp
             signmessage: ICZ/Fs0I/i7XNJbC+ubYkwpDZXI0uFZU1oaqrSaJlS+hYavTSzdfPKmRkLJrUdi6/RXl9WsYNEW5OU9l9hfMHCI=
             verifymessage: true
p2sh-segwit  2MyhmVFuFbTr4kAF1S4TtVmjEgkdouEntKK
             signmessage: error code: -3 error message: Address does not refer to key 
bech32       bcrt1qulxz0zapcld55a5twc9k88028y6dyxrggxug0n
             signmessage: error code: -3 error message: Address does not refer to key 
bech32m      bcrt1pq8x39vj0qay20825g8m3kffzx7sqwu9lfzmtjejta94luunwkmfsdc0hnd
             signmessage: error code: -3 error message: Address does not refer to key 
verifymessage with a bech32 address: error code: -3 error message: Address does not refer to key 

Every SegWit and Taproot address fails with Address does not refer to key, both for signing and for verifying a signature someone else made. The legacy signature's first byte, 31 or 32 in these runs (base64 H or I), marks a compressed P2PKH key.

Our implementation produces the same bytes as Bitcoin Core. With a key generated for the run and imported into the regtest wallet as a pkh() descriptor, both signed the same message:

Output: legacy-prepare.mjs, then crosscheck.sh in bitcoin/bitcoin:31.1
address mk1giEaBaAFPXNRMQcZNptPAp9kJmttKyp
core signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
our signature:  H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
identical: yes
core verifymessage(our signature): true

The signatures are identical because both use deterministic nonces (RFC 6979) and low-S normalization; a signer that uses random nonces produces a different, equally valid signature each time.

The header problem for SegWit addresses

Wallets that sign legacy-format messages for SegWit addresses do not agree on the header. Electrum signs with header 27 to 34 for every address type and, when verifying, tries every single-key address type the recovered key could have (electrum/bitcoin.py). Trezor follows BIP-137 and adds 4 for P2SH-P2WPKH and 8 for P2WPKH, with an option (no_script_type) to produce the Electrum form instead (trezor-firmware, sign_message.py). A verifier therefore has to choose between a strict check (the header must name the address's type) and a loose one (any address of the recovered key). The two conventions, same key and message, against our verifier and bip322-js 4.0.0:

headers.mjs
// Same key, same message, P2WPKH address: the BIP-137 header and the Electrum convention, and who accepts which.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { Verifier } from "bip322-js";
import { addressesOf, signLegacy, verifyLegacy } from "./btcmsg.mjs";

const priv = secp256k1.utils.randomSecretKey();
const { p2pkh, p2wpkh } = addressesOf(secp256k1.getPublicKey(priv, true));
const message = "ecdsa.com lab, 2026-10-20";
const lib = (address, sig, strict) => {
  try {
    return Verifier.verifySignature(address, message, sig, strict);
  } catch (e) {
    return `throws: ${e.message}`;
  }
};
for (const [label, sig] of [
  ["BIP-137 header (39-42)", signLegacy(message, priv, "p2wpkh", "bip137")],
  ["Electrum header (31-34)", signLegacy(message, priv, "p2wpkh", "electrum")],
]) {
  const header = Buffer.from(sig, "base64")[0];
  const loose = verifyLegacy(p2wpkh, message, sig);
  const strict = verifyLegacy(p2wpkh, message, sig, { strict: true });
  console.log(`${label}: header ${header}`);
  console.log(`  ours, loose:  ${loose.valid}   ours, strict: ${strict.valid}${strict.reason ? ` (${strict.reason})` : ""}`);
  console.log(`  bip322-js, loose: ${lib(p2wpkh, sig, false)}   bip322-js, strict: ${lib(p2wpkh, sig, true)}`);
  console.log(`  same signature for the P2PKH address of the key: ours, loose ${verifyLegacy(p2pkh, message, sig).valid}`);
}
Output (node:24.22.0-bookworm-slim)
BIP-137 header (39-42): header 39
  ours, loose:  true   ours, strict: true
  bip322-js, loose: true   bip322-js, strict: true
  same signature for the P2PKH address of the key: ours, loose true
Electrum header (31-34): header 31
  ours, loose:  true   ours, strict: false (header says p2pkh, address is p2wpkh)
  bip322-js, loose: true   bip322-js, strict: false
  same signature for the P2PKH address of the key: ours, loose true

Loose verification accepts both, and also accepts the same signature as proof for the key's P2PKH address. That is the honest meaning of a legacy signature: whoever holds this key, for every address derived from it. Strict verification rejects every Electrum-style SegWit signature. If you must accept legacy signatures for SegWit addresses, verify loosely and record that what you verified is control of a key.

BIP-322: proving control of an address

BIP-322 (bip-0322.mediawiki, status Complete, version 2.0.0) reuses the transaction machinery instead of inventing a message format. Two virtual transactions are built:

  • to_spend: version 0, one input spending the non-existent outpoint 000…000:FFFFFFFF with a scriptSig of OP_0 PUSH32[message_hash], one output of 0 satoshis whose scriptPubKey is the address being proven. message_hash is a BIP-340 tagged hash, SHA256(SHA256(tag) ‖ SHA256(tag) ‖ message) with tag BIP0322-signed-message.
  • to_sign: version 0, one input spending to_spend:0, one output of 0 satoshis to OP_RETURN.

Signing is spending to_spend:0 in to_sign as a wallet would spend any output of that address. Verification checks that spend under the standard script rules (SIGHASH_ALL or Taproot's default, low-S and strict DER, CLEANSTACK and others). Because neither transaction is valid on the network, a signature cannot be broadcast to move funds.

Encodings:

VariantPrefixContentUse
Simplesmpthe witness stack, base64native SegWit and Taproot, no time locks
Fullfulthe whole signed to_sign, base64any script, including nested SegWit, legacy and time locks
Full (proof of funds)pofa finalized PSBT of to_sign with additional inputsproving control of specific UTXOs
Legacynonethe BIP-137 signatureallowed only for legacy P2PKH addresses

The variant prefixes are new in version 2.0.0, and signers MUST now include them. For compatibility "a verifier might assume the simple variant in the absence of a prefix", which is how signatures from wallets built against the earlier text keep working. Verification has three outcomes, not two: valid, invalid, or inconclusive when the verifier cannot evaluate the scripts involved.

Multisig and hardware wallets: signing through PSBT

For a multisig address the witness needs signatures from several keys, held by different people or devices. BIP-322 handles this with the tools multisig users already have: the to_sign transaction is passed around as a PSBT with a new global field, PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (type 0x09), carrying the message. A signer that sees that field, a first input spending output 0 of the matching to_spend, and a single zero-value OP_RETURN output must show the user the message and the address rather than a transaction, and then signs as it would sign a spend. Each cosigner adds a partial signature; the finalized result becomes a ful signature. A device that does not recognize the field sees what looks like a transaction spending 0 satoshis, which is why wallet support for this flow matters more than the cryptography.

Two reference implementations are listed in the BIP: a Bitcoin Core pull request for basic support (#24058), which was closed without being merged, and a btcd pull request with complete support (#2521), the source of the generated test vectors and still open on 20 October 2026. Neither ships in a release, which is part of why the wallets and libraries in the table below that support BIP-322 each implement it themselves.

Checked against the BIP's test vectors

BIP-322 ships basic test vectors: message hashes and transaction IDs for three messages, and simple signatures for a P2WPKH address. Verifying them needs only the address, message and signature. The same signatures, with and without the new prefix, went to bip322-js 4.0.0 as well:

vectors.mjs
// BIP-322's published test vectors (addresses, messages and signatures only), against btcmsg.mjs and bip322-js 4.0.0.
import { readFileSync } from "node:fs";
import { Verifier } from "bip322-js";
import { messageHash, verifySimpleP2wpkh, virtualTransactions } from "./btcmsg.mjs";

const vectors = JSON.parse(readFileSync("bip322-basic-test-vectors.json", "utf8"));
for (const v of vectors.tx_hashes) {
  const { toSpendTxid, toSignTxid } = virtualTransactions(v.address, v.message);
  const ok = Buffer.from(messageHash(v.message)).toString("hex") === v.message_hash && toSpendTxid === v.to_spend_tx_hash && toSignTxid === v.to_sign_tx_hash;
  console.log(`hashes for ${JSON.stringify(v.message).padEnd(16).slice(0, 16)} message_hash, to_spend, to_sign match: ${ok}`);
}
for (const v of vectors.simple.filter((x) => x.type === "p2wpkh")) {
  for (const sig of v.bip322_signatures) {
    const ours = verifySimpleP2wpkh(v.address, v.message, sig);
    const call = (s) => { try { return Verifier.verifySignature(v.address, v.message, s); } catch (e) { return `throws: ${e.message}`; } };
    console.log(`${JSON.stringify(v.message).padEnd(14)} ${sig.slice(0, 12)}...  ours: ${ours} | bip322-js: ${call(sig)} | bip322-js, prefix removed: ${call(sig.slice(3))}`);
  }
}
const wrong = vectors.error.find((e) => e.description.startsWith("wrong message for valid simple p2wpkh"));
console.log(`error vector "${wrong.description}": ours ${verifySimpleP2wpkh(wrong.address, wrong.message, wrong.signature)}`);
Output (node:24.22.0-bookworm-slim)
hashes for ""               message_hash, to_spend, to_sign match: true
hashes for "Hello World"    message_hash, to_spend, to_sign match: true
hashes for "UTF-8 support:  message_hash, to_spend, to_sign match: true
""             smpAkcwRAIgM...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
""             smpAkgwRQIhA...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World"  smpAkcwRAIgZ...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World"  smpAkgwRQIhA...  ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
error vector "wrong message for valid simple p2wpkh signature (empty message was signed)": ours false

The implementation above reproduces every hash and verifies every simple P2WPKH signature, prefixed or not, and rejects the error vector. bip322-js 4.0.0, the widely used JavaScript library, does not understand the smp prefix: it decodes the prefix as base64 and fails with buffer too small, while the same signature without the prefix verifies. Signatures produced by newer wallets will therefore fail in services built on it until it is updated; stripping a known prefix before calling it is a workable bridge for simple signatures.

Who produces and accepts what

From each project's source code, or a test run where marked, as of October 2026:

SoftwareLegacy (BIP-137)BIP-322Notes
Bitcoin Core 31.1P2PKH onlynotested above
Electrum (master)P2PKH, P2WPKH, P2SH-P2WPKH, header 27-34noverifies both header conventions; refuses addresses without a single key
Trezor firmware (main)P2PKH, P2SH-P2WPKH, P2WPKH, BIP-137 headersnoTaproot: "Unsupported script type"
Sparrow (drongo master)yessimple, with smp/ful/pof prefixesverification: simple variant, single-key P2WPKH and P2TR
bip322-js 4.0.0yes, loose or strictsimple, P2WPKH, P2SH-P2WPKH, single-key P2TRno prefixes; see the note above

The practical consequences: a Taproot address can only be proven with BIP-322; a multisig address cannot be proven with the legacy format at all; and a service that wants to accept proofs from users of all these wallets needs to accept legacy signatures (loosely, for SegWit), unprefixed BIP-322 simple signatures, and prefixed ones.

How to verify in a service

  1. Fix the message on your side. Include your domain, a nonce or timestamp, and the purpose ("Proof of ownership for example.com, nonce 7f3a…"), and accept each nonce once, exactly as in Sign-In with Ethereum. A signature over a generic text can be replayed to any other service that asks for the same text.
  2. Compare the exact bytes. Both formats sign the raw message. Electrum's interface strips leading and trailing whitespace before signing; a trailing newline from a copy and paste, or CRLF line endings, produces a different hash and an "invalid signature".
  3. Detect the format, in this order: a smp, ful or pof prefix; a base64 string that decodes to exactly 65 bytes with a first byte between 27 and 42 (legacy); otherwise try an unprefixed BIP-322 simple signature.
  4. Decide what a legacy signature proves for SegWit addresses: loose verification proves control of the key, which for single-key addresses is control of the address.
  5. Treat inconclusive as not verified. A verifier that cannot evaluate a script (time locks, unusual scripts) must not report success.
  6. For proof of funds, check the chain. A pof signature proves control of the listed outputs when signed; whether they are still unspent is a separate lookup, and it never proves they are all the outputs of an address.

Sources