EIP-712 Vulnerability Patterns
EIP-712 is one of the most important signing standards in the Ethereum ecosystem, widely used in Permit (EIP-2612), Meta Transactions, Governance Voting, and more. However, due to its complex encoding rules, developers often make mistakes during implementation, leading to serious security vulnerabilities.
This article summarizes 4 common EIP-712 vulnerability patterns with 10 real audit cases to help you quickly master EIP-712 auditing.
EIP-712 Digest Structure
The EIP-712 signature digest consists of three parts:
digest = keccak256("\x19\x01" ‖ domainSeparator ‖ structHash)
1. "\x19\x01" - Magic Prefix (2 bytes)
A fixed 2-byte prefix that distinguishes different signature types:
| Prefix | Signature Type |
|---|---|
\x19\x00 |
personal_sign (EIP-191) |
\x19\x01 |
Typed data sign (EIP-712) |
\x19\x45 |
Other variants… |
\x19- Prevents collision with regular transaction signatures\x01- Indicates this is an EIP-712 typed signature
2. domainSeparator - Domain Separator (32 bytes)
Identifies the “scope” of the signature, preventing cross-contract/cross-chain replay:
domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("MyApp"), // Application name
keccak256("1"), // Version
block.chainid, // Chain ID
address(this) // Contract address
));
3. structHash - Structured Data Hash (32 bytes)
The actual business data that the user signs:
// Example: Permit operation
structHash = keccak256(abi.encode(
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
owner,
spender,
value,
nonce,
deadline
));
Visual Representation
digest = keccak256(
┌─────────────┬────────────────────┬─────────────────┐
│ "\x19\x01" │ domainSeparator │ structHash │
│ 2 bytes │ 32 bytes │ 32 bytes │
└─────────────┴────────────────────┴─────────────────┘
)
= 32 bytes hash
The final digest is passed to ecrecover(digest, v, r, s) to verify the signer’s address.
Vulnerability Patterns
1. Cross-chain EIP-712 signature replay via user-controlled domain separator
Prerequisite Knowledge:
EIP-712 specifies that the domain separator is a critical component of typed data signatures, designed to prevent replay attacks across different domains (contracts or chains). The domain separator is defined as keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, name, version, chainId, verifyingContract, salt)). The chainId field is essential for ensuring signatures are only valid on a specific blockchain. When a signature is verified, the domain separator must be computed on-chain using the correct chainId to prevent cross-chain replay attacks.
Root Cause:
The contract accepts the domainSeparator as a user-provided parameter instead of computing it on-chain using block.chainid. This violates the EIP-712 security model where the domain separator should be trustlessly derived from the contract’s deployment context. The attacker can supply any domain separator, effectively bypassing the chain-specific replay protection that EIP-712 is designed to provide.
Attack Vector:
- User signs a transaction on Chain A with their private key, creating a valid EIP-712 signature. 2. Attacker obtains this signature and the original request data. 3. Attacker submits the same transaction to the contract on Chain B, providing a domain separator that corresponds to Chain B (or any domain that makes the signature valid). 4. Since the contract doesn’t verify the domain separator and only checks the signature against the user-provided domain, the signature is accepted on Chain B. 5. The transaction executes unauthorized on Chain B, potentially transferring funds or performing other actions without the user’s consent.
Instances:
- [code4rena/2025-01-next-generation] Cross-chain signature replay attack due to user-supplied
domainSeparatorand missing deadline check (finding-003.md)contracts/Forwarder.solfunction _verifySig( ForwardRequest memory req, bytes32 domainSeparator, // VULNERABILITY: User-supplied domainSeparator allows cross-chain replay bytes32 requestTypeHash, bytes memory suffixData, bytes memory sig ) internal view { require(typeHashes[requestTypeHash], "NGEUR Forwarder: invalid request typehash"); bytes32 digest = keccak256( abi.encodePacked("\x19\x01", domainSeparator, keccak256(_getEncoded(req, requestTypeHash, suffixData))) ); // VULNERABILITY: No deadline check allows indefinite signature validity require(digest.recover(sig) == req.from, "NGEUR Forwarder: signature mismatch"); } function execute( ForwardRequest calldata req, bytes32 domainSeparator, // VULNERABILITY: User-supplied domainSeparator passed to _verifySig bytes32 requestTypeHash, bytes calldata suffixData, bytes calldata sig ) external payable returns (bool success, bytes memory ret) { _verifyNonce(req); _verifySig(req, domainSeparator, requestTypeHash, suffixData, sig); // VULNERABILITY: Vulnerable signature verification _updateNonce(req); require(req.to == _eurfAddress, "NGEUR Forwarder: can only forward NGEUR transactions"); bytes4 transferSelector = bytes4(keccak256("transfer(address,uint256)")); bytes4 reqTransferSelector = bytes4(req.data[:4]); require(reqTransferSelector == transferSelector, "NGEUR Forwarder: can only forward transfer transactions"); // solhint-disable-next-line avoid-low-level-calls (success, ret) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from)); require(success, "NGEUR Forwarder: failed tx execution"); _eurf.payGaslessBasefee(req.from, _msgSender()); return (success, ret); } function verify( ForwardRequest calldata req, bytes32 domainSeparator, // VULNERABILITY: User-supplied domainSeparator passed to _verifySig bytes32 requestTypeHash, bytes calldata suffixData, bytes calldata sig ) external view { _verifyNonce(req); _verifySig(req, domainSeparator, requestTypeHash, suffixData, sig); // VULNERABILITY: Vulnerable signature verification bytes4 transferSelector = bytes4(keccak256("transfer(address,uint256)")); bytes4 reqTransferSelector = bytes4(req.data[:4]); require(reqTransferSelector == transferSelector, "NGEUR Forwarder: can only forward transfer transactions"); }
2. EIP712 Dynamic Type Encoding Mismatch
Prerequisite Knowledge:
According to EIP-712 specification, dynamic values bytes and string are encoded as a keccak256 hash of their contents. The specification states: ‘The dynamic values bytes and string are encoded as a keccak256 hash of their contents.’ This means when encoding a struct containing a bytes field for the digest, the bytes value must first be hashed with keccak256 before being included in the abi.encode call.
Root Cause:
The contract incorrectly assumes that the bytes type can be directly encoded in the EIP-712 digest calculation. It violates the EIP-712 specification by not hashing the dynamic bytes data before encoding, leading to a mismatch between the digest computed by the contract and the one computed by standard EIP-712 compliant tools.
Attack Vector:
An attacker or even a legitimate user cannot generate a valid signature using standard EIP-712 libraries (like ethers.js). The signature generated by these tools will fail the contract’s checkSignature validation, effectively breaking the signature verification functionality for any user attempting to use standard tools.
Source:
- [sherlock/2024-04-titles] mt030d - Incorrect encoding of bytes for EIP712 digest in
TitleGraphcauses signatures generated by common EIP712 tools to be unusable (issue-74.md)wallflower-contract-v2/src/graph/TitlesGraph.sol/// @notice Modified to check the signature for a proxied acknowledgment. modifier checkSignature(bytes32 edgeId, bytes calldata data, bytes calldata signature) { bytes32 digest = _hashTypedData(keccak256(abi.encode(ACK_TYPEHASH, edgeId, data))); // VULNERABILITY: EIP712 requires dynamic bytes to be encoded as keccak256 hash of contents if ( !edges[edgeId].to.creator.target.isValidSignatureNowCalldata(digest, signature) || _isUsed[keccak256(signature)] ) { revert Unauthorized(); } _; _isUsed[keccak256(signature)] = true; }
3. EIP-712 Signature Replay Due to Missing Context
Prerequisite Knowledge:
EIP-712 typed data signing requires all relevant context to be included in the signed message to prevent replay attacks. The signature is only valid for the specific data structure hash. According to EIP-712: ‘The data is signed in a structured way that is human-readable and prevents collisions on different data structures.’ If a piece of context (like a nonce, deadline, or in this case, an election cycle ID) is omitted from the signed data, the signature remains valid across different contexts where the signed data is the same.
Root Cause:
The rankCandidatesBySig function’s EIP-712 typed data hash only includes the TYPEHASH and orderedCandidates. It omits the current election cycle identifier (s_voteNumber). This violates the core principle of EIP-712, which is to bind a signature to a specific, complete set of data. The contract incorrectly assumes that a signature for a set of candidates is only valid for a single, implicit context, when in fact it’s valid for any context where the same candidate set is presented.
Attack Vector:
An attacker obtains a valid signature from a voter for a specific candidate ranking. Because the election cycle ID (s_voteNumber) is not part of the signed message, the attacker can wait for the current election to end and then call rankCandidatesBySig in the new election cycle with the same signature and candidate list. The signature verification will pass because the signed data hash is identical, allowing the attacker to cast the victim’s vote in a new election without their consent.
Source:
- [codehawks/2024-09-president-elector] Signature Replay Attack in rankCandidatesBySig Function (
M-01.md)src/RankedChoice.solbytes32 public constant TYPEHASH = keccak256("rankCandidates(uint256[])"); // VULNERABILITY: TYPEHASH doesn't include s_voteNumber, allowing signature replay across different election cycles function rankCandidatesBySig( address[] memory orderedCandidates, bytes memory signature ) external { bytes32 structHash = keccak256(abi.encode(TYPEHASH, orderedCandidates)); // VULNERABILITY: Missing s_voteNumber in signature hash enables replay attacks bytes32 hash = _hashTypedDataV4(structHash); address signer = ECDSA.recover(hash, signature); _rankCandidates(orderedCandidates, signer); }
4. EIP-712 Domain TypeHash Mismatch
Prerequisite Knowledge:
EIP-712 requires that the type hash must exactly match the structure of the encoded data. The type hash is keccak256 of the struct’s type definition string. For example, if the domain separator encodes ‘EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)’, then the DOMAIN_TYPEHASH must be keccak256(‘EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)’). Any mismatch between the type hash definition and the actual encoded structure will cause signature verification to fail.
Root Cause:
The contract defines a DOMAIN_TYPEHASH that omits the ‘version’ field, but the domainSeparator calculation includes the version field. This violates the EIP-712 specification requirement that the type hash must exactly match the encoded data structure.
Attack Vector:
The vulnerability prevents the delegateBySig function from working correctly. Any attempt to delegate voting power using an EIP-712 signature will fail verification because the digest hash calculated by the contract will not match the digest hash used by the signer, rendering the signature-based delegation feature completely broken.
Instances:
- [code4rena/2025-05-blackhole] EIP-712 domain type hash mismatch breaks signature-based delegation (
finding-015.md)contracts/VotingEscrow.solbytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); // VULNERABILITY: Type hash missing version field bytes32 domainSeparator = keccak256( // VULNERABILITY: Domain separator includes version parameter not in type hash abi.encode( DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes(version)), // Version parameter encoded but not in type hash block.chainid, address(this) ) );
- [codehawks/2024-05-beanstalk-the-finale] M-16. Improper Domain Separator Hash in _domainSeparatorV4() Function (
M-16.md)protocol/contracts/libraries/LibTractor.solfunction _domainSeparatorV4() internal view returns (bytes32) { return keccak256( abi.encode( BLUEPRINT_TYPE_HASH, // VULNERABILITY: Using BLUEPRINT_TYPE_HASH instead of EIP712_TYPE_HASH for domain separator TRACTOR_HASHED_NAME, TRACTOR_HASHED_VERSION, C.getChainId(), address(this) ) ); }
- [codehawks/2024-05-beanstalk-the-finale] M-02. The declaration and use of
LibTractor::BLUEPRINT_TYPE_HASHare inconsistent with the structurestruct Blueprint, and the standard is confusing. It is recommended to unify the standard (M-02.md)protocol/contracts/libraries/LibTractor.solbytes32 public constant BLUEPRINT_TYPE_HASH = keccak256( // VULNERABILITY: Type hash defines 'bytes operatorData' but struct has 'bytes32[] operatorPasteInstrs' "Blueprint(address publisher,bytes data,bytes operatorData,uint256 maxNonce,uint256 startTime,uint256 endTime)" ); struct Blueprint { address publisher; bytes data; // VULNERABILITY: Actual struct field is 'bytes32[] operatorPasteInstrs', not 'bytes operatorData' bytes32[] operatorPasteInstrs; uint256 maxNonce; uint256 startTime; uint256 endTime; } function _getBlueprintHash(Blueprint calldata blueprint) internal view returns (bytes32) { return _hashTypedDataV4( keccak256( abi.encode( // VULNERABILITY: Uses mismatched BLUEPRINT_TYPE_HASH that doesn't match struct definition BLUEPRINT_TYPE_HASH, blueprint.publisher, keccak256(blueprint.data), keccak256(abi.encodePacked(blueprint.operatorPasteInstrs)), blueprint.maxNonce, blueprint.startTime, blueprint.endTime ) ) ); }
- [codehawks/2024-07-biconomy] M-04. Typehash for ModuleEnableMode struct is incorrect (
M-04.md)contracts/types/Constants.solbytes32 constant MODULE_ENABLE_MODE_TYPE_HASH = keccak256("ModuleEnableMode(address module, bytes32 initDataHash)"); // VULNERABILITY: Incorrect EIP-712 typehash - has extra space after comma and wrong type (bytes32 vs bytes)contracts/base/ModuleManager.solfunction _getEnableModeDataHash(address module, bytes calldata initData) internal view returns (bytes32 digest) { // VULNERABILITY: Function signature doesn't match typehash - uses 'bytes' instead of 'bytes32' digest = _hashTypedData( keccak256( abi.encode( MODULE_ENABLE_MODE_TYPE_HASH, module, keccak256(initData) // Mismatch: initData is 'bytes' but typehash expects 'bytes32' ) ) ); }
- [code4rena/2024-04-noya] [M-11]
Keepersdoes not implement EIP712 correctly on multiple occasions (finding-034.md)contracts/governance/Keepers.solbytes32 txInputHash = keccak256(abi.encode(TXTYPE_HASH, nonce, destination, data, gasLimit, executor, deadline)); bytes32 totalHash = keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), txInputHash)); // VULNERABILITY: Incorrect EIP712 hash construction - should use _hashTypedDataV4() instead address lastAdd = address(0); for (uint256 i = 0; i < threshold;) { address recovered = ECDSA.recover(totalHash, sigV[i], sigR[i], sigS[i]); // VULNERABILITY: Using raw ECDSA.recover instead of EIP712.recover for typed data signatures require(recovered > lastAdd && isOwner[recovered]); lastAdd = recovered; unchecked { ++i; } }
- [codehawks/2024-09-president-elector] Incorrect hashing in
rankCandidatesBySigfunction leads to mismatched signer and msg.sender (M-02.md)src/RankedChoice.solbytes32 public constant TYPEHASH = keccak256("rankCandidates(uint256[])"); // VULNERABILITY: Incorrect type in hash - should be address[] not uint256[] function rankCandidatesBySig( address[] memory orderedCandidates, bytes memory signature ) external { bytes32 structHash = keccak256(abi.encode(TYPEHASH, orderedCandidates)); // VULNERABILITY: Uses wrong TYPEHASH causing mismatch bytes32 hash = _hashTypedDataV4(structHash); address signer = ECDSA.recover(hash, signature); // VULNERABILITY: Recovered signer won't match due to incorrect hash _rankCandidates(orderedCandidates, signer); // VULNERABILITY: Uses incorrect signer address without validation
- [sherlock/2024-09-predict-fun] hashProposal uses wrong typeshash when hashing the encoded Proposal struct data (
issue-266.md)predict-dot-loan/contracts/PredictDotLoan.solfunction hashProposal(Proposal calldata proposal) public view returns (bytes32 digest) { digest = _hashTypedDataV4( keccak256( abi.encode( keccak256( // VULNERABILITY: Type hash uses 'uint256 questionId' but struct defines 'bytes32 questionId' "Proposal(address from,uint256 loanAmount,uint256 collateralAmount,uint8 questionType,uint256 questionId,bool outcome,uint256 interestRatePerSecond,uint256 duration,uint256 validUntil,uint256 salt,uint256 nonce,uint8 proposalType,uint256 protocolFeeBasisPoints)" ), proposal.from, proposal.loanAmount, proposal.collateralAmount, proposal.questionType, proposal.questionId, proposal.outcome, proposal.interestRatePerSecond, proposal.duration, proposal.validUntil, proposal.salt, proposal.nonce, proposal.proposalType, proposal.protocolFeeBasisPoints ) ) ); }
EIP-712 Audit Checklist
When auditing EIP-712 implementations, ensure you check these 4 core issues:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Is domainSeparator computed internally by the contract? It should NOT accept user-provided domainSeparator parameter | Pattern 1 |
| ✅ | Are dynamic types (bytes/string) hashed before encoding? Check how dynamic types are handled in abi.encode() |
Pattern 2 |
| ✅ | Does structHash include all anti-replay fields? Check for nonce, deadline, business-specific IDs | Pattern 3 |
| ✅ | Does TypeHash exactly match the encoding structure? Compare field types, order, and format character by character | Pattern 4 |
InfiniteSec