# Signature Replay Attacks — When EIP-712 Goes Wrong
EIP-712 is the standard for structured data signatures in Ethereum. It is used for gasless transactions, permit functions, meta-transactions, and DAO votes.
But EIP-712 is only as secure as its implementation. Get the domain separator wrong, forget a nonce, or omit the contract address, and you open the door to replay attacks.
Here is how EIP-712 works, where it goes wrong, and how to implement it securely.
How EIP-712 Works
EIP-712 defines a standard for hashing structured data before signing. The signature includes:
Example: Permit Function
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyToken"),
keccak256("1"),
block.chainid,
address(this)
));
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "Expired");
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
nonces[owner]++,
deadline
));
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
structHash
));
address signer = ecrecover(digest, v, r, s);
require(signer == owner, "Invalid signature");
allowance[owner][spender] = value;
}
The domain separator binds the signature to:
- The contract name and version
- The chain ID
- The contract address
This prevents replay across contracts and chains.
Attack 1: Missing Chain ID
If the domain separator does NOT include block.chainid, the signature can be replayed on other chains.
Vulnerable Code
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,address verifyingContract)"),
keccak256("MyToken"),
keccak256("1"),
address(this) // Missing chainId!
));
An attacker can:
Fix
Always include chainId:
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyToken"),
keccak256("1"),
block.chainid, // Include chainId
address(this)
));
Attack 2: Missing Nonce
If the signature does NOT include a nonce, it can be replayed multiple times.
Vulnerable Code
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
deadline
// Missing nonce!
));
An attacker can:
Fix
Include a nonce and increment it on each use:
mapping(address => uint256) public nonces;
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
nonces[owner]++, // Include and increment nonce
deadline
));
Attack 3: Missing Contract Address
If the domain separator does NOT include address(this), the signature can be replayed on other contracts.
Vulnerable Code
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId)"),
keccak256("MyToken"),
keccak256("1"),
block.chainid
// Missing address(this)!
));
An attacker can:
Fix
Include address(this):
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyToken"),
keccak256("1"),
block.chainid,
address(this) // Include contract address
));
Attack 4: Cross-Contract Replay
If two contracts share the same domain separator, signatures can be replayed across them.
Vulnerable Code
contract TokenA {
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyToken"), // Same name!
keccak256("1"),
block.chainid,
address(this)
));
}
contract TokenB {
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyToken"), // Same name!
keccak256("1"),
block.chainid,
address(this)
));
}
If deployed at the same address on different chains, signatures can be replayed.
Fix
Use unique names or include a salt:
bytes32 public DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)"),
keccak256("TokenA"), // Unique name
keccak256("1"),
block.chainid,
address(this),
keccak256("unique-salt")
));
Best Practices
1. Use OpenZeppelin's EIP712 and Nonces
OpenZeppelin's EIP712 contract handles domain separator construction and caching:
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/Nonces.sol";
contract MyToken is EIP712, Nonces {
constructor() EIP712("MyToken", "1") {}
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
require(block.timestamp <= deadline, "Expired");
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
_useNonce(owner), // Safe nonce handling
deadline
));
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, v, r, s);
require(signer == owner, "Invalid signature");
allowance[owner][spender] = value;
}
}
2. Include All Critical Fields
Your struct hash should include:
ownerorfrom— who is signing
nonce— to prevent replay
deadline— to prevent stale signatures
- All parameters that affect the action
3. Test Replay Resistance
Write Foundry tests to verify signatures cannot be replayed:
function testCannotReplaySignature() public {
uint256 privateKey = 0x1234;
address owner = vm.addr(privateKey);
bytes32 digest = ... // Construct digest
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
// First call succeeds
token.permit(owner, spender, value, deadline, v, r, s);
// Second call with same signature fails
vm.expectRevert("Invalid nonce");
token.permit(owner, spender, value, deadline, v, r, s);
}
4. Test Cross-Chain Replay
Test that signatures fail on different chain IDs:
function testCannotReplayCrossChain() public {
uint256 privateKey = 0x1234;
address owner = vm.addr(privateKey);
// Sign on chain 1
vm.chainId(1);
bytes32 digest1 = ... // Construct digest
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest1);
// Replay on chain 2 fails
vm.chainId(2);
vm.expectRevert("Invalid signature");
token.permit(owner, spender, value, deadline, v, r, s);
}
Summary
EIP-712 is powerful, but only if you get the details right:
chainId in the domain separatoraddress(this) in the domain separatorUse OpenZeppelin's EIP712 and Nonces to avoid common pitfalls.
EIP-712 prevents replay attacks — but only if you implement it correctly.