At a glance
- The bug
- A contract accepts a signed message and acts on it, but never marks that message as used, so the same signature can be submitted again for a second payout
- The wider bug
- The same signature working on another chain, or on a sister contract, because the signed data did not pin down where it was meant to be used
- The tool
- EIP-712 typed data with a domain separator: name, version, chain id and contract address, so a signature is valid in exactly one place
- The fix
- A nonce per signer that the contract consumes on first use, plus the domain, so a message works once and only where intended
- The habit
- For every signature a contract verifies, ask: what stops this being replayed, on this chain and every other?
Why contracts trust signatures at all
Not every action needs a transaction. A user can sign a message off-chain, for free, and hand that signature to someone else to submit on-chain later. It is how gasless approvals work, how meta-transactions let a relayer pay the gas, and how an exchange can let you authorise a trade without a wallet pop-up for every step. The contract recovers the signer with ecrecover and, if it matches, treats the message as that person's instruction.
The power is obvious and so is the risk. A transaction is protected by the account nonce, which the network enforces. A bare signature has none of that. It is just a number that proves who signed some bytes, and unless the contract adds protection of its own, the same proof works as many times as it is presented.
The replay, in three flavours
Here is an airdrop that pays out to anyone who presents a signature from the trusted signer. Read it for what is missing.
function claim(address to, uint256 amount, bytes memory sig) external {
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = recover(hash, sig);
require(signer == trustedSigner, "bad signature");
token.transfer(to, amount); // nothing records that this sig was used
}
The signature is checked and the tokens are sent, but nothing is written down. Call claim again with the same three arguments and it passes again, and again, until the contract is empty. That is the first flavour: no nonce, so one signature is an unlimited coupon.
There are two more, and they are subtler because the signed data itself is incomplete. If the hash does not include the chain id, a signature made on a testnet, or on a fork, is equally valid on mainnet: cross-chain replay. If it does not include the contract address, a signature meant for one deployment works on an identical sister contract that shares the same signer: cross-contract replay. The signer proved they signed these bytes, but never said for which chain and which contract.
EIP-712: signing something you can read, in one place only
The standard answer to all three flavours is EIP-712. Instead of signing an opaque hash, the user signs structured, typed data under a domain separator, a preamble that names exactly where the signature is valid: the contract's name and version, the chain id, and the verifying contract's address. Bind those into the hash and a signature is worthless anywhere but the one chain and the one contract it was made for. Cross-chain and cross-contract replay are closed by construction.
bytes32 DOMAIN = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("OverWatch Airdrop")),
keccak256(bytes("1")),
block.chainid, // pins the chain
address(this) // pins the contract
));
The fix: a nonce the contract consumes
The domain stops it being replayed elsewhere. To stop it being replayed here, the signed message must include a nonce, and the contract must record that nonce as spent the first time it sees it.
mapping(address => uint256) public nonces;
function claim(address to, uint256 amount, uint256 nonce, bytes memory sig) external {
bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, to, amount, nonce));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN, structHash));
require(recover(digest, sig) == trustedSigner, "bad signature");
require(nonce == nonces[to], "nonce used or out of order");
nonces[to]++; // consume it: this signature will never pass again
token.transfer(to, amount);
}
Now the signature is valid once, for this chain, for this contract, and never again. Do not hand-roll the primitives if you can help it: OpenZeppelin's EIP712 and ECDSA build the domain and recover the signer correctly, including the malleability and zero-address checks that a raw ecrecover misses.
Contract wallets sign differently. A smart-contract wallet cannot use ecrecover; it validates signatures through ERC-1271, by asking the wallet contract "is this signature valid?" If your verification only handles raw keys, contract wallets break, and if you add ERC-1271 without a nonce, its signatures replay just like any other. Whichever path you support, the nonce and the domain still apply.
The short version. A signature proves who signed some bytes, nothing more. A contract that acts on one must add what the bytes do not carry: a nonce it consumes, so the message works once, and an EIP-712 domain binding the chain id and its own address, so the message works nowhere else. Reach for audited libraries for the recovery and the domain, and remember that contract wallets sign through ERC-1271 and need the same protections.
References & further reading
- Ethereum Improvement Proposals, EIP-712: Typed structured data hashing and signing. The domain separator and why it scopes a signature.
- OpenZeppelin, Cryptography utilities.
ECDSAandEIP712, with the checks a rawecrecoverlacks. - Ethereum Improvement Proposals, EIP-1271: Standard signature validation for contracts. How smart-contract wallets prove a signature.
- Solidity documentation, ecrecover and signature malleability. The sharp edges of the raw primitive.