Securite·9 min de lecture·Par Solingo

Signature Replay Attacks — When EIP-712 Goes Wrong

EIP-712 prevents most replay attacks — but only if you get the details right. Common mistakes and how to avoid them.

# 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:

  • Domain separator — prevents cross-contract and cross-chain replay
  • Struct hash — hash of the data being signed
  • Type hash — hash of the struct definition
  • 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:

  • Get a user to sign a permit on Ethereum mainnet
  • Replay the signature on a fork or testnet
  • Steal funds if the same contract address exists on both chains
  • 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:

  • Observe a signed permit
  • Replay it multiple times to grant allowance repeatedly
  • 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:

  • Deploy a malicious contract with the same domain separator
  • Replay signatures meant for the legitimate contract
  • 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:

    • owner or from — 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:

  • Include chainId in the domain separator
  • Include address(this) in the domain separator
  • Include a nonce in the struct hash
  • Increment the nonce after each use
  • Use unique names for each contract
  • Test replay resistance in your test suite
  • Use OpenZeppelin's EIP712 and Nonces to avoid common pitfalls.

    EIP-712 prevents replay attacks — but only if you implement it correctly.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement