What a Signature Actually Authorizes
ecrecover answers exactly one question: which address produced this signature for this hash? That’s it. It does not tell you whether the signer meant the message for your contract, for this chain, for this transaction, or whether they already used it an hour ago. Every signature-replay vulnerability is a contract treating the answer to “who signed?” as if it were the answer to “is this a valid, fresh, one-time authorization for me, right now?”
Those are different questions, and the difference is the entire attack surface.
The shape of the mistake
Here is the pattern, stripped to its essence:
function executeAction(bytes32 messageHash, bytes memory signature, uint256 amount) external {
address signer = ECDSA.recover(messageHash, signature);
require(signer == owner, "Invalid signature");
_transfer(msg.sender, amount);
}
The signature is authentic. The signer is the owner. The require passes. And anyone watching the mempool can copy messageHash and signature out of this transaction and submit them again — and again — because nothing in the contract records that this particular authorization was ever spent. A signature that was meant to authorize one transfer authorizes an unbounded number of them.
Replay comes in three flavors, and a robust design has to close all three:
- Same-chain replay. Resubmit the same signature to the same contract. Defeated by a nonce.
- Cross-contract replay. A signature accepted by contract A is also accepted by contract B because both verify the same message format. Defeated by binding the contract address into the signed data.
- Cross-chain replay. A signature valid on Ethereum is mathematically valid on every EVM chain. This bites hardest after a fork, when a message signed before the split verifies on both sides. Defeated by binding the chain ID.
EIP-155 added the chain ID to transaction signatures years ago, which is why you can’t replay a raw mainnet transaction on a testnet. But that protection lives at the transaction layer. The moment you verify an application-level signature inside a contract — a meta-transaction, a gasless approval, an off-chain order — you are back to first principles and must add chain binding yourself.
Why EIP-712 exists
You could bind all of this by hand: hash the chain ID, the contract address, a nonce, and a deadline into the message alongside the payload. People did, and the results were inconsistent and easy to get subtly wrong. EIP-712 standardizes it.
The core idea is a domain separator: a hash computed once from the contract’s name, version, chain ID, and address. Every signed message is hashed together with that domain separator, so a signature is structurally bound to one contract on one chain. A message signed for MyApp v1 on chain 1 at 0xabc... cannot verify against MyApp v1 on chain 137 at 0xdef.... Cross-contract and cross-chain replay are closed by construction, before you write a line of nonce logic.
EIP-712 also makes the signed data legible. Instead of asking a user to sign an opaque 32-byte hash, the typed-data format lets wallets display the actual fields — recipient, amount, deadline — so the human approving the signature can see what they’re authorizing. That readability is a security property in its own right; blind-signing is how phishing turns a curious click into a drained wallet.
What EIP-712 does not do is prevent same-chain replay. The domain separator is the same for every message to your contract, so two identical messages produce identical hashes and identical signatures. You still need a nonce.
The two parts of a complete check
A signature-based authorization that holds up has two independent jobs, and conflating them is the usual root cause:
- Authenticity — the signature recovers to the expected signer over data bound to this contract and chain. EIP-712’s domain separator handles the binding.
- Freshness and uniqueness — this authorization hasn’t been used and hasn’t expired. A monotonic nonce handles uniqueness; a deadline handles expiry.
flowchart TD
classDef process fill:#1a2233,stroke:#7ea8d4,stroke-width:2px,color:#c0d8f0
classDef success fill:#1a331a,stroke:#a8c686,stroke-width:2px,color:#c8e8b0
classDef attack fill:#331a1a,stroke:#d97777,stroke-width:2px,color:#f0c0c0
S["Signed message"]:::process
A["Authentic?<br/>recover over EIP-712 hash<br/>(binds chain + contract)"]:::process
F["Fresh?<br/>nonce unused<br/>+ deadline not passed"]:::process
OK["Execute, then<br/>consume the nonce"]:::success
R["Reject"]:::attack
S --> A
A -->|"no"| R
A -->|"yes"| F
F -->|"no"| R
F -->|"yes"| OK
The standard library shape covers all of it:
contract SafeAuth is EIP712 {
bytes32 private constant _TYPEHASH =
keccak256("Execute(address recipient,uint256 amount,uint256 nonce,uint256 deadline)");
mapping(address => uint256) public nonces;
address public owner;
constructor() EIP712("SafeAuth", "1") {}
function executeAction(address recipient, uint256 amount, uint256 deadline, bytes memory signature) external {
require(block.timestamp <= deadline, "Signature expired");
bytes32 structHash = keccak256(abi.encode(_TYPEHASH, recipient, amount, nonces[owner]++, deadline));
bytes32 digest = _hashTypedDataV4(structHash); // mixes in the domain separator
require(ECDSA.recover(digest, signature) == owner, "Invalid signature");
_transfer(recipient, amount);
}
}
_hashTypedDataV4 folds in the domain separator (chain ID and contract address); the nonces[owner]++ consumes the authorization atomically; the deadline bounds how long a leaked-but-unused signature stays dangerous.
Two traps even careful code falls into
Signature malleability. Every ECDSA signature has a twin. For any valid (r, s) there is a second valid (r, n - s) that recovers to the same signer without knowing the key. If your replay protection keys on the signature bytes — marking a signature hash as “used” — an attacker flips s to its counterpart and walks right past the check with a different-looking signature that authenticates identically. EIP-2 restricted s to the lower half of the curve order specifically to kill this, so the fix is to reject any s above secp256k1n/2 (and to track nonces, not signature hashes). A canonical ECDSA.recover already enforces the bound and rejects the zero-address return that an invalid signature can otherwise produce.
Permit front-running. EIP-2612’s permit() brought gasless approvals to ERC-20, and with them a griefing vector. Because a permit signature sits in the mempool before it’s used, anyone can submit it first. The approval still lands — same effect — but the victim’s own transaction, which assumed it needed to call permit, now reverts because the nonce is already consumed. No funds move, but flows that bundle permit with a dependent action break. The mitigation is to treat a permit that’s already been applied as a success rather than a hard failure, so a front-run doesn’t brick the path.
The one-line version
Verifying a signature is necessary and not sufficient. Authenticity tells you who signed; it tells you nothing about scope or freshness. Bind the message to your contract and chain with EIP-712, make it single-use with a nonce, time-box it with a deadline, and reject malleable signatures. Each of those closes a door that ecrecover leaves wide open — and a smart-contract wallet that signs via ERC-1271 instead of a raw key needs the same four properties, just verified through isValidSignature.
References
- EIP-712: Typed structured data hashing and signing
- EIP-155: Simple replay attack protection
- EIP-2612: Permit — signed approvals for ERC-20
- EIP-2: Homestead — secp256k1 s-value restriction
- ERC-1271: Standard signature validation for contracts
- SWC-121: Missing protection against signature replay attacks
- SWC-117: Signature malleability
- CWE-347: Improper verification of cryptographic signature