Skill ā Copyable reference implementation. Use as-is or customize. See Skills Philosophy.
Sign arbitrary messages with private keys and verify signatures to authenticate users. This guide covers EIP-191 personal messages, the standard used by wallets like MetaMask for off-chain authentication.
EIP-191 Personal Sign
EIP-191 defines a standard format for signed data that prevents signed messages from being valid transactions. The format prepends \x19Ethereum Signed Message:\n{length} to your message before hashing.
import { Keccak256Hash } from '@voltaire/primitives/crypto/Keccak256';
import { Secp256k1 } from '@voltaire/primitives/crypto/Secp256k1';
import { Hash as SignedDataHash } from '@voltaire/primitives/SignedData';
// Create the hash function with keccak256 dependency
const hashPersonalMessage = SignedDataHash({ keccak256: Keccak256Hash.hash });
// Sign a personal message
const message = 'Sign this message to authenticate';
const messageHash = hashPersonalMessage(message);
// Sign with your private key (32 bytes)
const privateKey = new Uint8Array(32);
crypto.getRandomValues(privateKey);
const signature = Secp256k1.sign(messageHash, privateKey);
// Returns: { r: Uint8Array(32), s: Uint8Array(32), v: 27 | 28 }
Hash Prefixing Convention
The EIP-191 personal message format:
"\x19Ethereum Signed Message:\n" + message.length + message
For example, signing āHelloā produces:
keccak256("\x19Ethereum Signed Message:\n5Hello")
This prefix ensures:
- Signed messages cannot be valid transactions
- Users know they are signing a message, not a transaction
- Cross-application signature reuse is prevented
import { PERSONAL_MESSAGE_PREFIX } from '@voltaire/primitives/SignedData';
// The prefix is: "\x19Ethereum Signed Message:\n"
console.log(PERSONAL_MESSAGE_PREFIX);
// "\x19Ethereum Signed Message:\n"
// Manual hash construction (for understanding - use SignedData.Hash instead)
const message = 'Hello';
const prefix = new TextEncoder().encode(`\x19Ethereum Signed Message:\n${message.length}`);
const messageBytes = new TextEncoder().encode(message);
const data = new Uint8Array(prefix.length + messageBytes.length);
data.set(prefix, 0);
data.set(messageBytes, prefix.length);
const hash = Keccak256Hash.hash(data);
Verifying Signatures
Verify that a signature was created by a specific address:
import { Keccak256Hash } from '@voltaire/primitives/crypto/Keccak256';
import { Secp256k1 } from '@voltaire/primitives/crypto/Secp256k1';
import { Verify as SignedDataVerify } from '@voltaire/primitives/SignedData';
import { FromPublicKey } from '@voltaire/primitives/Address';
// Create verification function with dependencies
const fromPublicKey = FromPublicKey({ keccak256: Keccak256Hash.hash });
const verify = SignedDataVerify({
keccak256: Keccak256Hash.hash,
recoverPublicKey: Secp256k1.recoverPublicKey,
addressFromPublicKey: fromPublicKey,
});
// Verify a signature
const message = 'Sign this message to authenticate';
const signature = {
r: signatureR, // 32 bytes
s: signatureS, // 32 bytes
v: 27, // or 28
};
const expectedAddress = signerAddress; // 20-byte address
const isValid = verify(message, signature, expectedAddress);
// Returns: true if signature is valid and from expectedAddress
Recovering the Signer Address
Recover the signerās address from a signature without knowing it beforehand:
import { Keccak256Hash } from '@voltaire/primitives/crypto/Keccak256';
import { Secp256k1 } from '@voltaire/primitives/crypto/Secp256k1';
import { Hash as SignedDataHash } from '@voltaire/primitives/SignedData';
import { FromPublicKey } from '@voltaire/primitives/Address';
// Set up dependencies
const hashPersonalMessage = SignedDataHash({ keccak256: Keccak256Hash.hash });
const fromPublicKey = FromPublicKey({ keccak256: Keccak256Hash.hash });
// Hash the message with EIP-191 format
const message = 'Sign this message to authenticate';
const messageHash = hashPersonalMessage(message);
// Recover public key from signature
const signature = {
r: signatureR,
s: signatureS,
v: 27,
};
const publicKey = Secp256k1.recoverPublicKey(signature, messageHash);
// Returns: 64-byte public key (x || y coordinates)
// Derive address from public key
const recoveredAddress = fromPublicKey(publicKey);
// Returns: 20-byte Ethereum address
Complete Example
Full sign-and-verify flow:
import { Keccak256Hash } from '@voltaire/primitives/crypto/Keccak256';
import { Secp256k1 } from '@voltaire/primitives/crypto/Secp256k1';
import { Hash as SignedDataHash, Verify as SignedDataVerify } from '@voltaire/primitives/SignedData';
import { FromPublicKey } from '@voltaire/primitives/Address';
import { Hex } from '@voltaire/primitives/Hex';
// Initialize dependencies
const hashPersonalMessage = SignedDataHash({ keccak256: Keccak256Hash.hash });
const fromPublicKey = FromPublicKey({ keccak256: Keccak256Hash.hash });
const verify = SignedDataVerify({
keccak256: Keccak256Hash.hash,
recoverPublicKey: Secp256k1.recoverPublicKey,
addressFromPublicKey: fromPublicKey,
});
// 1. Generate a key pair
const privateKey = Secp256k1.randomPrivateKey();
const publicKey = Secp256k1.derivePublicKey(privateKey);
const address = fromPublicKey(publicKey);
// 2. Sign a message
const message = 'I agree to the terms of service';
const messageHash = hashPersonalMessage(message);
const signature = Secp256k1.sign(messageHash, privateKey);
console.log('Signature:', {
r: Hex.fromBytes(signature.r),
s: Hex.fromBytes(signature.s),
v: signature.v,
});
// 3. Verify the signature
const isValid = verify(message, signature, address);
console.log('Signature valid:', isValid); // true
// 4. Recover signer from signature
const recoveredPublicKey = Secp256k1.recoverPublicKey(signature, messageHash);
const recoveredAddress = fromPublicKey(recoveredPublicKey);
// Compare addresses (both are Uint8Arrays)
const addressesMatch = address.every((byte, i) => byte === recoveredAddress[i]);
console.log('Addresses match:', addressesMatch); // true
EIP-191 Version Bytes
EIP-191 defines three version bytes for different use cases:
| Version | Byte | Use Case |
|---|
| Personal Message | 0x45 (āEā) | \x19Ethereum Signed Message:\n - Most common for wallet signatures |
| Structured Data | 0x01 | EIP-712 typed data signing |
| Data with Validator | 0x00 | Application-specific with validator address |
import {
VERSION_PERSONAL_MESSAGE, // 0x45
VERSION_STRUCTURED_DATA, // 0x01
VERSION_DATA_WITH_VALIDATOR, // 0x00
EIP191_PREFIX, // 0x19
} from '@voltaire/primitives/SignedData';
Security Considerations
Always validate signatures server-side. Client-side validation can be bypassed.
- Use EIP-191 prefix: Raw message signing allows transaction replay attacks
- Include context: Add domain, timestamp, or nonce to prevent cross-site signature reuse
- Verify v value: Must be 27 or 28 (or 0/1 in some libraries)
- Check address format: Recovered addresses are checksummed differently per library
- Time-bound signatures: Include expiration timestamps in signed messages