
Description
Sign messages with MetaMask using personal_sign and eth_signTypedData_v4 via the EIP-1193 provider, plus the connectAndSign shortcut
SKILL.md
Sign EVM Messages with MetaMask Connect
When to use
Use this skill when:
- Signing a plaintext message with
personal_signfor authentication or verification - Signing structured EIP-712 typed data with
eth_signTypedData_v4for permits, orders, or typed messages - Using the
connectAndSignshortcut to connect and sign in a single user approval - Handling signature errors and user rejections
Workflow
Step 1: Get the provider and connected account
Ensure the client is connected before requesting a signature:
import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm';
const client = await createEVMClient({
dapp: { name: 'My DApp', url: window.location.href },
api: {
supportedNetworks: {
...getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY', chainIds: ['0x1'] }),
},
},
});
const { accounts } = await client.connect({ chainIds: ['0x1'] });
const provider = client.getProvider();
const account = accounts[0]; // Address (0x-prefixed hex)
Step 2: Sign with personal_sign
personal_sign signs a UTF-8 message. The params order is [message, account] where message is a hex-encoded string:
// Convert message to hex
const message = 'Hello, MetaMask!';
const hexMessage = '0x' + Array.from(new TextEncoder().encode(message))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
try {
const signature = await provider.request({
method: 'personal_sign',
params: [hexMessage, account],
});
console.log('Signature:', signature);
// signature is a Hex string: 0x...
} catch (err: any) {
if (err.code === 4001) {
console.log('User rejected the signature request');
return;
}
throw err;
}
MetaMask also accepts a raw UTF-8 string for the message parameter, but hex encoding is the canonical format per EIP-191.
Step 3: Sign EIP-712 typed data with eth_signTypedData_v4
Build the full EIP-712 typed data structure with types, primaryType, domain, and message, then pass it as a JSON string:
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Mail: [
{ name: 'from', type: 'Person' },
{ name: 'to', type: 'Person' },
{ name: 'contents', type: 'string' },
],
Person: [
{ name: 'name', type: 'string' },
{ name: 'wallet', type: 'address' },
],
},
primaryType: 'Mail',
domain: {
name: 'Ether Mail',
version: '1',
chainId: 1,
verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
},
message: {
from: { name: 'Alice', wallet: '0xAliceAddress' },
to: { name: 'Bob', wallet: '0xBobAddress' },
contents: 'Hello Bob!',
},
};
try {
const signature = await provider.request({
method: 'eth_signTypedData_v4',
params: [account, JSON.stringify(typedData)],
});
console.log('Typed data signature:', signature);
} catch (err: any) {
if (err.code === 4001) {
console.log('User rejected the typed data signature');
return;
}
throw err;
}
Step 4: ERC-20 Permit (EIP-2612) example
A common use case for eth_signTypedData_v4 is signing ERC-20 permit approvals:
const permitData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Permit',
domain: {
name: 'USD Coin',
version: '2',
chainId: 1,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
},
message: {
owner: account,
spender: '0xSpenderContractAddress',
value: '1000000', // 1 USDC (6 decimals)
nonce: 0,
deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour
},
};
const signature = await provider.request({
method: 'eth_signTypedData_v4',
params: [account, JSON.stringify(permitData)],
});
Step 5: Use connectAndSign for single-approval flow
connectAndSign connects and signs a personal_sign message in one user interaction:
// For authentication, never sign a static string — it is replayable.
// Use an EIP-4361 (SIWE) formatted message with a server-issued nonce:
const siweMessage = [
`${window.location.host} wants you to sign in with your Ethereum account:`,
'', // account is filled by your SIWE library or template
'Sign in to My DApp',
'',
`URI: ${window.location.origin}`,
'Version: 1',
`Chain ID: 1`,
`Nonce: ${serverIssuedNonce}`, // fetched from your backend
`Issued At: ${new Date().toISOString()}`,
].join('\n');
const { accounts, chainId, signature } = await client.connectAndSign({
message: siweMessage,
chainIds: ['0x1'],
});
console.log('Connected account:', accounts[0]);
console.log('Signature:', signature);
This is ideal for sign-in-with-Ethereum (SIWE) flows where you want the user to connect and prove ownership in a single step — verify the signature, nonce, and domain server-side.
Step 6: Handle errors
try {
const signature = await provider.request({
method: 'personal_sign',
params: [hexMessage, account],
});
} catch (err: any) {
switch (err.code) {
case 4001:
// User rejected — show a message, offer retry
break;
case -32002:
// Request pending — another signing request is in progress
break;
default:
// Unexpected error
console.error('Signing failed:', err);
}
}
Important Notes
personal_signparams order is[message, account]— not[account, message]. Getting this wrong will produce an invalid signature or an error.eth_signTypedData_v4params are[account, typedDataJSON]— the typed data must be passed as aJSON.stringify'd string, not as a raw object.- The
EIP712Domaintype must be declared intypeseven thoughprimaryTypeis neverEIP712Domain. It defines the domain separator fields. connectAndSignonly supportspersonal_sign— for typed data signing during connection, useconnectWithwithmethod: 'eth_signTypedData_v4'instead.- Chain IDs in typed data
domain.chainIdare integers (e.g.,1), while chain IDs in SDK calls are hex strings (e.g.,'0x1'). Don't mix them up. - Error code 4001 is a deliberate user rejection — handle gracefully with a retry option.
- Error code -32002 means a request is pending — do not fire another sign request until the user responds.
- Always connect before signing —
personal_signandeth_signTypedData_v4require an active account. Callclient.connect()first or useconnectAndSign.
More skills from the metamask-connect-cursor-plugin repository
View all 18 skillsmigrate-from-sdk
migrate MetaMask SDK to connect-evm and connect-solana
Jul 13EthereumMigrationSDKWeb3migrate-wagmi-metamask-connector
migrate wagmi apps to MetaMask connector
Jul 13EthereumMigrationwagmiWeb3send-evm-transaction
send EVM transactions with MetaMask
Jul 13API DevelopmentEthereumWeb3send-solana-transaction
send Solana transactions via MetaMask
Jul 13PaymentsSolanaWeb3setup-evm-browser-app
scaffold EVM browser applications with MetaMask
Jul 13EthereumFrontendJavaScriptTypeScript +1setup-evm-react-app
scaffold React apps with MetaMask integration
Jul 13API DevelopmentEthereumFrontendReact +1
More from MetaMask
View publishersmart-accounts-kit
build dApps with MetaMask Smart Accounts
smart-accounts-kit
Jul 13API DevelopmentAuthEthereumPermissions +1x402-payments
build x402 payment flows
smart-accounts-kit
Jul 13API DevelopmentPaymentsWeb3x402discovery
discover and use services
ocap-kernel
Jul 13API DevelopmentSearchmetamask
interact with MetaMask wallet capabilities
ocap-kernel
Jul 13API DevelopmentEthereumWeb3wallet
manage wallet balances and transactions
ocap-kernel
Jul 13EthereumPaymentsWeb3metamask-connect
integrate MetaMask into dApps
connect-monorepo
Jul 13API DevelopmentEthereumSolanawagmi +1