Skip to main content
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:
  1. Signed messages cannot be valid transactions
  2. Users know they are signing a message, not a transaction
  3. 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:
VersionByteUse Case
Personal Message0x45 (ā€œEā€)\x19Ethereum Signed Message:\n - Most common for wallet signatures
Structured Data0x01EIP-712 typed data signing
Data with Validator0x00Application-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