EIP-191 vs EIP-712 is the choice between Ethereum's two signing formats for data that is not a transaction. EIP-191 (2016) defines a 0x19 prefix that can never start a valid transaction encoding, with a version byte that selects the format; version 0x45 is personal_sign, used for human-readable text. EIP-712 (2017) is version 0x01: it hashes typed, structured data together with a domain separator naming the application, chain and verifying contract. EIP-712 is therefore a specific EIP-191 format, not a competitor to the whole standard.
At a glance
EIP-191 personal_sign (version 0x45) | EIP-712 typed data (version 0x01) | |
|---|---|---|
| Status | Final, created 2016-01-20 | Final, created 2017-09-12 |
| Wallet RPC method | personal_sign | eth_signTypedData_v4 |
| Bytes hashed with Keccak-256 | 0x19 "Ethereum Signed Message:\n" length message | 0x19 0x01 domainSeparator hashStruct(message) |
| What the wallet shows | The text, or raw hex if the message is binary | Field names and values, plus the domain (name, chain, contract) |
| Replay protection | None built in; the message text must carry nonce, domain and chain | Domain separator with name, version, chainId, verifyingContract, salt |
| Contract-side verification | Rebuild the prefixed message, then ecrecover | Recompute type hashes and struct hashes, then ecrecover |
| Typical uses | Sign-In with Ethereum (EIP-4361), proof of address ownership | Permit (EIP-2612), Permit2, Safe transactions, exchange orders, governance votes |
| Signature | 65-byte secp256k1 ECDSA r, s, v | Same |
| Main pitfalls | Hex hashes signed blindly; ambiguous free text | Harmless-looking approvals; devices that show only a hash |
What each one hashes
EIP-191 version 0x45 prepends "\x19Ethereum Signed Message:\n" and the message length in decimal ASCII, then hashes with Keccak-256. The prefix guarantees that the signed bytes cannot be an RLP-encoded transaction, so a site cannot trick a wallet into signing a transfer by calling it a message. It does nothing else: whether the signature means "log in to example.com" or "approve unlimited spending" depends entirely on how the receiver interprets the text.
EIP-712 encodes each struct as the hash of its type string followed by 32-byte words for its fields: strings and bytes are hashed, addresses and integers are left-padded, nested structs are replaced by their own hashes. The domain is encoded the same way as an EIP712Domain struct. The final digest is keccak256(0x19 0x01 domainSeparator hashStruct(message)).
The script below computes both with @noble/curves 2.3.0 and @noble/hashes 2.3.0 on Node.js 26, using the "Ether Mail" message and the signing key from the reference Example.js of EIP-712. That key, keccak256("cow"), is published in the specification as a test vector: anyone can derive it, so its address must never hold funds.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { keccak_256 } from "@noble/hashes/sha3.js";
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
const hex = (b) => "0x" + bytesToHex(b);
// Test key published in the EIP-712 reference example (assets/eip-712/Example.js): keccak256("cow").
// It is public, so never send funds to its address.
const secretKey = keccak_256(utf8ToBytes("cow"));
const signer = hex(keccak_256(secp256k1.getPublicKey(secretKey, false).subarray(1)).subarray(12));
const sign = (digest) => {
const sig = secp256k1.sign(digest, secretKey, { prehash: false, format: "recovered" });
return { v: sig[0] + 27, r: hex(sig.subarray(1, 33)), s: hex(sig.subarray(33)) };
};
// EIP-191 version 0x45: personal_sign
const text = utf8ToBytes("hello");
const personalDigest = keccak_256(concatBytes(utf8ToBytes(`\x19Ethereum Signed Message:\n${text.length}`), text));
console.log("EIP-191 personal_sign digest:", hex(personalDigest));
// EIP-712: typed structured data (the "Ether Mail" example from the EIP)
const types = {
EIP712Domain: [["name", "string"], ["version", "string"], ["chainId", "uint256"], ["verifyingContract", "address"]],
Person: [["name", "string"], ["wallet", "address"]],
Mail: [["from", "Person"], ["to", "Person"], ["contents", "string"]],
};
const encodeType = (primary) => {
const deps = new Set();
const walk = (t) => types[t].forEach(([, ft]) => { if (types[ft] && !deps.has(ft)) { deps.add(ft); walk(ft); } });
walk(primary);
return [primary, ...[...deps].sort()].map((t) => `${t}(${types[t].map(([n, ft]) => `${ft} ${n}`).join(",")})`).join("");
};
const encodeValue = (ft, v) =>
types[ft] ? hashStruct(ft, v)
: ft === "string" ? keccak_256(utf8ToBytes(v))
: ft === "address" ? concatBytes(new Uint8Array(12), hexToBytes(v.slice(2)))
: hexToBytes(BigInt(v).toString(16).padStart(64, "0")); // uint256
const hashStruct = (t, v) =>
keccak_256(concatBytes(keccak_256(utf8ToBytes(encodeType(t))), ...types[t].map(([n, ft]) => encodeValue(ft, v[n]))));
const domain = { name: "Ether Mail", version: "1", chainId: 1, verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" };
const mail = {
from: { name: "Cow", wallet: "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" },
to: { name: "Bob", wallet: "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" },
contents: "Hello, Bob!",
};
const typedDigest = (d) => keccak_256(concatBytes(new Uint8Array([0x19, 0x01]), hashStruct("EIP712Domain", d), hashStruct("Mail", mail)));
console.log("encodeType(Mail):", encodeType("Mail"));
console.log("domainSeparator:", hex(hashStruct("EIP712Domain", domain)));
console.log("hashStruct(Mail):", hex(hashStruct("Mail", mail)));
console.log("EIP-712 digest:", hex(typedDigest(domain)));
console.log("signer:", signer, sign(typedDigest(domain)));
console.log("same mail, chainId 10:", hex(typedDigest({ ...domain, chainId: 10 })));
EIP-191 personal_sign digest: 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750
encodeType(Mail): Mail(Person from,Person to,string contents)Person(string name,address wallet)
domainSeparator: 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
hashStruct(Mail): 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e
EIP-712 digest: 0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2
signer: 0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826 {
v: 28,
r: '0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d',
s: '0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562'
}
same mail, chainId 10: 0x2752db86097e9749484b4645855d9ff3d1b6109c89ed55a9d7f9021dbc77bcf9
The type string, domain separator, struct hash, digest, signer address and v, r, s are exactly the values asserted in the reference Example.js of EIP-712, and the signature is deterministic (RFC 6979), so you can reproduce it. Changing only the chainId produces a different digest, which is what prevents cross-chain replay. The EIP-712 inspector shows the same intermediate hashes for any typed-data payload.
What users actually see
EIP-712's main benefit is display: a wallet can show "spender", "value" and "deadline" instead of an opaque string. That only helps if the device can render the fields and the user reads them. Most fund-draining phishing now asks for EIP-712 signatures that are fully valid, such as a permit (EIP-2612) or Permit2 approval granting a spender unlimited tokens, with no transaction and no gas, so the user sees no on-chain warning. In February 2025, three Bybit signers approved a malicious Safe transaction on hardware wallets that could show only its hash; the transaction replaced the wallet's implementation and about 1.5 billion dollars of ETH was drained. Recomputing a Safe transaction hash independently, as the Safe transaction hash tool does, is how signers can check what a device shows as hex.
personal_sign has the mirror-image weakness: wallets show text faithfully, but a site can ask users to sign a 32-byte hex string, which reveals nothing. Raw-hash signing without any prefix (eth_sign) was disabled by default in MetaMask in 2023 and removed in 2024 for that reason.
Verifying signatures
For externally owned accounts, both formats are verified by recomputing the digest and recovering the signer with ecrecover (or a library equivalent), then comparing the address; reject high-s signatures to avoid malleability. Smart-contract wallets (Safe, ERC-4337 accounts) cannot produce ECDSA signatures of their own, so verifiers must call ERC-1271 isValidSignature(hash, signature) and accept the magic value 0x1626ba7e; ERC-6492 extends this to wallets not yet deployed. The Ethereum signature verifier and SIWE verifier handle both cases.
When to choose which
- Sign-in and proof of address ownership: EIP-191 with an EIP-4361 (SIWE) message that includes domain, URI, chain ID, nonce and expiry.
- Token approvals without a transaction: EIP-712 permit or Permit2, with short deadlines.
- Orders, votes, meta-transactions, attestations: EIP-712 with a domain that includes
chainIdandverifyingContract. - Multisig transactions: EIP-712, as Safe uses, plus independent hash verification on a second device.
- Free-form text a person should read: EIP-191.
- Never:
personal_signover a hash, or EIP-712 domains without a chain ID.
Migration notes
- Moving an off-chain authorization from
personal_signto EIP-712: define the struct, include a nonce and deadline field, and keep accepting the old format only until outstanding signatures expire. - Version the domain: bump
versionwhen field semantics change, so old signatures cannot be read under new rules. - Verify with the same library you sign with, and test against the EIP-712 reference vectors above.
- Support contract wallets through ERC-1271 and ERC-6492 rather than assuming
ecrecover. - Revoke and expire: give users a way to revoke permits and use short deadlines; signatures without expiry remain usable indefinitely.