EIP-191 and EIP-712: Signed Message Standards
Raw ecrecover operates on a 32-byte hash with no inherent structure. The structure — the binding between a signature and what it authorizes — is supplied entirely by what the contract hashes before recovery. Two standards govern how Ethereum applications construct that hash:
- EIP-191: Signed Data Standard. The original, simple framing.
- EIP-712: Typed Structured Data Hashing and Signing. The modern, structured framing.
Both standards exist to prevent a particularly nasty class of bug: tricking a user into signing what looks like an innocuous message but is actually an authorization for a high-value action somewhere else.
EIP-191: Personal Sign
EIP-191 defines a single-byte version prefix followed by version-specific data. The most common form, version 0x45 ("E"), is what wallets implement as personal_sign:
0x19 || 0x45 || "thereum Signed Message:\n" || len(message) || message
When a user signs a message via personal_sign, the wallet:
- Wraps the message with the prefix above.
- Hashes it with keccak256.
- Signs the resulting 32-byte hash.
The wallet displays the raw message to the user. The user believes they are "just signing a string." The prefix exists so that what they sign cannot also be interpreted as a valid transaction (which has a different prefix structure), preventing a malicious dapp from getting a user to sign a transaction by disguising it as a message.
To verify an EIP-191 personal-sign signature on-chain:
function recoverPersonalSign(string memory message, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) {
bytes memory prefixed = abi.encodePacked(
"\x19Ethereum Signed Message:\n",
Strings.toString(bytes(message).length),
message
);
return ecrecover(keccak256(prefixed), v, r, s);
}
Or, more idiomatically, with OpenZeppelin:
bytes32 hash = ECDSA.toEthSignedMessageHash(bytes(message));
address signer = ECDSA.recover(hash, signature);
Problems with EIP-191 Personal Sign
It works, but the UX is bad and the security model is fragile:
- Wallets display the raw message bytes. If the message is binary data (encoded function call parameters), the user sees gibberish.
- No structured field labels. A user signing "0x1234...DEAD" has no way to know what fields mean what.
- No domain separation. The same signed message could be valid against any contract that uses the same encoding scheme — an attacker can replay a signature from one app against another.
- Easy to construct collisions between distinct messages by manipulating string concatenation (especially when fields can contain delimiter-like characters).
For any signature use case that involves structured data (token approvals, swap orders, multisig confirmations, off-chain quotes), EIP-712 is strictly preferred.
EIP-712: Typed Structured Data
EIP-712 lets wallets display structured signed data to users in a readable way, and lets contracts verify it with strong domain separation. A typed-data signature has three layers:
- Domain separator — identifies which application the signature is for.
- Typed-data struct — defines the message's fields with explicit types.
- Final signing hash — computed by combining the domain separator and the struct hash.
Domain Separator
The domain separator includes the chain ID, the verifying contract's address, and an application-specific name and version:
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("MyApp")),
keccak256(bytes("1")),
block.chainid,
address(this)
));
Critically:
- Including
chainIdprevents a signature from being valid on a different chain (e.g., signed on Ethereum mainnet, replayed on a fork or testnet). - Including
verifyingContractprevents a signature from being valid against a different deployment of the same code. - Including
nameandversionlets the application identify itself in the wallet UI and version-bump if the message format changes.
Audit-critical: contracts should compute the domain separator dynamically (block.chainid, address(this)) rather than caching it at deployment. Cached domain separators break across:
- Forks (chain ID changes).
- Proxy upgrades where the implementation's deployment context differs from the proxy's.
- Clone-pattern deployments (multiple instances of the same code).
OpenZeppelin's EIP712 base contract handles this correctly by default — it caches the domain separator for gas efficiency but recomputes when block.chainid differs. Custom implementations should match this behavior.
Typed Struct
Each struct type has a typehash: a keccak256 of its canonical encoding.
bytes32 PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
bytes32 structHash = keccak256(abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
nonce,
deadline
));
Field order and types must match exactly. Any deviation produces a different typehash, which means signatures are not interchangeable between versions — an intentional consequence.
The Final Hash
bytes32 digest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
structHash
));
address signer = ECDSA.recover(digest, signature);
The 0x1901 prefix is the EIP-712 marker; together with the domain separator, it ensures the signed bytes cannot be confused with a transaction, an EIP-191 message, or a different EIP-712 application.
What an Auditor Should Check
For every EIP-712 implementation in scope:
-
The domain separator includes
chainIdandverifyingContract. -
The domain separator is computed dynamically (or correctly rebuilt on
block.chainidmismatch). - The typehash string is exact — every type, every name, every comma, every space.
-
The struct's
abi.encodematches the typehash's declared field order and types. -
Bytes/string fields are hashed (
keccak256(bytes(field))) before being included inabi.encode, not included raw. - Nested struct fields are themselves hashed correctly using their own typehashes.
- Arrays of structs are hashed by concatenating their element struct hashes, then keccak256ing.
-
The signing prefix is
"\x19\x01", two bytes, with no leading or trailing whitespace. - If the contract supports both EIP-712 and EIP-191 paths, neither can be used as an unintended bypass of the other.
The mismatched-typehash bug is the most common EIP-712 finding: a single character difference in the typehash string ("Permit(address owner,address spender,...)" vs "Permit(address owner, address spender,...)" — note the space) produces an incompatible signature. Off-chain libraries (ethers, viem, web3.js) compute typehashes from a canonical form; the on-chain code must match.
EIP-712 in Wallet UIs
Modern wallets (MetaMask, Rabby, Frame, Coinbase Wallet) display EIP-712 messages with field labels and values. This is a significant UX improvement and reduces the chance of users blindly signing dangerous payloads. But the security only holds if:
- The application uses meaningful field names (not
bytes bloboruint256 _x1). - The wallet correctly parses the typed data (older wallets had bugs here; modern ones are reliable).
- The user actually reads what they sign.
The third condition is the soft underbelly. Phishing UIs that ask users to sign innocuous-looking messages that authorize token transfers ("welcome message", "join airdrop", "verify wallet") have drained many wallets. The audit can't fix user behavior, but the audit can flag any signed message whose field names or types could mislead a user about what they are authorizing.
EIP-1271: Contract Wallet Signatures
When a smart-contract wallet (Safe, Argent, 4337 wallet) needs to sign a message, it can't use ecrecover directly — it has no private key. EIP-1271 defines a standard for contract wallets to verify signatures on their own behalf:
interface IERC1271 {
function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4);
}
// Returns 0x1626ba7e if valid.
A contract that accepts signatures from arbitrary signers should support both EOA and contract-wallet flows:
function isValidSignatureNow(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) {
if (signer.code.length == 0) {
// EOA path: ecrecover
return ECDSA.recover(hash, signature) == signer;
} else {
// Contract wallet path: EIP-1271
try IERC1271(signer).isValidSignature(hash, signature) returns (bytes4 magic) {
return magic == 0x1626ba7e;
} catch {
return false;
}
}
}
OpenZeppelin's SignatureChecker.isValidSignatureNow implements this pattern correctly. Audit notes:
- A contract that only checks EOA signatures (no EIP-1271 fallback) excludes contract wallets. For some applications this is intentional; for most it's an oversight.
- A contract that supports EIP-1271 must handle the call's failure modes (revert, out-of-gas, return-data-too-short) gracefully.
- The signature data passed to
isValidSignatureis opaque to the verifying contract — it could be anything the wallet'sisValidSignatureunderstands. This is by design.
ERC-6492: Counterfactual Signatures
A contract wallet can sign messages before being deployed, using ERC-6492 to encode "deploy this code, then verify against the deployed instance" semantics. This matters for account abstraction flows where the wallet may not yet exist on-chain when it signs an off-chain message.
ERC-6492 is increasingly seen in 4337 deployments and in applications that target counterfactual smart-contract wallets (Coinbase Smart Wallet, Safe predicted-address flows). Audit considerations are specialized; refer to the spec when reviewing contracts that accept ERC-6492-formatted signatures.
Recap
- Use EIP-712 for any structured signed message in new code.
- Verify the domain separator is dynamic and includes chain ID and contract address.
- Verify the typehash string and the
abi.encodefield order match exactly. - Support EIP-1271 if the application is expected to interact with contract wallets.
- Reject EIP-191 personal-sign for anything other than human-readable text.
A signature scheme that follows these rules and has correct replay protection (next section) is hard to misuse. Most signature findings in the wild come from skipping one of these.