
Solidity EIP-712 Typed Data Signatures: Designing Secure Off-Chain Authorization Flows
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:
- Domain separator
Identifies the contract, chain, and application context.
- Typed data hash
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, notabi.encodePacked, for struct hashing. - Include all relevant domain fields.
- Use a nonce or unique order ID to prevent replay.
Comparison of encoding choices
| Approach | Suitable for EIP-712? | Notes |
|---|---|---|
abi.encode | Yes | Safe for typed struct hashing |
abi.encodePacked | Usually no | Can create ambiguity for dynamic values |
| Raw string concatenation | No | Hard to validate and easy to mismatch |
ecrecover on arbitrary bytes | No | Lacks 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
- Per-signer nonce
- simplest and most common
- increments after each successful authorization
- Per-order nonce or order hash
- useful in marketplaces
- allows independent cancellation of individual orders
- Bitmap nonces
- efficient for large-scale systems
- useful when many independent signatures may be consumed out of order
Choosing the right strategy
| Strategy | Best for | Tradeoff |
|---|---|---|
| Sequential nonce | Simple approvals, vaults | Requires ordered consumption |
| Order hash | Market orders, listings | Needs explicit cancellation logic |
| Bitmap nonce | High-throughput systems | More 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)fromecrecover - 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, andsformat 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
constantvalues - 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.
