Signature Replay Attacks: Why Your ECDSA Signature Needs a Nonce
A valid ECDSA signature is a bearer asset — anyone holding it can replay it forever unless your contract says otherwise. Missing nonces, missing chainIds, and missing contract addresses have drained cross-chain bridges of hundreds of millions. Here's exactly how signature replay works in Solidity…
The Bug in One Sentence
If your contract accepts an ECDSA signature without binding it to a nonce, a chainId, and the contract's own address, that signature is a reusable coupon — and attackers will redeem it until your treasury is empty.
Signatures are stateless. `ecrecover` doesn't care if you've seen the signature before, whether it was meant for mainnet or a testnet fork, or whether it was signed for a totally different contract. That's your job. Forget it, and you've shipped SWC-121.
Anatomy of a Replay
Here's the classic vulnerable pattern — a meta-transaction or permit-style function that trusts a signature without state:
```solidity
// VULNERABLE
contract Vault {
mapping(address => uint256) public balances;
function withdraw(
address to,
uint256 amount,
bytes calldata signature
) external {
bytes32 hash = keccak256(abi.encodePacked(to, amount));
bytes32 ethSigned = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
);
address signer = recover(ethSigned, signature);
require(signer == owner, "bad sig");
payable(to).transfer(amount);
}
}
```
Three distinct replay vectors live inside those 15 lines:
1. **Same-contract replay.** The signed hash is `(to, amount)`. Submit it once, withdraw 1 ETH. Submit it again. Withdraw another. Repeat until the vault is dry.
2. **Cross-chain replay.** No `chainId`. The same signature is valid on Ethereum, Optimism, Arbitrum, BSC, every fork. Sign once on a testnet, replay on mainnet.
3. **Cross-contract replay.** No `address(this)`. Deploy a clone of this contract, get a signature for the clone, replay it on the original.
The Fix: EIP-712 + Nonces
The canonical defense is EIP-712 typed structured data plus a per-signer nonce that the contract increments on use.
```solidity
// FIXED
contract Vault {
using ECDSA for bytes32;
bytes32 private constant WITHDRAW_TYPEHASH =
keccak256("Withdraw(address to,uint256 amount,uint256 nonce,uint256 deadline)");
bytes32 private immutable DOMAIN_SEPARATOR;
mapping(address => uint256) public nonces;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("Vault")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
}
function withdraw(
address to,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "expired");
uint256 nonce = nonces[owner]++;
bytes32 structHash = keccak256(abi.encode(
WITHDRAW_TYPEHASH, to, amount, nonce, deadline
));
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01", DOMAIN_SEPARATOR, structHash
));
require(digest.recover(signature) == owner, "bad sig");
payable(to).transfer(amount);
}
}
```
What this buys you:
**Nonce** — single-use guarantee. Once consumed, the same signature reverts.
- **chainId in the domain separator** — useless on any other chain.
- **verifyingContract in the domain separator** — useless against any other deployment.
- **deadline** — bounds the window even if a signer forgets to revoke.
For signatures that don't have a natural ordering (e.g., a relayer network where messages arrive out of order), swap the incrementing nonce for a `mapping(bytes32 => bool) usedHashes` and mark each digest consumed on use. Same idea, different shape.
Real-World Damage
Cross-chain bridges are the natural habitat of replay bugs because they were built before chainId-binding was reflex.
**Wormhole (Feb 2022, $325M)** — not strictly a replay, but a signature-verification failure in the same family: a guardian-set check was bypassed, letting a forged VAA mint 120k wETH on Solana without backing on Ethereum.
- **Nomad Bridge (Aug 2022, $190M)** — a misconfigured Merkle root made every message look pre-approved. Once the first attacker drained funds, hundreds copy-pasted the calldata. The exploit was a literal replay free-for-all.
- **Qubit Bridge (Jan 2022, $80M)** — signature/deposit verification logic let attackers reuse a deposit proof to mint xETH on BSC with no real ETH locked.
- **PolyNetwork (Aug 2021, $611M)** — cross-chain message verification was tricked into accepting attacker-controlled keepers; the same signature pattern was replayable across chains.
Every one of these traces back to the same root cause: a signature or proof treated as authoritative without being tightly scoped to a specific chain, contract, nonce, and intent.
Subtle Variants That Still Bite in 2025
1. **EIP-2612 `permit` front-running.** Not a replay, but related: a signed permit broadcast publicly can be front-run to consume the nonce, griefing the user. Always pair `permit` with the action it authorizes in the same transaction.
2. **Signature malleability.** Pre-OpenZeppelin 4.7, `ecrecover` accepted both `(r, s, v)` and `(r, -s, v')`. Two valid signatures for the same message means even a `usedHashes[sig]` check fails. Use `ECDSA.recover` from a recent OZ version, which rejects high-s values.
3. **EIP-1271 contract signatures.** Smart-contract wallets can return a valid signature today and a different one tomorrow as their owner set changes. Treat 1271 validations as ephemeral — re-check or bind to nonces.
4. **L2 chainId collisions during forks.** When a chain hard-forks (think ETC vs ETH in 2016), pre-fork signatures replay on both. Domain separators with `block.chainid` recomputed on access, not cached, handle this — but most don't bother because forks are rare. Document the assumption.
Audit Checklist
[ ] Every signature-gated function takes a nonce or uses a `usedHashes` mapping.
- [ ] Domain separator includes `block.chainid` AND `address(this)`.
- [ ] Using `ECDSA.recover` (OZ ≥4.7), not raw `ecrecover`.
- [ ] Deadlines on all off-chain signatures.
- [ ] `permit`-style flows are atomic with the action they authorize.
- [ ] If signatures cross chains (bridges), the source chainId is part of the signed payload.
If you're shipping a bridge, a meta-tx relayer, or anything with off-chain signatures touching real money, run it through the [free AI audit at /audit](https://www.cryptohawking.com/audit) before mainnet. For production bridge or vault contracts, the [3-business-day manual audit at /audit/manual](https://www.cryptohawking.com/audit/manual) catches the variants automated tools miss — particularly cross-chain replay paths and 1271 edge cases that need a human reading the threat model.
Closing Thought
A signature without a nonce and a domain separator isn't authentication — it's a permanent bearer check made out to `0xDEADBEEF`. The cryptography works. The accounting around it is what fails. Bind every signature to *who*, *what*, *which chain*, *which contract*, and *which time* — or assume an attacker will find a context where you forgot.
FAQ
Why isn't a nonce alone enough to prevent signature replay?
A nonce stops replay within the same contract on the same chain, but it doesn't stop cross-chain or cross-contract replay. If your contract is deployed to multiple chains with the same address (very common with CREATE2 and deterministic deployers), a signature valid on one chain replays perfectly on another — the nonces are independent. You need `block.chainid` and `address(this)` baked into the signed digest, which is exactly what an EIP-712 domain separator gives you. Nonces handle freshness; the domain separator handles scope. Both are required.
Should I use an incrementing nonce or a used-hash mapping?
Use an incrementing nonce when signatures are produced and consumed in a predictable order (ERC-20 permits, governance votes, sequential meta-transactions). Use a `mapping(bytes32 => bool) usedHashes` when signatures can be submitted out of order — typical for relayer networks, intent-based protocols, or any system where multiple signed messages might be in flight simultaneously. The hash-based approach is more flexible but costs slightly more gas per use (~22k for the SSTORE vs ~5k for incrementing). For bridges, hash-based is almost always correct.
Is signature malleability still a real concern in 2025?
Yes, if you're using raw `ecrecover` or rolled-your-own signature parsing. Solidity's `ecrecover` precompile still accepts high-s signatures, so for every valid `(r, s, v)` there's a malleable `(r, n-s, v^1)` that recovers the same signer. If your replay protection is `usedHashes[keccak256(signature)]`, an attacker submits both forms and gets one free replay. The fix is one line: use OpenZeppelin's `ECDSA.recover`, which rejects `s > secp256k1n/2`. Or hash the *message digest* you stored, not the raw signature bytes.
How does EIP-712 actually prevent cross-chain replay?
EIP-712 defines a domain separator: `keccak256(EIP712Domain(name, version, chainId, verifyingContract))`. This hash is mixed into every signed digest via the `\x19\x01` prefix. Because `chainId` and `verifyingContract` are part of the hash, a signature produced for chainId 1 / contract A will not verify against chainId 10 / contract A or chainId 1 / contract B. The signer cryptographically commits to *which* contract on *which* chain they're authorizing. Anything trying to replay the signature elsewhere recovers a different address — usually garbage — and the require check fails.
What's the right way to handle signatures from smart contract wallets?
Use EIP-1271's `isValidSignature(bytes32 hash, bytes signature) returns (bytes4)`. The contract wallet returns the magic value `0x1626ba7e` if the signature is valid. The catch: 1271 validations are stateful and time-dependent — a multisig wallet that approved a signature today may revoke a signer tomorrow, invalidating it. Never cache 1271 results, always re-verify at the moment of use, and pair the signature with a nonce so the action executes atomically with validation. OpenZeppelin's `SignatureChecker` library handles both EOA and 1271 cases correctly.