Transaction Signatures vs Message Signatures: Understanding the Difference
26 min read
November 12, 2025
🚧 Site Migration Notice
I've recently migrated this site from Ghost CMS to a new Astro-based frontend. While I've worked hard to ensure everything transferred correctly, some articles may contain formatting errors or broken elements.
If you spot any issues, I'd really appreciate it if you could let me know! Your feedback helps improve the site for everyone.

Introduction
Two signature prompts appear in your wallet. One reads “Send 1 ETH to 0x742d…”. The other says “Login to authenticate session.” Both display cryptographic hex strings. Both require your approval.
Question: which one can drain your wallet?
The answer? Either one. Both can be weaponized. But they operate through fundamentally different mechanisms, and that difference is what separates secure dApps from drained wallets.
In the previous post, we covered the cryptographic foundations. ECDSA, secp256k1, the anatomy of r, s, v components. Now we’re cutting deeper into how Ethereum actually processes these signatures. Because transaction signatures and message signatures aren’t just “two types of the same thing.” They’re architecturally distinct, with different encoding schemes, verification flows, and attack surfaces.
Most developers think the ETH prefix we covered earlier solves the cross-contamination problem. It doesn’t. Not completely. Understanding why requires dissecting how each signature type gets constructed, transmitted, and verified.
This post will show you:
- RLP encoding: how Ethereum serializes transactions into signable byte arrays
- The complete EIP-191 specification with all three version bytes
- Why smart contracts verify message signatures manually (and what goes wrong)
- The attack patterns that exploit confusion between signature types
- When each signature type is appropriate (and when it’s suicide)
If you’re building anything that touches signatures, wallet integrations, authentication systems, permit functions, meta-transactions, you need this. Because attackers already know these patterns. They’re exploiting them right now.
Let’s start with how Ethereum packages transaction data.
Transaction Signatures: RLP Encoding Under the Hood
What Ethereum Actually Signs
When you approve a transaction, you’re not signing “send 1 ETH to Alice.” You’re signing a precisely structured data package with seven mandatory fields that Ethereum nodes need to execute your transaction:
nonce → Transaction counter preventing replays
gasPrice → Wei per gas unit you'll pay
gasLimit → Maximum gas consumption ceiling
to → Recipient address (20 bytes)
value → Wei to transfer
data → Contract calldata or empty bytes
chainId → Network identifier (1=mainnet, 5=goerli, etc.)
Every field matters. Modify one byte, change the nonce from 42 to 43, bump the gasPrice by 1 wei, and you’ve created a completely different transaction with a completely different signature.
But Ethereum nodes can’t process a JavaScript object. They need raw bytes. This is where RLP encoding enters.
RLP: Ethereum’s Serialization Language
RLP (Recursive Length Prefix) is Ethereum’s data packing format, defined in Appendix B of the Yellow Paper. It’s how complex nested data structures get flattened into deterministic byte sequences that every node interprets identically.
The specification defines encoding rules for two data types:
Strings (raw byte sequences):
- Single byte [0x00-0x7f]: The byte is its own encoding
- Strings 0-55 bytes:
[0x80 + length] + data - Strings >55 bytes:
[0xb7 + length_of_length] + length + data
Lists (arrays of items):
- Payload 0-55 bytes:
[0xc0 + payload_length] + concatenated_items - Payload >55 bytes:
[0xf7 + length_of_length] + payload_length + concatenated_items
The first byte tells you what follows. Bytes in [0x00-0x7f] are strings. Bytes in [0x80-0xbf] are string encodings. Bytes in [0xc0-0xff] are lists.
Why does this matter for security? Because RLP is deterministic. Given identical input, every implementation produces identical output. This means signatures are bound to the exact transaction structure. No ambiguity, no interpretation needed.
Let’s see RLP in action:
// For RLP encoding in ethers.js v6, use the separate package
// npm install @ethersproject/rlp
const { ethers } = require('ethers');
const { encode: encodeRlp } = require('@ethersproject/rlp');
// RLP encodes strings by prepending length
function demonstrateRLP() {
// Short string (< 55 bytes)
const str = "hello";
const strBytes = ethers.toUtf8Bytes(str);
const encoded = encodeRlp(strBytes);
console.log("String:", str);
console.log("RLP encoded:", encoded);
// Output: 0x8568656c6c6f
// Breakdown: 0x85 = (0x80 + 5 byte length), then "hello" in hex
// List of strings
const list = ["cat", "dog"];
const encodedList = encodeRlp([
ethers.toUtf8Bytes(list[0]),
ethers.toUtf8Bytes(list[1])
]);
console.log("\nList:", list);
console.log("RLP encoded:", encodedList);
// Output: 0xc88363617483646f67
// Breakdown: 0xc8 = (0xc0 + 8 byte payload), then encoded items
}
demonstrateRLP();

Output after running the script
That first byte, 0x85 for the string, 0xc8 for the list, is the RLP prefix. It tells decoders how many bytes follow and what type of data structure to expect.
How Transactions Get RLP-Encoded and Signed
Here’s the complete flow from transaction parameters to broadcast-ready signature:
RLP Encoding
Let’s manually construct and sign a transaction to see RLP encoding in practice:
Click to expand: Complete transaction signing example
const { ethers } = require('ethers');
async function manualTransactionSigning() {
const wallet = ethers.Wallet.createRandom();
const recipientWallet = ethers.Wallet.createRandom();
console.log("Wallet:", wallet.address);
console.log("Private key:", wallet.privateKey);
// Transaction parameters for 1 ETH transfer
const txParams = {
nonce: 0,
gasPrice: ethers.parseUnits('20', 'gwei'),
gasLimit: 21000,
to: recipientWallet.address,
value: ethers.parseEther('1.0'),
data: '0x',
chainId: 1 // Mainnet
};
console.log("\nTransaction parameters:");
console.log(JSON.stringify(txParams, (key, value) =>
typeof value === 'bigint' ? value.toString() : value, 2));
// Get unsigned serialized transaction (RLP-encoded)
const unsignedTx = ethers.Transaction.from(txParams).unsignedSerialized;
console.log("\nRLP-encoded (unsigned):", unsignedTx);
// Hash the RLP-encoded transaction
const txHash = ethers.keccak256(unsignedTx);
console.log("Transaction hash (signed digest):", txHash);
// Sign the transaction
const signedTx = await wallet.signTransaction(txParams);
console.log("\nSigned transaction (RLP + signature):", signedTx);
// Parse signature components
const parsedTx = ethers.Transaction.from(signedTx);
console.log("\nSignature components:");
console.log("r:", parsedTx.signature.r);
console.log("s:", parsedTx.signature.s);
console.log("v:", parsedTx.signature.v);
// Verify signature by recovering signer
const recoveredSigner = parsedTx.from;
console.log("\nRecovered signer:", recoveredSigner);
console.log("Signature valid:", recoveredSigner === wallet.address);
}
manualTransactionSigning();

Manual transaction
Notice what happened:
- RLP encoding transformed our transaction object into a deterministic byte array
- Keccak-256 hashed that byte array into a 32-byte digest
- ECDSA signing generated r, s, v from the hash and private key
- Serialization combined the original RLP transaction with the signature
- Recovery extracted the signer’s address from the signature
This entire process happens automatically every time you click “Confirm” in your wallet. The signature is cryptographically bound to the RLP-encoded transaction. Change one field, and the signature breaks.
Built-in Protection Mechanisms
Transaction signatures include two built-in replay protections:
Nonce: Each address maintains a transaction counter. Node A with nonce 42 can only execute transaction #42 next. You can’t replay transaction #41 (already executed) or skip to #43 (nonce mismatch). This prevents attackers from rebroadcasting old transactions.
ChainId: EIP-155 embeds the network identifier into the signature. A transaction signed for Ethereum mainnet (chainId=1) cannot execute on Polygon (chainId=137) or any other network. This prevents cross-chain replay attacks.
These protections are baked into the RLP structure. They’re automatic, mandatory, and verified by every node in the network.
Message signatures have none of this. That’s why they require manual security implementation.
Message Signatures: EIP-191 and Manual Verification
Why Message Signatures Exist
Transaction signatures cost gas. They modify blockchain state. They’re permanent.
Sometimes you just need to prove ownership without spending money:
- “Sign in with Ethereum” authentication
- DAO voting (collect signatures off-chain, execute once)
- NFT minting allowlists (prove you’re on the list without paying gas)
- Gasless token approvals (EIP-2612 Permit)
Message signatures serve this purpose. They’re free, off-chain, and can be verified by anyone with the signature and original message.
But unlike transactions, message signatures have no automatic verification. No nodes check them. No built-in replay protection. No mandatory nonce or chainId.
This is where EIP-191 enters.
EIP-191: Structured Signed Data Standard
EIP-191 defines the format for all signed data in Ethereum. The core structure is:
0x19 <version byte> <version-specific data> <data to sign>
Why 0x19? Because it ensures signed data is not valid RLP. In RLP, single bytes in [0x00-0x7f] are their own encoding. Bytes in [0x80-0xff] are string/list prefixes. The byte 0x19 sits in the single-byte range, which means if RLP-decoded, it would be interpreted as a standalone byte (ASCII value 25), not as the start of a valid transaction structure.
This prevents a signed message from being mistaken for a signed transaction. It’s domain separation at the byte level.
EIP-191 defines three version bytes:
Eip-191 version bytes
Version 0x00 - Data with Intended Validator:
Format: 0x19 0x00 <validator address> <data>
Used when a specific smart contract will verify the signature. The validator address (20 bytes) is included in the signed data, binding the signature to that contract. Multisig wallets use this to ensure pre-signed transactions only execute through the intended wallet contract.
Version 0x01 - Structured Data (EIP-712):
Format: 0x19 0x01 <domainSeparator> <hashStruct>
The most sophisticated version. Used for signing typed, structured data like token permits (EIP-2612) and meta-transactions. We’ll cover this in detail in a later post.
Version 0x45 - Personal Sign:
Format: 0x19 'Ethereum Signed Message:\n' <length> <message>
This is what wallet.signMessage() uses. The complete prefix is \x19Ethereum Signed Message:\n followed by the message length as a string, then the message itself.
Note: The “version byte 0x45” is not a separate byte. It refers to the ASCII value of ‘E’ in “Ethereum” (0x45 = ‘E’). The actual format starts with 0x19, then the string “Ethereum…” where ‘E’ happens to be 0x45 in ASCII.
Demonstrating the Personal Sign Prefix
Let’s see exactly what gets constructed when you sign “Login to dApp”:
Click to expand: EIP-191 prefix construction example
const { ethers } = require('ethers');
function demonstrateEIP191PersonalSign() {
const message = "Login to dApp";
console.log("Original message:", message);
console.log("Message length:", message.length, "bytes");
// EIP-191 version 0x45 construction
const prefix = "\x19Ethereum Signed Message:\n";
const messageBytes = ethers.toUtf8Bytes(message);
const lengthStr = String(messageBytes.length);
const lengthBytes = ethers.toUtf8Bytes(lengthStr);
// Concatenate: prefix + length (as string) + message
const prefixedMessage = ethers.concat([
ethers.toUtf8Bytes(prefix),
lengthBytes,
messageBytes
]);
console.log("\nEIP-191 construction:");
console.log("1. Prefix (\\x19Ethereum Signed Message:\\n):");
console.log(" ", ethers.hexlify(ethers.toUtf8Bytes(prefix)));
console.log("2. Length (as string '13'):");
console.log(" ", ethers.hexlify(lengthBytes));
console.log("3. Message:");
console.log(" ", ethers.hexlify(messageBytes));
console.log("\n4. Complete prefixed message:");
console.log(" ", ethers.hexlify(prefixedMessage));
// Hash the prefixed message (this is what gets signed)
const messageHash = ethers.keccak256(prefixedMessage);
console.log("\n5. Keccak-256 hash (signed digest):");
console.log(" ", messageHash);
// Verify against ethers.js helper
const expectedHash = ethers.hashMessage(message);
console.log("\n6. ethers.hashMessage() output:");
console.log(" ", expectedHash);
console.log("\nMatch:", messageHash === expectedHash);
}
demonstrateEIP191PersonalSign();

eip-191 prefix construction example
This prefixed structure is what your private key actually signs. Not just “Login to dApp”, but the entire 0x19 'Ethereum Signed Message:\n13Login to dApp' byte sequence.
The length is included as a string (“13”), not as a binary number. This means the length field itself varies in size depending on how many digits are needed. A 9-byte message has length “9” (1 byte). A 100-byte message has length “100” (3 bytes).
Complete Message Signature Flow
Now let’s sign a message and verify it:
Click to expand: Message signature and verification example
const { ethers } = require('ethers');
async function completeMessageSignatureFlow() {
const wallet = ethers.Wallet.createRandom();
const message = "Authorize withdrawal: 100 USDC";
console.log("=== Message Signature Flow ===\n");
console.log("Signer:", wallet.address);
console.log("Message:", message);
// Sign the message (wallet adds EIP-191 prefix automatically)
const signature = await wallet.signMessage(message);
console.log("\nSignature (65 bytes):", signature);
// Decompose signature into components
const sig = ethers.Signature.from(signature);
console.log("\nSignature components:");
console.log("r (32 bytes):", sig.r);
console.log("s (32 bytes):", sig.s);
console.log("v (1 byte):", sig.v);
// Verify by recovering signer
const recoveredSigner = ethers.verifyMessage(message, signature);
console.log("\n=== Verification ===");
console.log("Expected signer:", wallet.address);
console.log("Recovered signer:", recoveredSigner);
console.log("Valid:", recoveredSigner === wallet.address);
// Demonstrate signature-message binding
const tamperedMessage = "Authorize withdrawal: 1000 USDC";
const recoveredFromTampered = ethers.verifyMessage(tamperedMessage, signature);
console.log("\n=== Tamper Test ===");
console.log("Tampered message:", tamperedMessage);
console.log("Recovered signer:", recoveredFromTampered);
console.log("Still valid:", recoveredFromTampered === wallet.address);
}
completeMessageSignatureFlow();

Message Signature Verification Example
Key observation: when we changed “100 USDC” to “1000 USDC”, the recovered address changed completely. The signature is cryptographically bound to the exact message. You cannot modify the message and reuse the signature.
But here’s the dangerous part: nothing prevents the signature from being reused with the original message. If the verifying contract doesn’t implement replay protection (nonces, deadlines, single-use flags), an attacker can submit the same signature repeatedly.
This is the fundamental difference from transaction signatures, which have mandatory nonce protection.
Smart Contract Verification: On-Chain Implementation
Complete Signature Verification Contract
Let’s build a contract that verifies EIP-191 personal sign messages with proper security controls:
Click to expand: Complete SignatureVerifier contract (207 lines)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title SignatureVerifier
* @notice Demonstrates secure message signature verification
* @dev Implements EIP-191 version 0x45 (personal_sign) verification with replay protection
*/
contract SignatureVerifier {
// SECURITY: Nonce tracking prevents replay attacks
mapping(address => uint256) public nonces;
// SECURITY: Track used signatures to prevent reuse
mapping(bytes32 => bool) public usedSignatures;
/**
* @notice Verify an EIP-191 personal_sign message signature
* @param message The original message that was signed
* @param signature The 65-byte ECDSA signature (r + s + v)
* @param expectedSigner The address that should have signed
* @return bool True if signature is valid and from expected signer
*/
function verifyPersonalSign(
string memory message,
bytes memory signature,
address expectedSigner
) public pure returns (bool) {
// Reconstruct the EIP-191 prefixed message hash
// Format: "\x19Ethereum Signed Message:\n" + len(message) + message
bytes32 messageHash = getEthSignedMessageHash(message);
// Recover signer from signature
address recoveredSigner = recoverSigner(messageHash, signature);
// SECURITY: Verify recovered signer matches expected
return recoveredSigner == expectedSigner;
}
/**
* @notice Verify signature with nonce-based replay protection
* @param message The message to verify (must include nonce)
* @param nonce The nonce value included in the message
* @param signature The signature to verify
* @param expectedSigner The expected signing address
* @return bool True if valid
* @dev The message must be constructed with the nonce before signing
* Example: "Transfer 100 tokens. Nonce: 5"
* The nonce parameter must match the value in the message and the stored nonce
*/
function verifyWithNonce(
string memory message,
uint256 nonce,
bytes memory signature,
address expectedSigner
) public returns (bool) {
// SECURITY: Validate nonce matches stored value
require(nonce == nonces[expectedSigner], "Invalid nonce");
// SECURITY: Use standard EIP-191 hash (nonce must be in message text)
bytes32 messageHash = getEthSignedMessageHash(message);
// Recover and verify signer
address recoveredSigner = recoverSigner(messageHash, signature);
require(recoveredSigner == expectedSigner, "Invalid signature");
// SECURITY: Increment nonce to prevent replay
nonces[expectedSigner]++;
return true;
}
/**
* @notice Verify signature with single-use enforcement
* @param message The message to verify
* @param signature The signature to verify (will be marked as used)
* @param expectedSigner The expected signing address
* @return bool True if valid
*/
function verifyOnce(
string memory message,
bytes memory signature,
address expectedSigner
) public returns (bool) {
bytes32 sigHash = keccak256(signature);
// SECURITY: Ensure signature hasn't been used
require(!usedSignatures[sigHash], "Signature already used");
// Verify the signature
bytes32 messageHash = getEthSignedMessageHash(message);
address recoveredSigner = recoverSigner(messageHash, signature);
require(recoveredSigner == expectedSigner, "Invalid signature");
// SECURITY: Mark signature as used
usedSignatures[sigHash] = true;
return true;
}
/**
* @notice Construct EIP-191 prefixed message hash
* @param message The original message
* @return bytes32 The Keccak-256 hash of the prefixed message
*/
function getEthSignedMessageHash(string memory message)
public
pure
returns (bytes32)
{
// EIP-191 version 0x45: "\x19Ethereum Signed Message:\n" + len + message
bytes memory messageBytes = bytes(message);
return keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
uintToString(messageBytes.length),
messageBytes
)
);
}
/**
* @notice Recover signer address from message hash and signature
* @param messageHash The hash that was signed
* @param signature The 65-byte signature (r + s + v)
* @return address The recovered signer address
*/
function recoverSigner(
bytes32 messageHash,
bytes memory signature
) public pure returns (address) {
// SECURITY: Signature must be exactly 65 bytes
require(signature.length == 65, "Invalid signature length");
// Extract r, s, v components
bytes32 r;
bytes32 s;
uint8 v;
// Use assembly for gas-efficient extraction
// Memory layout: [32-byte length][32-byte r][32-byte s][1-byte v]
assembly {
// Load r: bytes 0-31 of signature data
// Offset 32 skips the length prefix stored by Solidity
r := mload(add(signature, 32))
// Load s: bytes 32-63 of signature data
// Offset 64 = 32 (length prefix) + 32 (r component)
s := mload(add(signature, 64))
// Load v: byte 64 of signature data
// Offset 96 = 32 (length) + 32 (r) + 32 (s)
// Use byte(0, ...) to extract only the first byte
v := byte(0, mload(add(signature, 96)))
}
// SECURITY: Normalize v to 27 or 28
// Some libraries return v as 0 or 1
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "Invalid v value");
// Call ecrecover precompile
// VULNERABILITY WARNING: ecrecover returns address(0) on failure!
address signer = ecrecover(messageHash, v, r, s);
// SECURITY: Always check for zero address
require(signer != address(0), "Invalid signature");
return signer;
}
/**
* @notice Convert uint to string (helper for EIP-191 length encoding)
* @param value The uint to convert
* @return string The string representation
*/
function uintToString(uint256 value) internal pure returns (string memory) {
if (value == 0) {
return "0";
}
uint256 temp = value;
uint256 digits;
// Count digits
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
// Convert each digit
while (value != 0) {
digits--;
buffer[digits] = bytes1(uint8(48 + (value % 10)));
value /= 10;
}
return string(buffer);
}
/**
* @notice Get current nonce for an address
* @param user The address to check
* @return uint256 The current nonce
*/
function getNonce(address user) public view returns (uint256) {
return nonces[user];
}
}
This contract demonstrates three security patterns:
1. Basic verification (verifyPersonalSign)
- Reconstructs the EIP-191 prefix:
\x19Ethereum Signed Message:\n+ length + message - Uses
ecrecoverto extract the signer - Compares recovered signer to expected address
- No replay protection - signature can be reused
2. Nonce-based verification (verifyWithNonce)
- Requires nonce to be included in the message text before signing
- Example: “Transfer 100 tokens. Nonce: 5”
- Validates nonce matches stored value - rejects old or future nonces
- Increments nonce after successful verification
- Prevents replay - each nonce works only once sequentially
- Caller must provide the nonce value as a parameter for validation
3. Single-use verification (verifyOnce)
- Tracks signature hashes in a mapping
- Rejects signatures that have been used before
- Alternative to nonces - useful when signer doesn’t track nonces
Important ecrecover Behavior
The Solidity documentation states that ecrecover “returns zero on error”. This is an important security consideration:
// VULNERABILITY: Not checking for zero address
address signer = ecrecover(hash, v, r, s);
if (signer == expectedSigner) {
// If ecrecover fails (returns 0x0), this check passes
// when expectedSigner is also 0x0 (default value)!
}
// FIX: Always validate the recovered address
address signer = ecrecover(hash, v, r, s);
require(signer != address(0), "Invalid signature");
require(signer == expectedSigner, "Wrong signer");
According to OpenZeppelin’s ECDSA library documentation, ecrecover can fail and return address(0) for several reasons:
- Invalid signature parameters
- Malformed signature data
- Incorrect message hash
Always check for the zero address before trusting the result.
Deploying and Testing with Foundry
Let’s deploy this contract to a local blockchain using Foundry’s Anvil and test it with a real deployment. This gives you hands-on experience with on-chain signature verification.
Prerequisites
Install Foundry if you haven’t already:
curl -L https://foundry.paradigm.xyz | bash
foundryup
Step 1: Start Local Blockchain (Anvil)
Open a terminal and start Anvil:
anvil
Anvil starts a local Ethereum node at http://localhost:8545 with 10 pre-funded accounts. Keep this terminal open.
You’ll see output with available accounts:
Available Accounts
==================
(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000 ETH)
...
Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
...
Step 2: Deploy the Contract
In a new terminal, deploy using forge create:
forge create $(pwd)/SignatureVerifier.sol:SignatureVerifier \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
The private key is Anvil’s first default account (safe for local testing only).

Deploy contract
Copy the “Deployed to” address - you’ll need it for testing.
Step 3: Test the Deployed Contract
Create a test script test-contract.js:
Click to expand: Complete contract testing script
const { ethers } = require('ethers');
async function testDeployedContract() {
// Connect to local Anvil node
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const deployer = await provider.getSigner(0);
// Create a test wallet for signing
const testWallet = ethers.Wallet.createRandom().connect(provider);
console.log("=== SignatureVerifier Contract Testing ===\n");
console.log("Deployer:", await deployer.getAddress());
console.log("Test wallet:", testWallet.address);
console.log();
// Contract ABI
const abi = [
"function verifyPersonalSign(string memory message, bytes memory signature, address expectedSigner) public pure returns (bool)",
"function verifyWithNonce(string memory message, uint256 nonce, bytes memory signature, address expectedSigner) public returns (bool)",
"function verifyOnce(string memory message, bytes memory signature, address expectedSigner) public returns (bool)",
"function getNonce(address user) public view returns (uint256)"
];
// Replace with your deployed contract address
const contractAddress = process.argv[2];
if (!contractAddress) {
console.error("Usage: node test-contract.js <CONTRACT_ADDRESS>");
process.exit(1);
}
const contract = new ethers.Contract(contractAddress, abi, deployer);
// Test 1: Basic signature verification
console.log("=== Test 1: Basic Verification (verifyPersonalSign) ===");
const message1 = "Claim reward: 50 tokens";
const signature1 = await testWallet.signMessage(message1);
console.log("Message:", message1);
console.log("Signature:", signature1.slice(0, 20) + "...");
const isValid = await contract.verifyPersonalSign(
message1,
signature1,
testWallet.address
);
console.log("✓ Verification:", isValid ? "PASS" : "FAIL");
// Test with wrong signer
const wrongAddress = await deployer.getAddress();
const isInvalid = await contract.verifyPersonalSign(
message1,
signature1,
wrongAddress
);
console.log("✓ Wrong signer rejected:", !isInvalid ? "PASS" : "FAIL");
console.log();
// Test 2: Nonce-based verification
console.log("=== Test 2: Nonce-Based Replay Protection ===");
let nonce = await contract.getNonce(testWallet.address);
console.log("Initial nonce:", nonce.toString());
const message2 = `Transfer 100 tokens. Nonce: ${nonce}`;
const signature2 = await testWallet.signMessage(message2);
console.log("Message:", message2);
// First submission - should succeed
const tx1 = await contract.verifyWithNonce(
message2,
nonce, // Pass nonce parameter
signature2,
testWallet.address
);
await tx1.wait();
console.log("✓ First submission: PASS (tx:", tx1.hash.slice(0, 10) + "...)");
nonce = await contract.getNonce(testWallet.address);
console.log("✓ Nonce incremented to:", nonce.toString());
// Try replay - old nonce will be rejected
console.log("Attempting replay with old nonce...");
try {
const tx2 = await contract.verifyWithNonce(
message2,
0, // Old nonce - should fail
signature2,
testWallet.address
);
await tx2.wait();
console.log("✗ SECURITY ISSUE: Replay succeeded (should have failed!)");
} catch (error) {
console.log("✓ Replay prevented:", error.reason || "Invalid nonce");
}
console.log();
// Test 3: Single-use signature
console.log("=== Test 3: Single-Use Signature (verifyOnce) ===");
const message3 = "One-time action: Mint NFT";
const signature3 = await testWallet.signMessage(message3);
const tx3 = await contract.verifyOnce(
message3,
signature3,
testWallet.address
);
await tx3.wait();
console.log("✓ First use: PASS (tx:", tx3.hash.slice(0, 10) + "...)");
try {
const tx4 = await contract.verifyOnce(message3, signature3, testWallet.address);
await tx4.wait();
console.log("✗ Signature reuse: FAIL (should have been prevented)");
} catch (error) {
console.log("✓ Signature reuse prevented:", error.reason || "Signature already used");
}
console.log();
console.log("=== All Tests Complete ===");
console.log("Summary:");
console.log("1. ✓ Basic verification works");
console.log("2. ✓ Nonce-based replay protection works (replay rejected)");
console.log("3. ✓ Single-use signature tracking works");
}
testDeployedContract().catch(console.error);
Run the test script with your deployed contract address:
node test-contract.js 0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE

Testing SignatureVerifier contract with Foundry and Anvil
This demonstrates three security patterns in action:
1. verifyPersonalSign: Basic verification with no replay protection. The same signature can be reused indefinitely. Use only when replay doesn’t matter (e.g., read-only verification).
2. verifyWithNonce: Nonce-based replay protection. The function validates that the provided nonce matches the stored nonce before accepting the signature. After successful verification, it increments the nonce. This ensures signatures with old or future nonces are rejected, preventing replay attacks. Critical for financial operations.
3. verifyOnce: Single-use signature tracking. Each signature hash is stored after first use. Alternative to nonces when the signer doesn’t track nonce state.
Architectural Differences: Side-by-Side Comparison
Let’s visualize the fundamental differences in how these signature types operate:
Message Signature Diagram
Transaction Signature Diagram
Notice the key difference: transaction signatures have automatic network verification. Message signatures require manual verification, which is where vulnerabilities enter.
When to Use Each Type
Use Transaction Signatures When:
- Transferring ETH or tokens between addresses
- Calling state-changing smart contract functions
- Deploying new contracts
- You need automatic network-wide verification
- You need guaranteed replay protection
- Gas costs are acceptable
Use Message Signatures When:
- Implementing passwordless authentication (“Sign in with Ethereum”)
- Collecting off-chain votes or approvals (DAO governance)
- Creating gasless token approvals (EIP-2612 Permit)
- Building meta-transaction systems (covered in next post)
- Proving ownership without spending gas
- You need off-chain signature aggregation
Never Use Message Signatures For:
- Authorization without nonce/expiry/single-use protection
- Actions where replay would be catastrophic
- Systems where you can’t clearly explain to users what they’re authorizing
- Anything where “replay this 1000 times” would be devastating
Common Verification Pitfalls
Understanding how to verify signatures is only half the battle. Let’s examine two common implementation mistakes that create vulnerabilities.
Vulnerability #1: Missing ecrecover Validation
// VULNERABILITY: Not checking ecrecover return value
contract UnsafeVerifier {
function verify(
string memory message,
bytes memory signature,
address expectedSigner
) public pure returns (bool) {
bytes32 messageHash = getEthSignedMessageHash(message);
// VULNERABILITY: ecrecover returns address(0) on failure
address signer = ecrecover(messageHash, v, r, s);
// If expectedSigner is accidentally 0x0 (uninitialized),
// this passes when it should fail!
return signer == expectedSigner;
}
}
The Attack:
If expectedSigner is address(0) (uninitialized variable, constructor bug, etc.) and the signature is malformed, ecrecover returns address(0), the comparison passes, and invalid signatures are accepted.
The Fix:
// FIX: Always validate ecrecover output
function verify(
string memory message,
bytes memory signature,
address expectedSigner
) public pure returns (bool) {
bytes32 messageHash = getEthSignedMessageHash(message);
address signer = ecrecover(messageHash, v, r, s);
// SECURITY: Check for ecrecover failure
require(signer != address(0), "Invalid signature");
// SECURITY: Check for uninitialized expectedSigner
require(expectedSigner != address(0), "Invalid expected signer");
return signer == expectedSigner;
}
Vulnerability #2: Incorrect Prefix Reconstruction
// VULNERABILITY: Missing EIP-191 prefix
contract WrongPrefixVerifier {
function verify(
string memory message,
bytes memory signature
) public pure returns (bool) {
// VULNERABILITY: Hashing raw message without EIP-191 prefix
bytes32 messageHash = keccak256(bytes(message));
// This will NEVER match signatures from standard wallets
// because wallets add "\x19Ethereum Signed Message:\n<length>"
address signer = recoverSigner(messageHash, signature);
return signer == msg.sender;
}
}
The Problem: Standard Ethereum wallets (MetaMask, Ledger, etc.) automatically add the EIP-191 prefix when signing messages. If your contract hashes the raw message without the prefix, signature verification will always fail. Users can’t authenticate, and the system is broken.
The Fix: Always reconstruct the exact format wallets use:
// FIX: Add EIP-191 prefix
function verify(
string memory message,
bytes memory signature
) public pure returns (bool) {
// SECURITY: Include EIP-191 prefix to match wallet behavior
bytes32 messageHash = keccak256(
abi.encodePacked(
"\x19Ethereum Signed Message:\n",
uintToString(bytes(message).length),
message
)
);
address signer = recoverSigner(messageHash, signature);
require(signer != address(0), "Invalid signature");
return signer == msg.sender;
}
Key Takeaways
1. Different Encoding Schemes
| Aspect | Transaction Signatures | Message Signatures |
|---|---|---|
| Encoding | RLP (Recursive Length Prefix) | EIP-191 prefix variants |
| Structure | RLP([nonce, gasPrice, ...]) | 0x19 <version> <data> |
| Hash Input | RLP-encoded transaction | Prefixed message |
| Built-in Fields | nonce, chainId, gas parameters | Version byte only |
| Standardization | Yellow Paper (Appendix B) | EIP-191 |
2. RLP vs EIP-191: Know What Gets Signed
RLP serializes complex data structures into deterministic byte arrays. Every Ethereum node uses identical RLP encoding, ensuring signatures are bound to exact transaction parameters. RLP is mandatory for transactions.
EIP-191 provides domain separation through version bytes. The 0x19 prefix ensures signed messages cannot be mistaken for RLP-encoded transactions. The version byte (0x00, 0x01, 0x45) specifies the data structure and intended use.
3. Manual Verification Requires Manual Security
Transaction signatures are verified automatically by every network node. Message signatures require explicit verification logic in smart contracts or backend systems.
This means:
- You must implement replay protection (nonces, expiries, or single-use flags)
- You must validate
ecrecoverreturn values (check foraddress(0)) - You must reconstruct the exact prefix format wallets use
- You must handle signature malleability and edge cases
Use OpenZeppelin’s ECDSA library instead of rolling your own. It handles these gotchas correctly.
4. Security Checklist
When implementing message signature verification:
Always reconstruct the EIP-191 prefix exactly as wallets create it
Always check ecrecover return value against address(0)
Always implement replay protection (nonces or used signature tracking)
Always validate signature length (must be exactly 65 bytes)
Always normalize v to 27 or 28 (some libraries use 0/1)
Never trust signatures without comparing recovered address to expected address
Never skip expiry checks if time-sensitive
Never assume users understand what they’re signing
5. Users Cannot Distinguish Signatures
To users, both signature types look like cryptographic hex strings in a wallet popup. One might drain their wallet, the other might just log them in. They can’t tell the difference.
This means:
- Make signed messages as clear as possible (“Authorize withdrawal of 100 USDC”)
- Include amounts, recipients, and actions explicitly in the message text
- Never hide important information in calldata or structured fields users can’t read
- Consider using EIP-712 for structured data.
What’s Next: Meta-Transactions and Gasless Execution
You now understand the two fundamental signature types. Transaction signatures operate on-chain with automatic verification. Message signatures operate off-chain with manual verification and no built-in replay protection.
Next, we combine them.
Meta-transactions use message signatures to authorize actions that relayers execute as on-chain transactions. Users sign messages (free), relayers submit transactions (pay gas). This enables gasless onboarding, sponsored transactions, and improved UX.
But meta-transactions introduce new attack surfaces:
- Signature replay across different relayers
- Front-running and MEV exploitation
- Nonce desynchronization between user and relayer
- Relayer trust assumptions
- Cross-chain and cross-contract replay attacks
In the next post, we’ll dissect:
- How meta-transaction systems work architecturally
- The EIP-2771 trusted forwarder standard
- Real implementations (OpenZeppelin MinimalForwarder, Biconomy, GSN)
- Attack patterns specific to gasless systems
- How to build secure meta-transaction contracts
If you thought message signatures were complex, meta-transactions are where things get truly interesting. And by interesting, I mean exploitable.
Additional Resources
Ethereum Standards
- EIP-191: Signed Data Standard - Complete specification for all signature version bytes
- EIP-155: Simple Replay Attack Protection - ChainId inclusion in transaction signatures
- Ethereum Yellow Paper, Appendix B - RLP encoding specification
- Ethereum.org RLP Documentation - Accessible RLP explanation with examples
Security Resources
- OpenZeppelin ECDSA Library - Production-ready signature verification implementation
- Solidity Documentation: ecrecover - Official documentation for the ecrecover precompile
- Trail of Bits: ECDSA Handle With Care - Common signature verification pitfalls
Chapters
Previous chapter