3.8.8 Signature & Replay Issues
Off-chain signatures are how smart contracts trust off-chain actors. A user signs an order, a permit, a vote, a meta-transaction — and the contract verifies the signature before acting. The pattern unlocks gas-free user actions, gasless onboarding flows, multi-sig wallets, bridge transfers, decentralized exchanges, and almost every DeFi UX that doesn't require the user to submit each operation on-chain themselves.
The same pattern is where many of the largest smart contract losses have occurred. The Wormhole bridge ($325M, February 2022). The Poly Network bridge ($611M, August 2021). The Nomad bridge ($190M, August 2022). Each was a signature-verification bug — the contract trusted a signature it should not have trusted, or didn't bind the signature to the operation it was meant to authorize, or accepted a signature that should have been rejected.
This section covers the specific signature bugs that have produced these losses, with concrete code for each. The patterns are straightforward once you've seen them; the difficulty is that signature verification looks correct until it isn't, and the bugs hide behind cryptographic operations that developers tend to treat as black boxes. Trust the libraries — but understand what they're doing.
The section is organized in roughly increasing subtlety:
- Signature malleability — the textbook attack on raw
ecrecover - Missing chain ID — signatures replayable across chains
- Missing nonce / replayable signatures — signatures replayable within a chain
- Wrong domain separator (EIP-712) — signatures meant for one contract used at another
- Insufficient parameter binding — signature authorizes the operation but not specific outputs
- Signature aggregation and threshold bugs — multi-sig variants with their own failure modes
Signature Malleability
ECDSA signatures over secp256k1 (the curve Ethereum uses) have a mathematical property: for every valid signature (r, s, v), there is a second valid signature (r, n - s, v') that recovers to the same address. The two signatures verify identically but have different s values, so any system using the signature itself (rather than its (r, s, v) decomposition) as a unique identifier can be tricked into accepting both.
Vulnerable Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SignatureBasedAction {
mapping(bytes32 => bool) public usedSignatureHashes;
function executeAction(
bytes32 messageHash,
bytes calldata signature
) external {
// BUG: uses signature itself as the uniqueness key
bytes32 sigHash = keccak256(signature);
require(!usedSignatureHashes[sigHash], "signature already used");
address signer = _recover(messageHash, signature);
require(_isAuthorized(signer), "unauthorized");
usedSignatureHashes[sigHash] = true;
_doAction(signer);
}
}
An attacker who observes a valid (r, s, v) can compute (r, n - s, v') (the "malleable twin"), which is also a valid signature for the same message and signer. The contract's deduplication is based on keccak256(signature), which differs between the original and the twin, so the contract accepts both — letting the action execute twice.
The malleable twin is computable by anyone who sees the original signature on-chain, with no access to the signer's private key. The attack does not require breaking ECDSA; it exploits the fact that ECDSA signatures are not canonical by default.
Fixed Example: Reject High-s Signatures
The defense is to require signatures to use the "low-s" form. Half of all valid signatures are low-s; the malleable twin is the high-s form. Rejecting high-s signatures eliminates the second valid signature for every message.
function _recover(bytes32 messageHash, bytes calldata signature) internal pure returns (address) {
require(signature.length == 65, "bad signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(signature.offset)
s := calldataload(add(signature.offset, 32))
v := byte(0, calldataload(add(signature.offset, 64)))
}
// Reject high-s values to prevent malleability
require(
uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0,
"bad signature: high-s"
);
require(v == 27 || v == 28, "bad v");
return ecrecover(messageHash, v, r, s);
}
The threshold 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 is secp256k1n / 2 — the boundary between low-s and high-s. Signatures with s above this value are rejected.
Better Fix: Use OpenZeppelin's ECDSA Library
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SafeSignatureBasedAction {
using ECDSA for bytes32;
mapping(bytes32 => bool) public usedMessageHashes;
function executeAction(
bytes32 messageHash,
bytes calldata signature
) external {
// Deduplicate by message hash, not signature hash
require(!usedMessageHashes[messageHash], "message already used");
address signer = messageHash.recover(signature); // OZ enforces low-s
require(_isAuthorized(signer), "unauthorized");
usedMessageHashes[messageHash] = true;
_doAction(signer);
}
}
Two improvements:
-
OpenZeppelin's
ECDSA.recoverenforces low-s internally. Any high-s signature reverts. No custom assembly needed. -
Deduplicate by message hash, not signature. This is the more fundamental fix. The malleability attack works because the contract uses the signature as the unique identifier; using the message hash instead means both the original and the twin map to the same key, and the second attempt is rejected as a duplicate regardless of malleability.
Always prefer message-hash deduplication. The malleability issue is real but secondary — if your contract tracks "has this message been processed?" rather than "have I seen this exact signature?", malleability becomes irrelevant.
Missing Chain ID: Cross-Chain Replay
A signature signed for one chain should not be valid on another chain. If the signed message doesn't include the chain ID, an attacker can take a signature created for, say, Ethereum mainnet and replay it on Polygon, BSC, Arbitrum, or any other EVM chain where the same contract is deployed.
Vulnerable Example
contract CrossChainVulnerable {
function withdraw(
address recipient,
uint256 amount,
bytes calldata signature
) external {
// BUG: hash doesn't include chain ID
bytes32 messageHash = keccak256(abi.encode(recipient, amount, nonce[recipient]));
bytes32 ethSigned = MessageHashUtils.toEthSignedMessageHash(messageHash);
address signer = ECDSA.recover(ethSigned, signature);
require(_isAuthorized(signer), "unauthorized");
nonce[recipient]++;
IERC20(token).transfer(recipient, amount);
}
}
This contract is deployed on multiple chains. The owner signs a withdrawal of 100 tokens on Ethereum. An observer sees the transaction on Ethereum, copies the signature, and submits the same call on Polygon — where the same contract has the same nonce[recipient] and the same signer. The withdrawal happens twice.
Fixed Example: Include Chain ID
function withdraw(
address recipient,
uint256 amount,
bytes calldata signature
) external {
bytes32 messageHash = keccak256(abi.encode(
recipient,
amount,
nonce[recipient],
block.chainid, // bind to this chain
address(this) // bind to this contract
));
bytes32 ethSigned = MessageHashUtils.toEthSignedMessageHash(messageHash);
address signer = ECDSA.recover(ethSigned, signature);
require(_isAuthorized(signer), "unauthorized");
nonce[recipient]++;
IERC20(token).transfer(recipient, amount);
}
Two additions: block.chainid ensures the signature is valid only for the chain on which it was signed; address(this) ensures the signature is valid only for this specific contract deployment.
The address(this) binding matters because even on the same chain, the same contract code may be deployed at multiple addresses. Without binding to the specific contract, a signature for one deployment can be replayed at another.
EIP-712 Handles This Automatically
The correct approach for any non-trivial signature scheme is EIP-712 typed data signing. EIP-712 builds the chain ID and contract address into the domain separator — a per-deployment value that prefixes every signed message. Signatures created under one domain separator cannot be valid under any other.
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract SafeBridge is EIP712 {
bytes32 public constant WITHDRAW_TYPEHASH = keccak256(
"Withdraw(address recipient,uint256 amount,uint256 nonce,uint256 deadline)"
);
constructor() EIP712("SafeBridge", "1") {}
function withdraw(
address recipient,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
bytes32 structHash = keccak256(abi.encode(
WITHDRAW_TYPEHASH,
recipient,
amount,
nonces[recipient],
deadline
));
bytes32 digest = _hashTypedDataV4(structHash); // applies domain separator
address signer = ECDSA.recover(digest, signature);
require(_isAuthorized(signer), "unauthorized");
nonces[recipient]++;
IERC20(token).transfer(recipient, amount);
}
}
The EIP712 constructor takes the contract name and version. The _hashTypedDataV4 function combines the domain separator (computed from name, version, chain ID, and contract address) with the struct hash to produce the final digest. The signer's wallet, when displaying the signing prompt, shows the structured data fields — name, version, contract, and operation parameters — making phishing attempts more visible.
For any signature-based authorization in a new contract, use EIP-712. Raw keccak256 signing with \x19Ethereum Signed Message:\n prefixes is a legacy pattern; EIP-712 is the modern standard.
Missing Nonce: Signature Replayability
Even with chain ID and contract address bound to the signature, the same signature is still valid forever unless something changes between uses. The standard "something" is a nonce — a counter that increments after each successful use, invalidating any further attempts to use the same signature.
Vulnerable Example
contract WithoutNonce {
function permitTransfer(
address from,
address to,
uint256 amount,
bytes calldata signature
) external {
bytes32 hash = keccak256(abi.encode(from, to, amount, block.chainid, address(this)));
require(ECDSA.recover(_toEthSigned(hash), signature) == from, "bad signature");
// BUG: no nonce, no consumption tracking
IERC20(token).transferFrom(from, to, amount);
}
}
A user signs "transfer 100 tokens from me to address X." Anyone who possesses that signature can call permitTransfer repeatedly until the user's balance runs out. The signature was meant to authorize one transfer; without a nonce, it authorizes an unlimited number of identical transfers.
Fixed Example: Per-Signer Nonces
mapping(address => uint256) public nonces;
function permitTransfer(
address from,
address to,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
bytes32 hash = keccak256(abi.encode(
from, to, amount, nonces[from], deadline, block.chainid, address(this)
));
require(ECDSA.recover(_toEthSigned(hash), signature) == from, "bad signature");
nonces[from]++; // consume the nonce
IERC20(token).transferFrom(from, to, amount);
}
The nonce binds the signature to the current value of nonces[from]. After the function runs, nonces[from] increments; the same signature would now hash to a different value and the verification would fail.
Sequential vs. Bitmap Nonces
The sequential nonce pattern above requires signatures to be consumed in order — the user can't sign three permits in advance and consume them out of order. For some applications, that's fine (EIP-2612 Permit works this way). For others (out-of-order signature execution, signature invalidation), bitmap nonces are better.
Bitmap nonces use a bit in a 256-bit storage word to track consumption:
mapping(address => mapping(uint256 => uint256)) private nonceBitmap;
function isNonceUsed(address signer, uint256 nonce) public view returns (bool) {
uint256 wordPos = nonce >> 8; // upper 248 bits
uint256 bitPos = nonce & 0xff; // lower 8 bits
return (nonceBitmap[signer][wordPos] & (1 << bitPos)) != 0;
}
function consumeNonce(address signer, uint256 nonce) internal {
uint256 wordPos = nonce >> 8;
uint256 bitPos = nonce & 0xff;
require(nonceBitmap[signer][wordPos] & (1 << bitPos) == 0, "nonce used");
nonceBitmap[signer][wordPos] |= (1 << bitPos);
}
Uniswap's Permit2 uses this pattern. The signer can issue many signatures with non-sequential nonces and consume them in any order; if a signature leaks or is no longer needed, the signer can pre-consume the nonce (effectively cancelling the signature) by submitting a no-op transaction that sets the bit.
Cross-reference: Section 3.7.2 covers Bitmap Nonces as a state pattern; Section 3.7.4 covers EIP-2612 Permit and its nonce mechanics.
Wrong Domain Separator
EIP-712 binds signatures to a domain separator computed from (name, version, chainId, verifyingContract). If the domain separator is computed incorrectly, signatures created off-chain don't match on-chain verification — and worse, attackers can sometimes craft signatures that match a different domain separator than the one the contract intended.
Vulnerable Pattern: Storing the Domain Separator at Deployment
contract Vulnerable {
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyContract"),
keccak256("1"),
block.chainid, // captured at deployment
address(this)
));
}
function verify(bytes32 structHash, bytes calldata sig) external view returns (address) {
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
return ECDSA.recover(digest, sig);
}
}
This compiles and works correctly — until the chain forks. If the chain hard-forks (Ethereum has done so several times, most consequentially after the DAO), block.chainid changes on one of the resulting chains. The stored DOMAIN_SEPARATOR no longer matches the new chain ID, but signatures created against the new chain ID will be valid on the old chain because the old DOMAIN_SEPARATOR is still hardcoded.
Fixed Example: Compute the Domain Separator Dynamically
OpenZeppelin's EIP712 computes the domain separator dynamically and caches it only when the chain ID matches the cached value:
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
contract Safe is EIP712 {
constructor() EIP712("MyContract", "1") {}
function verify(bytes32 structHash, bytes calldata sig) external view returns (address) {
bytes32 digest = _hashTypedDataV4(structHash);
return ECDSA.recover(digest, sig);
}
}
Internally, _hashTypedDataV4 calls _domainSeparatorV4, which returns the cached value if block.chainid still matches the cached chain ID, and recomputes otherwise. This handles chain forks gracefully.
The general lesson: never hardcode block.chainid into immutable state. Either compute the domain separator on every call (small gas cost), or cache it conditionally based on whether the chain ID is still the same as when it was last computed.
Insufficient Parameter Binding (The Wormhole Pattern)
A signature verifies that a signer authorized some message. If the message hash and the operation parameters are computed independently — and an attacker can manipulate the parameters without invalidating the signature — the signature authorizes a different operation than intended.
Vulnerable Example
contract VulnerableBridge {
function relayMessage(
address recipient,
uint256 amount,
bytes32 messageHash, // attacker provides this
bytes calldata signature
) external {
require(!processed[messageHash], "already processed");
require(_verifyValidator(messageHash, signature), "bad signature");
processed[messageHash] = true;
IERC20(token).transfer(recipient, amount);
}
}
The bug: recipient and amount are independent function parameters, but messageHash is also a function parameter. The signature verification proves "some validator signed some message hash," but says nothing about whether that hash corresponds to this particular recipient and amount.
An attacker who has obtained any valid signature (for any historical message) can call relayMessage with their own choice of recipient and amount, plus the historical messageHash and signature. The verification passes; the transfer happens with the attacker's parameters.
The Wormhole bridge exploit ($325M, February 2022) was exactly this class of bug. The attacker found a signature that the bridge would accept, then constructed a message that hashed to the validated state, draining the bridge.
Fixed Example: Compute the Hash From Parameters
The signature must commit to every parameter that affects the outcome. The on-chain code computes the hash from the parameters, then verifies the signature against the computed hash:
function relayMessage(
address recipient,
uint256 amount,
uint256 nonce,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
bytes32 messageHash = keccak256(abi.encode(
recipient,
amount,
nonce,
deadline,
block.chainid,
address(this)
));
require(!processed[messageHash], "already processed");
require(_verifyValidator(messageHash, signature), "bad signature");
processed[messageHash] = true;
IERC20(token).transfer(recipient, amount);
}
Now messageHash is derived deterministically from the function parameters. Changing recipient or amount produces a different messageHash, which produces a different verification target, which makes the signature invalid for the new parameters.
The pattern generalizes: whenever a signature authorizes an operation, the signed digest must cover every parameter that affects the operation's outcome. Missing one parameter is a bug; the attacker substitutes it freely.
Cross-reference: Section 3.8.4 (Access Control Failures) covers this pattern in the access-control framing; this section covers the signature-mechanics framing of the same bug.
Signature Aggregation Bugs in Multi-Sig Schemes
Contract-layer multi-sig schemes accept M valid signatures from a set of N possible signers. The aggregation step is where most multi-sig signature bugs occur.
Vulnerable Example: Duplicate Signature Acceptance
contract NaiveMultiSig {
address[] public signers;
uint256 public threshold;
function execute(bytes32 messageHash, bytes[] calldata signatures) external {
require(signatures.length >= threshold, "not enough signatures");
uint256 validCount = 0;
for (uint256 i = 0; i < signatures.length; ++i) {
address signer = ECDSA.recover(messageHash, signatures[i]);
if (_isSigner(signer)) {
validCount++;
}
}
require(validCount >= threshold, "threshold not met");
// ... execute action
}
}
The bug: the contract counts valid signatures but doesn't check that they come from distinct signers. An attacker who compromises one signer's key can submit M copies of that one signer's signature, meeting the threshold with a single compromised key instead of M distinct keys.
Fixed Example: Strictly Increasing Signers
function execute(bytes32 messageHash, bytes[] calldata signatures) external {
require(signatures.length >= threshold, "not enough signatures");
address lastSigner = address(0);
uint256 validCount = 0;
for (uint256 i = 0; i < signatures.length; ++i) {
address signer = ECDSA.recover(messageHash, signatures[i]);
require(signer > lastSigner, "signatures must be in increasing signer order");
require(_isSigner(signer), "not a valid signer");
lastSigner = signer;
validCount++;
}
require(validCount >= threshold, "threshold not met");
// ... execute action
}
Requiring strictly increasing signer addresses (signer > lastSigner) enforces both uniqueness and a canonical ordering — there's only one valid order for the signatures, and duplicates from the same signer cannot pass the check. The pattern is O(n) rather than the O(n²) of an explicit duplicate check.
This is the same pattern from Section 3.7.3 (contract-layer multi-sig), now framed as a defense against a specific bug class.
The "Signer ≠ Recovered Address" Trap
function execute(bytes32 messageHash, bytes[] calldata signatures) external {
// ...
for (uint256 i = 0; i < signatures.length; ++i) {
address signer = ECDSA.recover(messageHash, signatures[i]);
// BUG: doesn't reject signer == address(0)
if (_isSigner(signer)) {
validCount++;
}
}
}
ECDSA.recover returns address(0) when the signature is invalid. If address(0) is somehow in the _isSigner set (e.g., the contract was initialized incorrectly), invalid signatures count toward the threshold. OpenZeppelin's ECDSA.recover reverts on invalid signatures rather than returning address(0), which is the right behavior; if you're using a custom implementation that returns address(0), add explicit checks.
ERC-1271: Contract-Signed Signatures
Smart contract wallets (Safe, Argent, account abstraction wallets) cannot produce ECDSA signatures because they have no private key. They implement ERC-1271 instead: a contract has an isValidSignature(hash, signature) function that returns a magic value if the contract approves the hash.
Standard Check Pattern
import "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
contract AcceptsBothSignatureTypes {
function executeOrder(
address signer,
bytes32 orderHash,
bytes calldata signature
) external {
// SignatureChecker handles both EOA (ECDSA) and contract (ERC-1271) signatures
require(
SignatureChecker.isValidSignatureNow(signer, orderHash, signature),
"bad signature"
);
// ... execute order
}
}
OpenZeppelin's SignatureChecker.isValidSignatureNow first tries ECDSA recovery; if the signer is a contract, it then calls isValidSignature on the contract. This handles both EOA and contract signatures transparently.
The Pitfall: Time-Bound Contract Signatures
ERC-1271 has a subtle property that breaks naive assumptions: a contract can change its mind. A signature that was valid yesterday may not be valid today if the wallet contract's authorization state has changed. This breaks any signature scheme that assumes "valid once, valid forever."
For off-chain order matching (1inch, CowSwap, OpenSea), this is critical. The order is signed off-chain; by the time it's filled on-chain, the wallet's authorization may have changed. The standard defense is to bound the signature with a short deadline so the time window for state-change is small:
function executeOrder(
address signer,
Order calldata order,
bytes calldata signature
) external {
require(block.timestamp <= order.deadline, "expired");
bytes32 orderHash = _hashOrder(order);
require(
SignatureChecker.isValidSignatureNow(signer, orderHash, signature),
"bad signature"
);
// ... execute
}
Some protocols additionally pre-validate the signature off-chain and refuse to relay orders whose signers have changed authorization since signing.
Quick Reference
| Bug | What goes wrong | Defense |
|---|---|---|
| Signature malleability | High-s twin of valid signature is also valid | Use OpenZeppelin's ECDSA.recover (enforces low-s); dedupe by message hash, not signature |
| Missing chain ID | Signature replayable on other EVM chains | Include block.chainid in signed message; or use EIP-712 |
| Missing contract address | Signature replayable on other deployments | Include address(this); or use EIP-712 |
| Missing nonce | Signature replayable until balance depletes | Per-signer sequential or bitmap nonce |
| Hardcoded chain ID in immutable | Chain fork makes domain separator wrong | Compute domain separator dynamically; cache conditionally on chain ID match |
| Insufficient parameter binding | Attacker substitutes parameters; signature still verifies | Compute hash from parameters; sign everything that affects outcome |
| Multi-sig duplicate signatures | One compromised key passes M-of-N threshold | Require strictly increasing signer addresses |
address(0) in signer set | Invalid signatures count toward threshold | Use OZ ECDSA.recover (reverts on invalid); explicit signer != address(0) check |
| ERC-1271 signature staleness | Contract wallet's authorization changes between sign and execute | Deadline parameter bounding signature lifetime |
Cross-References
- Patterns — Section 3.7.4 covers Permit (EIP-2612) and contract-layer multi-sig
- Access control — Section 3.8.4 covers signature-without-parameter-binding in the access-control framing
- Bitmap nonces — Section 3.7.2 covers bitmap nonce mechanics
- Real exploits — Section 3.10.4 (Poly Network) and 3.10.7 (Wormhole) cover signature-related bridge exploits
- OpenZeppelin libraries —
ECDSA,EIP712,MessageHashUtils,SignatureCheckerprovide the reference implementations - Auditor's view — Section 4.14 covers signature malleability and replay attacks during audit