Auditing EIP-712: What to Look For in Off-Chain Signing Flows

By 0xh4ty - 2025-06-08 - 4 min read

Introduction

EIP-712 is a standard designed to make off-chain signing of structured data both secure and user-friendly. It is used extensively in the Ethereum ecosystem, especially in DeFi protocols where actions such as token approvals are signed off-chain to save gas. A prominent example of this is the ERC20 permit() function introduced by EIP-2612, which allows token holders to approve transfers without sending an on-chain transaction.

However, incorrect implementations of EIP-712 are a rich source of vulnerabilities. Improper handling of signed data can lead to serious bugs, including signature replay attacks and signature forgery. This post explores this bug class in depth.

The Basics of EIP-712

EIP-712 defines how to sign typed structured data in a way that prevents signature ambiguity. This makes signatures context-specific, reducing the risk of misuse across different domains or contracts.

EIP-2612 extends ERC20 with permit(), nonces(), and DOMAIN_SEPARATOR() to enable gasless approvals. The permit flow relies on strict adherence to EIP-712 to prevent misuse.

Observed Patterns

Vulnerable Code Patterns

Static DOMAIN_SEPARATOR (missing chainId and address(this))

bytes32 public constant DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,string version)"),
        keccak256(bytes("TokenName")),
        keccak256(bytes("1"))
    )
);

No nonce in permit struct or no increment of nonces[owner]

bytes32 structHash = keccak256(
    abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        deadline // missing nonce!
    )
);

// Missing: _nonces[owner]++;

No deadline check

// Missing: require(block.timestamp <= deadline, "expired");

Wrong struct hashing (typeHash)

bytes32 public constant PERMIT_TYPEHASH = keccak256(
    "Permit(address owner,address spender,uint256 deadline,uint256 value)" // wrong order and missing nonce
);

Using abi.encodePacked instead of abi.encode

bytes32 structHash = keccak256(
    abi.encodePacked(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        nonce,
        deadline
    )
);

Skipping \x19\x01 prefix

bytes32 digest = keccak256(
    abi.encodePacked(
        DOMAIN_SEPARATOR,
        structHash
    )
);

Missing signer != address(0) check

address signer = ecrecover(digest, v, r, s);
// Missing: require(signer != address(0), "invalid sig");
require(signer == owner, "invalid sig");

Rolling custom permit() instead of importing OpenZeppelin ERC20Permit

contract MyToken is ERC20, IERC20Permit {
    // Manual DOMAIN_SEPARATOR, manual nonces, manual ecrecover — risky
}

Exploit Ideas

How to Fix

References and Further Reading

Conclusion

Not only the variants covered in this post, but there are over 20 known variants caused by broken EIP-712 implementations.

Broken EIP-712 implementations are one of the most common and dangerous bug classes in DeFi smart contracts. The financial impact of a signature replay attack can be devastating. Strict adherence to the standard and using well-audited libraries like OpenZeppelin ERC20Permit is the best way to avoid these pitfalls.