Imagine your application wants a user to authorize an off-chain action:
Transfer 100 USDC
to 0x…
nonce 42
deadline …
You could serialize that data into a string and ask the wallet to sign it. But then subtle questions appear: Which serialization is canonical? Is 100 a string or an integer? Which contract is allowed to consume the signature? Can the same signature work on another chain?
EIP-712 solves the encoding side of this problem by defining a deterministic way to hash and sign typed structured data. Instead of signing arbitrary JSON text, the wallet signs a digest derived from explicit Solidity-like types, the message, and an application-specific domain.
That makes EIP-712 particularly useful for permits, meta-transactions, order protocols, delegated actions, and other off-chain authorizations that are later verified on-chain.
Why signing plain strings is not enough
Ethereum wallets can sign arbitrary messages using mechanisms such as personal_sign. This works well when the thing being signed really is a human-readable message.
Structured application data is different.
Suppose two applications serialize this object differently:
{“to”:”0x…”,”amount”:”100″}
and:
{
“amount”: “100”,
“to”: “0x…”
}
They may represent the same intent to a developer, but they are different byte strings.
Plain message signing also does not inherently describe Solidity types. A wallet sees bytes or text rather than an explicit structure such as address to, uint256 amount, and uint256 nonce.
EIP-712 defines a typed encoding and hashing scheme instead. Wallets can use that structure to present meaningful fields to the user rather than an opaque serialized blob.
EIP-712 Explained: Domain, Types, and Message
Consider a transfer authorization:
const domain = {
name: “ExampleApp”,
version: “1”,
chainId: 1,
verifyingContract: “0x1234567890123456789012345678901234567890”,
};
const types = {
Transfer: [
{ name: “to”, type: “address” },
{ name: “amount”, type: “uint256” },
{ name: “nonce”, type: “uint256” },
{ name: “deadline”, type: “uint256” },
],
};
const value = {
to,
amount,
nonce,
deadline,
};
There are four important pieces.
Domain identifies the application context in which the signature is valid.
Types define the exact structure and Solidity-compatible types being signed.
Primary type is the root structure — Transfer in this example.
Message is the actual set of values.
The domain is what gives EIP-712 its domain separation. Two applications can sign structurally identical Transfer messages without necessarily producing interchangeable signatures.
For production authorizations, two domain fields are especially important:
chainId
verifyingContract
chainId binds the signature to a network, while verifyingContract binds it to a particular contract address. Both become part of the EIP-712 domain separator and therefore affect the final digest.
Without appropriate domain separation, a signature intended for one context may be meaningful in another.
How the EIP-712 Hash Is Built
EIP-712 does not sign your JavaScript object or its JSON serialization.
Conceptually, the final digest is:
keccak256(
0x1901 ||
domainSeparator ||
hashStruct(message)
)
The 0x1901 prefix comes from the signed-data encoding scheme used by EIP-712.
For our Transfer structure, its encoded type is effectively:
Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)
Hashing this definition produces the typeHash:
keccak256(
“Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)”
)
The message’s structHash is then calculated from the type hash and encoded field values.
Conceptually:
keccak256(
abi.encode(
TRANSFER_TYPEHASH,
to,
amount,
nonce,
deadline
)
)
The domain is hashed using the same EIP-712 struct-hashing rules to produce the domain separator. Finally, the message struct hash and domain separator are combined into one digest.
This digest — not JSON.stringify(value) — is what ultimately gets signed.
That distinction is the reason JavaScript and Solidity can independently reconstruct exactly the same value.
At this point, the full EIP-712 flow looks like this:
Signing EIP-712 Typed Data in JavaScript
With ethers v6, the high-level API is signer.signTypedData(domain, types, value). ethers handles the EIP-712 encoding and hashing internally.
A browser example can stay compact:
import { BrowserProvider } from “ethers”;
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const chainId = (await provider.getNetwork()).chainId;
const domain = {
name: “ExampleApp”,
version: “1”,
chainId,
verifyingContract: “0x1234567890123456789012345678901234567890”,
};
const types = {
Transfer: [
{ name: “to”, type: “address” },
{ name: “amount”, type: “uint256” },
{ name: “nonce”, type: “uint256” },
{ name: “deadline”, type: “uint256” },
],
};
const value = {
to: “0x0000000000000000000000000000000000000000”,
amount: 100_000_000n, // 100 USDC with 6 decimals
nonce: 42n,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
};
const signature = await signer.signTypedData(
domain,
types,
value
);
Notice that the frontend does not manually hash individual fields. That is intentional.
Unless you are implementing infrastructure specifically around EIP-712 encoding, use the library implementation instead of recreating the algorithm yourself.
The important part is that the frontend schema must exactly match the Solidity schema.
Verifying an EIP-712 Signature in Solidity
On-chain, OpenZeppelin’s EIP712 contract reconstructs the domain-aware digest, while ECDSA performs signer recovery. OpenZeppelin explicitly documents the _hashTypedDataV4(structHash) plus ECDSA.recover pattern.
Here is the Solidity side of the same example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {EIP712} from
“@openzeppelin/contracts/utils/cryptography/EIP712.sol”;
import {ECDSA} from
“@openzeppelin/contracts/utils/cryptography/ECDSA.sol”;
contract TransferAuthorizer is EIP712 {
struct Transfer {
address to;
uint256 amount;
uint256 nonce;
uint256 deadline;
}
bytes32 private constant TRANSFER_TYPEHASH =
keccak256(
“Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)”
);
mapping(address => mapping(uint256 => bool))
public usedNonces;
constructor() EIP712(“ExampleApp”, “1”) {}
function authorizeTransfer(
address expectedSigner,
Transfer calldata transfer,
bytes calldata signature
) external {
require(
block.timestamp <= transfer.deadline,
“Authorization expired”
);
require(
!usedNonces[expectedSigner][transfer.nonce],
“Nonce already used”
);
bytes32 structHash = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
transfer.to,
transfer.amount,
transfer.nonce,
transfer.deadline
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recoveredSigner =
ECDSA.recover(digest, signature);
require(
recoveredSigner == expectedSigner,
“Invalid signature”
);
usedNonces[expectedSigner][transfer.nonce] = true;
// Execute the authorized business operation here.
}
}
_hashTypedDataV4 combines the message struct hash with the contract’s EIP-712 domain separator and produces the final digest expected by the signature verification logic.
Using OpenZeppelin is preferable to manually maintaining EIP-712 domain and ECDSA recovery code. Apart from reducing code, it avoids subtle mistakes around domain construction and signature handling.
One additional production nuance: ECDSA.recover verifies signatures from EOAs. If your application must support smart contract wallets, account abstraction, or multisigs, you should also account for ERC-1271-style contract signatures rather than assuming every signer has an ECDSA private key.
EIP-712 Does Not Prevent Replays for You
EIP-712 gives you deterministic structured signing and domain separation. It does not automatically make an authorization single-use.
Consider this perfectly valid signature:
Alice authorizes transfer X
If the contract accepts it today and nothing in contract state marks it as consumed, an attacker may simply submit the same authorization again.
That is why production messages commonly contain a nonce:
uint256 nonce;
and the contract consumes it:
mapping(address => mapping(uint256 => bool))
public usedNonces;
A deadline prevents an authorization from remaining usable indefinitely.
Meanwhile, chainId and verifyingContract provide domain-level protection against signatures being reused in different EIP-712 domains.
These controls solve related but different problems:
nonce -> prevents repeated execution
deadline -> limits validity in time
chainId -> binds the domain to a chain
verifyingContract -> binds the domain to a contract
The core rule is simple:
A valid signature is not necessarily a valid authorization forever.
Signature verification proves who signed a digest. Your contract still decides whether that authorization is currently acceptable.
Common EIP-712 Mistakes in Production
Most EIP-712 bugs are not cryptography bugs. They are schema or authorization bugs.
1. Different field order between frontend and Solidity.
Transfer(address to,uint256 amount,…) must match exactly on both sides.
2. Type mismatches.
uint256, address, bytes32, and string are not interchangeable representations.
3. Different domain name or version.
ExampleApp version 1 and ExampleApp version 2 intentionally produce different domains.
4. Missing nonce.
A correctly signed authorization may be replayable if contract state does not consume something unique.
5. Missing deadline.
Without expiration, an unused signature may remain actionable much longer than intended.
6. Using abi.encodePacked for the struct hash.
The standard EIP-712 struct encoding corresponds to the ABI-encoded values; OpenZeppelin’s documented pattern uses abi.encode.
7. Incorrect domain assumptions around deployments or proxies.
Your frontend must sign against the same effective EIP-712 domain the verification contract reconstructs. Contract addresses, chain changes, and domain-version changes should be treated as protocol changes, not frontend details.
Conclusion
EIP-712 turns Ethereum signatures from loosely defined byte-string signing into deterministic, typed, domain-separated authorization.
The flow is straightforward once the layers are separated:
JavaScript object
↓
typed EIP-712 structure
↓
domain separator + struct hash
↓
EIP-712 digest
↓
wallet signature
↓
Solidity rebuilds digest
↓
recover signer
↓
apply nonce/deadline/business rules
The standard handles how structured data becomes a signable digest. Your application still owns what that signature authorizes, when it expires, and whether it has already been used.
That separation is the key to implementing EIP-712 correctly in production.
EIP-712 Explained: Sign and Verify Typed Data with ethers.js and Solidity was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.
