What EIP-712 solves

Plain eth_sign and personal_sign signatures are hard to reason about because they sign opaque byte strings. That creates two problems:

  • Users cannot easily understand what they are approving.
  • Contracts must carefully reconstruct the exact signed payload, which is error-prone.

EIP-712 addresses both issues by defining a standard for signing structured data. The signed message includes:

  • a domain separator, which binds the signature to a specific app and chain
  • a typed struct, which describes the action being approved

This makes signatures more secure, more readable in wallets, and easier to validate on-chain.

Common use cases

EIP-712 is useful for:

  • token permits, such as approving spending without an on-chain approval transaction
  • meta-transactions, where a relayer pays gas
  • off-chain order books and signed listings
  • delegated voting and authorization
  • permit-based access to vaults, bridges, and marketplaces

Core concepts: domain separator and typed struct

An EIP-712 signature is built from two parts:

  1. Domain separator
  2. Identifies the contract, chain, and application context.

  1. Typed data hash
  2. Encodes the specific action being authorized, such as “transfer 100 tokens to Alice before deadline.”

The final digest is typically:

keccak256(
    abi.encodePacked(
        "\x19\x01",
        domainSeparator,
        structHash
    )
)

The signer signs this digest off-chain, and the contract recomputes it on-chain to recover the signer address.

Why the domain matters

Without a domain separator, a signature intended for one contract could potentially be replayed in another contract that accepts the same struct layout. The domain usually includes:

  • name
  • version
  • chain ID
  • verifying contract address

This binds the signature to one protocol instance and one chain.


A practical example: signed authorization for a vault withdrawal

Suppose a vault allows a user to authorize a withdrawal via signature, and a relayer submits the transaction. The user signs a message like:

  • recipient
  • amount
  • nonce
  • deadline

The contract verifies the signature and executes the withdrawal.

Example contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SignedVault {
    bytes32 public constant WITHDRAW_TYPEHASH =
        keccak256("Withdraw(address recipient,uint256 amount,uint256 nonce,uint256 deadline)");

    bytes32 private immutable _DOMAIN_SEPARATOR;
    uint256 private immutable _CHAIN_ID;

    mapping(address => uint256) public nonces;
    mapping(address => uint256) public balances;

    error InvalidSignature();
    error SignatureExpired();
    error InsufficientBalance();

    constructor() {
        _CHAIN_ID = block.chainid;
        _DOMAIN_SEPARATOR = _buildDomainSeparator();
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawBySig(
        address signer,
        address recipient,
        uint256 amount,
        uint256 nonce,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        if (block.timestamp > deadline) revert SignatureExpired();
        if (nonce != nonces[signer]) revert InvalidSignature();

        bytes32 structHash = keccak256(
            abi.encode(
                WITHDRAW_TYPEHASH,
                recipient,
                amount,
                nonce,
                deadline
            )
        );

        bytes32 digest = keccak256(
            abi.encodePacked("\x19\x01", _domainSeparator(), structHash)
        );

        address recovered = ecrecover(digest, v, r, s);
        if (recovered == address(0) || recovered != signer) revert InvalidSignature();

        nonces[signer] = nonce + 1;

        if (balances[signer] < amount) revert InsufficientBalance();
        balances[signer] -= amount;

        (bool ok, ) = recipient.call{value: amount}("");
        require(ok, "TRANSFER_FAILED");
    }

    function _domainSeparator() internal view returns (bytes32) {
        if (block.chainid == _CHAIN_ID) {
            return _DOMAIN_SEPARATOR;
        }
        return _buildDomainSeparator();
    }

    function _buildDomainSeparator() internal view returns (bytes32) {
        return keccak256(
            abi.encode(
                keccak256(
                    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
                ),
                keccak256(bytes("SignedVault")),
                keccak256(bytes("1")),
                block.chainid,
                address(this)
            )
        );
    }
}

What this example demonstrates

  • the signature is bound to this contract and chain
  • each signer has a nonce to prevent replay
  • the signature expires after deadline
  • the contract verifies the recovered address before executing the transfer

Building the digest correctly

The most important part of EIP-712 is matching the off-chain and on-chain encoding exactly. A mismatch in field order, type names, or domain fields will invalidate signatures.

Rules to follow

  • Use the exact same type string in both off-chain and on-chain code.
  • Keep field order identical.
  • Use abi.encode, not abi.encodePacked, for struct hashing.
  • Include all relevant domain fields.
  • Use a nonce or unique order ID to prevent replay.

Comparison of encoding choices

ApproachSuitable for EIP-712?Notes
abi.encodeYesSafe for typed struct hashing
abi.encodePackedUsually noCan create ambiguity for dynamic values
Raw string concatenationNoHard to validate and easy to mismatch
ecrecover on arbitrary bytesNoLacks structured domain separation

Off-chain signing workflow

The signer usually creates the signature in a frontend, backend, or script using an EIP-712-capable library. The off-chain code must construct the same domain and typed data as the contract expects.

A typical payload looks like this conceptually:

{
  "domain": {
    "name": "SignedVault",
    "version": "1",
    "chainId": 1,
    "verifyingContract": "0x..."
  },
  "types": {
    "Withdraw": [
      {"name": "recipient", "type": "address"},
      {"name": "amount", "type": "uint256"},
      {"name": "nonce", "type": "uint256"},
      {"name": "deadline", "type": "uint256"}
    ]
  },
  "message": {
    "recipient": "0x...",
    "amount": "1000000000000000000",
    "nonce": 0,
    "deadline": 1730000000
  }
}

Best practices for off-chain integration

  • Display the typed message clearly in the UI.
  • Avoid signing from ambiguous or user-unfriendly prompts.
  • Validate the chain ID before signing.
  • Fetch the current nonce from the contract before building the payload.
  • Reject expired requests before sending them to the wallet.

Replay protection strategies

Replay protection is not optional. A valid signature should be usable exactly once, or only within a narrowly defined context.

Common strategies

  1. Per-signer nonce
  • simplest and most common
  • increments after each successful authorization
  1. Per-order nonce or order hash
  • useful in marketplaces
  • allows independent cancellation of individual orders
  1. Bitmap nonces
  • efficient for large-scale systems
  • useful when many independent signatures may be consumed out of order

Choosing the right strategy

StrategyBest forTradeoff
Sequential nonceSimple approvals, vaultsRequires ordered consumption
Order hashMarket orders, listingsNeeds explicit cancellation logic
Bitmap nonceHigh-throughput systemsMore complex state management

For most applications, a sequential nonce is the safest starting point.


Handling signature malleability and recovery safely

When using ecrecover, you must consider signature malleability and invalid inputs. A recovered address of address(0) is always invalid. In addition, signatures should be normalized to canonical s values when possible.

Recommended safeguards

  • reject address(0) from ecrecover
  • ensure the recovered signer matches the expected signer
  • prefer libraries or wallet tooling that produce canonical signatures
  • if your stack supports it, validate v, r, and s format strictly

For production systems, many teams use audited helper libraries rather than hand-rolling recovery logic.


Using OpenZeppelin EIP-712 helpers

In real projects, you usually do not want to manually manage every detail of the domain separator. OpenZeppelin provides a well-tested EIP712 base contract and signature utilities.

When to use a library

Use a library when:

  • you want less boilerplate
  • you need compatibility with upgradeable contracts
  • you want a standard implementation of domain separator caching
  • you are building permit-style flows or meta-transaction systems

Manual implementation is still useful for learning and for highly specialized systems, but libraries reduce the risk of subtle mistakes.

Practical guidance

  • inherit from a trusted EIP-712 base contract
  • define typed data hashes as constant values
  • keep the typed struct minimal and explicit
  • include a nonce and deadline in nearly every authorization flow

Security checklist for EIP-712 implementations

Before shipping an EIP-712 feature, verify the following:

  • The domain separator includes the correct contract and chain ID.
  • Every signature has a nonce or unique identifier.
  • Expiration is enforced when appropriate.
  • The recovered signer is checked against the expected authority.
  • The struct hash uses the exact field order and types used off-chain.
  • Dynamic values are encoded safely.
  • The contract rejects stale or replayed signatures.
  • The UI shows the user what they are signing.

Additional design tips

  • Keep signed messages small and focused.
  • Do not include unnecessary fields that complicate verification.
  • If the action is sensitive, add a short deadline.
  • If signatures can be canceled, expose a cancellation mechanism.
  • Avoid reusing the same typed struct for unrelated actions.

When EIP-712 is the right choice

EIP-712 is ideal when you need a user to authorize an action without immediately sending a transaction. It is especially valuable when the signature must be understandable, replay-resistant, and bound to a specific protocol instance.

It is less appropriate when:

  • the action is purely internal and never leaves the chain
  • the authorization does not need user-readable structure
  • a simple on-chain approval is sufficient

For most modern dApps, however, EIP-712 is the preferred standard for off-chain authorization.


Learn more with useful resources