EIP-712 Domain Separator

concept
ethereumeip-712signaturesreplay-protectionphishing

The domain separator is the 32-byte value that binds an EIP-712 signature to a specific protocol, version, chain, and contract. It is the mechanism that prevents a signature produced for one DApp from being replayed against another — even when the two DApps use structurally identical message types.

The definition

domainSeparator = hashStruct(eip712Domain)

Where eip712Domain is an instance of a reserved struct type named EIP712Domain. Domain separators are computed using the same hashStruct rules as any other struct — EIP712Domain is not special at the hashing layer, only semantically.

The EIP712Domain fields

EIP712Domain may contain one or more of the following fields. Protocol designers include only the fields that make sense for their signing domain:

FieldPurpose
string nameUser-readable name of the signing domain (e.g., “Ether Mail”, “Uniswap V2”, “Farcaster SignedKeyRequestValidator”)
string versionCurrent major version; signatures from different versions are incompatible
uint256 chainIdEIP-155 chain ID; wallets should refuse signing if it does not match the active chain
address verifyingContractAddress of the contract that will verify the signature; enables contract-specific phishing prevention
bytes32 saltDisambiguating salt of last resort

Field ordering rules:

  • Present fields must appear in the order above (absences skipped).
  • Future field additions must be alphabetical and come after these.
  • User-agents should accept fields in any order as specified by the EIP712Domain type.
  • DApp implementers should not add private fields; new fields should be proposed through the EIP process.

What the domain separator protects against

Cross-DApp collision

Two DApps may independently define the same structure:

Transfer(address from,address to,uint256 amount)

Without domain separation, a signature for DApp A’s Transfer would also authorize DApp B’s Transfer — a catastrophic replay. With domain separation, the two signing domains produce distinct domainSeparator values and the signatures are incompatible.

Cross-chain replay

Because chainId is part of EIP712Domain, a signature for Ethereum mainnet cannot be replayed on Optimism, Arbitrum, or any other EVM chain. This is why wallets should verify chainId against the active chain before signing.

Cross-contract replay within the same DApp

Because verifyingContract is part of EIP712Domain, a signature for contract A cannot be replayed against contract B even in the same DApp — useful when a protocol has multiple verifier contracts.

Cross-version replay

Because version is part of EIP712Domain, upgrading a protocol’s major version invalidates all outstanding signatures from the previous version. This is how a protocol can force renewal of existing approvals after a breaking change.

Multiple signing purposes within a single DApp

The same DApp may need signatures from multiple parties over the same struct — for example, both from and to in a Transfer(address from,address to,uint256 amount). By using two distinct domain separators (e.g., one with name: "DApp From" and one with name: "DApp To"), the signatures can be distinguished from each other without changing the underlying struct type.

Rejected alternative: verifyingContract alone

An early alternative was to use only the verifying contract address as the domain separator. This solves the cross-DApp collision problem but:

  • Does not support multiple signing purposes on the same struct within a DApp.
  • Does not separate across versions or chains.
  • Does not give wallets a human-readable name to display.

The full EIP712Domain struct was adopted instead. The standard still suggests implementors include verifyingContract where appropriate — it just isn’t sufficient on its own.

The final signable encoding

Once the domain separator is computed, every EIP-712 signature follows the same pattern:

sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)))

The \x19\x01 prefix is ERC-191 version byte 0x01 with the domain separator as version-specific data. The hashStruct(message) at the end is the primary-type struct being authorized. See Ethereum Signed Message Encoding for how this prefix keeps the encoding injective with respect to transactions and raw-bytes signing.

Applied example: Farcaster SignedKeyRequest

The Farcaster account-creation walkthrough shows a real-world domain separator computation:

EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)

With:

  • name = "Farcaster SignedKeyRequestValidator"
  • version = "1"
  • chainId = 10 (Optimism)
  • verifyingContract = 0x00000000FC700472606ED4fA22623Acf62c60553

These four fields ensure the signature authorizing a new Farcaster signing key is bound to Farcaster’s validator on Optimism and cannot be replayed against any other contract, chain, or protocol version.

Caching in Solidity

Protocol implementations typically cache the domain separator as a storage variable or a bytes32 immutable, recomputing only if chainId changes (rare, only on chain forks). The typeHash for EIP712Domain itself is always a compile-time constant.

bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

bytes32 public immutable DOMAIN_SEPARATOR = keccak256(abi.encode(
    EIP712_DOMAIN_TYPEHASH,
    keccak256(bytes("MyProtocol")),
    keccak256(bytes("1")),
    block.chainid,
    address(this)
));

Connections