EIP-712 & Signature Replay: What Bug Hunters Miss
EIP-712 signature replay is what happens when a signed typed message stays valid the second time you show it — on the same contract, on a clone, or on another chain. This Andrax tutorial — by Syed Zada Abrar — covers ~65% theory, ~35% local permit lab.
Prerequisites: Solidity Essentials; Foundry + Slither Setup.
Also useful: Smart Contract Access Control Bugs; Hub; How to Start Web3 Bug Hunting in 2026.
Ethics fence: the toy verifier below is ILLUSTRATIVE / local lab only. Sign with Foundry’s vm.sign on Anvil chain ids inside a test. Do not replay permits against live tokens, and do not build a phishing site. Immunefi rules ban mainnet and public-testnet testing of in-scope code.
What EIP-712 actually binds
EIP-712 hashes typed structs instead of opaque byte strings, so a wallet can show fields. The signed digest is:
keccak256(0x19 0x01 ‖ domainSeparator ‖ hashStruct(message))
hashStruct is keccak256(typeHash ‖ encodeData(value)). Dynamic string and bytes members are hashed before they go into encodeData. The standard does not include replay protection. Its security section says the application must reject a repeated message or make the action idempotent, and must tolerate the signature being submitted by someone other than the signer (front-running). Both are explicitly out of scope for the EIP.
The domain separator is hashStruct of an EIP712Domain that includes only the fields you need, in this order, skipping gaps:
| Field | Role |
|---|---|
string name | Human-readable domain |
string version | Major version; changing it invalidates old signatures |
uint256 chainId | EIP-155 chain id |
address verifyingContract | Contract that is supposed to check the signature |
bytes32 salt | Last-resort disambiguator |
Leave chainId out and the same digest can verify on another chain. Leave verifyingContract out and another contract with the same name, version, and chain can accept it. Those omissions are the usual cross chain signature replay and cross-contract replay findings.
OpenZeppelin’s EIP712 implements domain separator v4 (the MetaMask eth_signTypedDataV4 encoding) and _hashTypedDataV4(structHash). The separator is rebuilt when the chain id changes, specifically “to protect against replay attacks on an eventual fork” (EIP712). Caching a separator that baked in chainId at deployment, then never rebuilding it, is the fork-replay shape EIP-2612 also warns about.
Replay classes hunters should name
| Class | What is missing | What repeats |
|---|---|---|
| Same-contract replay | Nonce, or any consumed nullifier | The identical action on the same deployment |
| Cross-contract | verifyingContract (and sometimes salt) | The signature on a second contract with the same domain fields |
| Cross-chain | chainId in the domain, or a stale cached separator | The signature where the same address exists on another chain (CREATE2 clones are the usual reason the address matches) |
| Fork | Separator not rebuilt after a chain split | Pre-fork signatures on both resulting chains |
A nonce that only increments, as in OpenZeppelin Nonces, closes same-contract replay if every accepted signature consumes it. A deadline limits how long a withheld signature stays useful; it does not replace the nonce. EIP-712’s front-running note still applies: a relayer can submit the signature first. For a well-built permit, the effect matches what the signer asked for. The bug is when the second submission also pays out.
Permit phishing (user-side) vs contract bugs
EIP-2612 permit sets an ERC-20 allowance from an EIP-712 signature. The typed struct is Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline). The caller can be anyone. A common domain is name, version, chainId, and address(this).
Permit phishing is a person signing that struct in a wallet because a site presented it as a login. Hunters reviewing an integration look for: value at the maximum uint, a far-away deadline (the EIP allows type(uint256).max for non-expiring permits), a spender that is not the router the user thinks they are using, and a domain the wallet will not display clearly. That is a social-engineering pattern. This tutorial does not describe how to operate a phishing page.
Contract-side misses on the same function are different and in scope for a code review:
- Nonce not checked or not incremented
deadlinenot compared toblock.timestamp- Domain missing
chainIdorverifyingContract owner == address(0)accepted
EIP-2612’s security considerations say the ecrecover precompile fails silently and returns the zero address on a malformed signature, so implementations must require owner != address(0) or they can approve “zombie” balances of the zero address. The same silent zero is how an uninitialized owner plus require(ecrecover(...) == owner) becomes “any bad signature is authorized.”
ecrecover zero address, and what OpenZeppelin does instead
Raw ecrecover returns address(0) when the signature is not a valid recoverable signature. It also accepts malleable (s, v) pairs: the same signer can be recovered from two different signatures. Do not use the signature bytes as a unique id.
OpenZeppelin ECDSA.tryRecover documents that it will not return address(0) without also returning an error (InvalidSignature, InvalidSignatureLength, or InvalidSignatureS). ECDSA.recover reverts on those errors. Both reject s in the upper half and require v to be 27 or 28. The docs also say not to treat signatures as unique identifiers — use nonces or explicit invalidation (ECDSA). Prefer that library over a hand-rolled ecrecover in production reviews. The lab below uses raw ecrecover on purpose so the zero-address behavior is visible.
SignatureChecker.isValidSignatureNow adds ERC-1271 (contract wallets). Those signatures can become valid or invalid later, because the contract can change its mind. A cached true is not a nonce.
Lab — replay one signature across two Anvil chain ids
Goal: sign a digest that omits chainId, change vm.chainId, and show the signature still recovers. Then show a domain that includes chainId does not. Local test only. No broadcast.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// ILLUSTRATIVE / LOCAL LAB ONLY — not a token, not a permit
contract ToyReplay {
bytes32 private constant _TYPEHASH =
keccak256("Action(address to,uint256 amount,uint256 nonce)");
/// Domain omits chainId — cross-chain replay shape
function digestWeak(address to, uint256 amount, uint256 nonce) public view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(_TYPEHASH, to, amount, nonce));
bytes32 domain = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,address verifyingContract)"),
keccak256(bytes("ToyReplay")),
address(this)
)
);
return keccak256(abi.encodePacked(hex"1901", domain, structHash));
}
/// Includes chainId — digest changes when the chain id changes
function digestStrong(address to, uint256 amount, uint256 nonce) public view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(_TYPEHASH, to, amount, nonce));
bytes32 domain = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256(bytes("ToyReplay")),
block.chainid,
address(this)
)
);
return keccak256(abi.encodePacked(hex"1901", domain, structHash));
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
/// LOCAL LAB ONLY — toy key, never fund it
contract ReplayLabTest is Test {
uint256 constant PK = 0xA11CE;
function test_weakDigestReplaysAcrossChainIds() public {
ToyReplay toy = new ToyReplay();
address signer = vm.addr(PK);
address to = makeAddr("to");
vm.chainId(31337);
bytes32 weak = toy.digestWeak(to, 1 ether, 0);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, weak);
vm.chainId(31338);
assertEq(toy.digestWeak(to, 1 ether, 0), weak, "weak digest ignored chainId");
assertEq(ecrecover(weak, v, r, s), signer, "replayed on the other chain id");
bytes32 strongHere = toy.digestStrong(to, 1 ether, 0);
assertTrue(ecrecover(strongHere, v, r, s) != signer, "chainId-bound digest does not match");
}
function test_strongDigestIsChainBound() public {
ToyReplay toy = new ToyReplay();
address signer = vm.addr(PK);
address to = makeAddr("to");
vm.chainId(31337);
bytes32 strong = toy.digestStrong(to, 1 ether, 0);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, strong);
vm.chainId(31338);
bytes32 moved = toy.digestStrong(to, 1 ether, 0);
assertTrue(moved != strong);
assertTrue(ecrecover(moved, v, r, s) != signer);
}
function test_malformedRecoverIsZero() public {
// r = 0 is not a valid signature; precompile returns address(0)
address who = ecrecover(bytes32(uint256(1)), 27, bytes32(0), bytes32(0));
assertEq(who, address(0));
}
}
Run forge test --match-contract ReplayLabTest. The first test is the cross-chain hole. The second is the fix’s property (chain id inside the domain). The third is the EIP-2612 silent failure.
Add one more local assertion in your notes, not against a live token: if a verifier does require(ecrecover(digest, v, r, s) == owner) and owner is still address(0), the malformed signature in the third test passes. The fix is require(signer != address(0)) before the comparison, or ECDSA.recover, which reverts.
Lab write-up (five sentences): which domain fields you omitted, what stayed constant when chainId changed, what the strong digest did, how a nonce would stop a second use on the same chain, and why this was not broadcast.
Same-chain replay is the nonce column: call a hypothetical execute twice with the same (v,r,s) and no nonce store. You do not need a second chain for that finding. Write it down separately so “replay” is not only “cross-chain.”
How to report signature findings
- Quote the domain type string and the struct type string you hashed
- Say which of chain id, verifying contract, nonce, deadline, or zero-address check is missing
- Show the local
vm.sign/vm.chainIdtest, or a same-chain double submit on the toy - Do not attach a stolen mainnet permit or a phishing domain
- Note malleability if the code uses raw
ecrecoverand treats the signature as an id - Severity follows impact (whose funds move), not the words “signature replay”
Common hunter traps
| Trap | Better habit |
|---|---|
Calling every permit a vulnerability | Phishing is user-side; file contract bugs that are missing checks |
| Assuming EIP-712 implies a nonce | The EIP says replay protection is the application’s job |
Treating ECDSA.recover like raw ecrecover | OZ reverts; it does not hand back address(0) as success |
| Using signature bytes as a unique key | Nonces; OZ rejects malleable s but still says not to use signatures as ids |
| Forgetting CREATE2 | Same address on two chains makes a missing chainId exploitable in a lab |
| Live permit replay “to confirm” | Anvil chain ids only |
Checklist before you call it signature replay
- Digest construction matches
\\x19\\x01 ‖ domain ‖ structHash - Domain includes
chainIdandverifyingContractunless you can justify the omission - Nonce consumed exactly once, or the action is truly idempotent
- Deadline enforced where the flow can be withheld
- Recovered signer checked against
address(0)if you use rawecrecover - Fork / cached-separator case considered for upgradeable or immutable domain caches
- Immunefi / program rules respected
Next steps
- Prev (live): Proxy, UUPS & Storage Collision Bugs for Auditors
- Live: Solidity Essentials · Foundry + Slither · Hub
- Next (live): ERC-4626 Vault Security: Share Inflation & Donation Attacks
Educational content by Syed Zada Abrar for Andrax (andraxpentester.in). Authorized research only. All verifiers ILLUSTRATIVE / local lab only.