Short answer. Sign-In with Ethereum (EIP-4361, Final) is a plain-text message with fixed fields, signed with personal_sign (EIP-191). Wallets show it and refuse it if its domain does not match the page asking. The server must do the rest: issue a random nonce and accept it once, check domain, URI, Chain ID, Version and the time fields against its own values, then verify the signature. Use ecrecover only for plain accounts; smart-contract wallets need ERC-1271 isValidSignature on the message's chain, and wallets that are not deployed yet sign with the ERC-6492 wrapper. Bind the session to the address, not to an ENS name or a balance.
The message
A SIWE message, created with viem for this article:
app.example wants you to sign in with your Ethereum account:
0x701111bB8ee63D09066471B2b26E8a6eD017B521
Sign in to app.example.
URI: https://app.example/login
Version: 1
Chain ID: 31337
Nonce: 7d19b07372ad88a030c953465f770a48
Issued At: 2026-10-20T13:10:41.396Z
Expiration Time: 2026-10-20T13:20:41.396Z
The format is fixed by an ABNF grammar in the EIP, and each line has a job (§ Message Fields):
| Field | Required | What it does |
|---|---|---|
scheme (before the domain, optional) | no | Origin scheme; HTTPS when absent |
domain | yes | The origin requesting the sign-in; wallets check it against the page |
address | yes | The account signing, EIP-55 checksummed |
statement | no | One human-readable line, no newline inside |
URI | yes | The resource the sign-in is for |
Version | yes | Must be 1 |
Chain ID | yes | The chain the session is bound to, where contract accounts must be resolved |
Nonce | yes | Random, at least 8 alphanumeric characters, against replay |
Issued At | yes | RFC 3339 time of creation |
Expiration Time, Not Before | no | Validity window of the signed message |
Request ID, Resources | no | Application identifiers and URIs the user grants access to |
The wallet signs it with personal_sign, that is EIP-191 version 0x45: keccak256 over "\x19Ethereum Signed Message:\n", the byte length in decimal and the message. Because the message is text, a user can read what they sign, unlike the 32-byte hashes discussed in EIP-712: what you actually sign.
Who checks what
EIP-4361 splits the work. Wallets must parse the message against the ABNF, compare scheme and domain with the actual origin of the request and reject a mismatch, and show domain, address, statement and resources before signing. That is the phishing protection: a page on app-example.login cannot get a signature on a message saying app.example from a conforming browser wallet.
The relying party must check the message "for conformance to the ABNF", against "expected values after parsing", and verify the signature, with "the contract performing the verification … resolved from the specified chain-id" for contract accounts. Sessions "MUST be bound to the address". None of that is automatic, and a phishing page can still collect a signature on a message with its own domain, which is why the server must check the domain too.
A server verifier
The verifier below does those checks before it touches the signature, and uses viem's verifySiweMessage for the cryptography. Two decisions are deliberate: the nonce is consumed on the first attempt, successful or not, and the chain is the one the server's client is connected to, so a message for another chain cannot even reach the signature check.
import { randomBytes } from "node:crypto";
import { parseSiweMessage } from "viem/siwe";
/**
* Server side of Sign-In with Ethereum (EIP-4361). `client` is a viem public client connected
* to the chain the sessions are bound to: EIP-4361 requires contract accounts to be resolved
* on the message's Chain ID.
*/
export function createSiweVerifier({ client, domain, origin, maxAgeSeconds = 300 }) {
const pending = new Map(); // nonce -> expiry (ms); one entry per sign-in attempt
return {
/** Step 1: a fresh nonce for the page that is about to ask the wallet to sign. */
issueNonce() {
const nonce = randomBytes(16).toString("hex"); // 128 bits from the OS CSPRNG, 32 alphanumeric characters
pending.set(nonce, Date.now() + maxAgeSeconds * 1000);
return nonce;
},
/** Step 2: check the signed message, then the signature; returns the session subject. */
async verify(message, signature) {
const fields = parseSiweMessage(message);
const expiry = pending.get(fields.nonce);
pending.delete(fields.nonce); // single use, whatever the outcome
if (expiry === undefined) throw new Error("unknown or already used nonce");
if (Date.now() > expiry) throw new Error("nonce expired");
if (fields.domain !== domain) throw new Error(`message is for ${fields.domain}`);
if (!fields.uri || new URL(fields.uri).origin !== origin) throw new Error(`URI ${fields.uri} is not on ${origin}`);
if (fields.version !== "1") throw new Error(`unsupported version ${fields.version}`);
if (fields.chainId !== client.chain.id) throw new Error(`message is for chain ${fields.chainId}`);
if (!fields.issuedAt || Date.now() - fields.issuedAt.getTime() > maxAgeSeconds * 1000) {
throw new Error("issued too long ago");
}
// verifySiweMessage checks domain and nonce only when they are passed, plus expiration and
// not-before, then the signature: ecrecover for plain accounts, ERC-1271 for deployed
// contract accounts, ERC-6492 for contract accounts that are not deployed yet.
const valid = await client.verifySiweMessage({ message, signature, domain, nonce: fields.nonce });
if (!valid) throw new Error("signature does not verify");
return { address: fields.address, chainId: fields.chainId }; // bind the session to the address
},
};
}
Note what viem does and does not check. In 2.57.7, verifySiweMessage compares domain, nonce and scheme only when you pass them, checks Expiration Time and Not Before against the current time, and then verifies the signature. It does not look at Chain ID, URI, Version or how old Issued At is. Calling it with only message and signature accepts a message signed for any domain with any nonce, which this short test confirms, together with a second trap, line endings:
// Two ways a SIWE check goes wrong without any attacker skill: calling the library without the
// expected values, and changing the line endings of the message. EOA only; keys generated here.
import { createPublicClient, http } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { foundry } from "viem/chains";
import { createSiweMessage } from "viem/siwe";
const client = createPublicClient({ chain: foundry, transport: http("http://127.0.0.1:8545") });
const alice = privateKeyToAccount(generatePrivateKey());
// A message alice signed on some other site, with that site's nonce.
const elsewhere = createSiweMessage({
domain: "other.example", address: alice.address, uri: "https://other.example/", version: "1",
chainId: foundry.id, nonce: "a1b2c3d4", issuedAt: new Date(),
});
const signature = await alice.signMessage({ message: elsewhere });
console.log(`verifySiweMessage({ message, signature }) -> ${await client.verifySiweMessage({ message: elsewhere, signature })}`);
console.log(`verifySiweMessage({ message, signature, domain, nonce }) -> ${await client.verifySiweMessage({ message: elsewhere, signature, domain: "app.example", nonce: "f00dfeed" })}`);
// The same message after a transport or template turned LF into CRLF.
const crlf = elsewhere.replaceAll("\n", "\r\n");
console.log(`same message with CRLF line endings -> ${await client.verifySiweMessage({ message: crlf, signature })}`);
// What personal_sign receives over EIP-1193: the hex-encoded UTF-8 message first, then the address.
const params = [`0x${Buffer.from(elsewhere, "utf8").toString("hex")}`, alice.address];
console.log(`personal_sign params: ["${params[0].slice(0, 26)}...", "${params[1]}"]`);
verifySiweMessage({ message, signature }) -> true
verifySiweMessage({ message, signature, domain, nonce }) -> false
same message with CRLF line endings -> false
personal_sign params: ["0x6f746865722e6578616d706c...", "0xC527ED169430Ca38aADCEb03cd04f6F390b4840F"]
The first line is a valid signature on a message for other.example, accepted by a call that names no domain. The third shows why a SIWE message must travel byte for byte: the ABNF uses LF between lines, and a template, a form field or an HTTP layer that rewrites it to CRLF produces a different EIP-191 hash and a failed verification that looks like a bad signature.
// How related are two consecutive nonces from viem's generateSiweNonce()?
import { generateSiweNonce } from "viem/siwe";
const a = generateSiweNonce();
const b = generateSiweNonce();
console.log(`first ${a}`);
console.log(`second ${b}`);
console.log(`second equals first shifted by one character: ${b.slice(0, 95) === a.slice(1)}`);
console.log(`alphabet: ${[...new Set(a + b)].sort().join("")}`);
first ef56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b
second f56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b7
second equals first shifted by one character: true
alphabet: 0123456789abcdef
The client side
The page that starts a sign-in does three things: fetch a nonce from the server, build the message with the page's own domain and URI, and ask the wallet to sign it. Over the browser provider interface (EIP-1193) that last step is a personal_sign request whose parameters are, in MetaMask's API specification, the message as hex-encoded UTF-8 first and the address second, as the last line of the pitfalls.mjs output shows. Libraries such as viem's signMessage do the encoding; hand-rolled code that passes the address first or the raw string instead of hex is a common source of signatures over the wrong bytes.
Then send the server the exact message string that was signed, not the fields it was built from. The server parses and checks that string; reconstructing it on the server from separate fields invites every whitespace and ordering difference to become a verification failure, or, worse, a mismatch between what was checked and what was signed.
The Resources list deserves a word. ERC-5573 ("ReCaps", still a draft) uses its last entry, a urn:recap: URI, to request scoped capabilities and adds a generated sentence to the statement ("I further authorize the stated URI to perform the following actions on my behalf…"). If you use it, the server must parse and enforce those capabilities; a plain SIWE verifier ignores them.
Smart-contract wallets: ERC-1271 and ERC-6492
A smart-contract wallet, such as a Safe or an ERC-4337 account, has no private key of its own. ecrecover on its signature returns the address of whatever key signed (an owner, a session key) or nothing at all, never the wallet's address. ERC-1271 (Final) lets the wallet decide: the verifier calls isValidSignature(bytes32 hash, bytes signature) on the wallet, which returns 0x1626ba7e if the signature is valid by its own rules.
Many smart wallets are deployed lazily, on their first transaction, yet users want to sign in before that. ERC-6492 (Final) wraps the signature with the factory address and the calldata that would deploy the wallet, followed by the 32-byte suffix 0x6492…6492. A verifier that sees the suffix simulates the deployment in an eth_call and then asks the would-be wallet; the ERC requires checking for the suffix before ERC-1271 and before ecrecover. viem's verifySiweMessage does all three through verifyHash.
To test all three paths, the lab ran a local chain with anvil from Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5) and a minimal wallet compiled with solc 0.8.30: one owner key, isValidSignature with ecrecover, and a CREATE2 factory so that the address is known before deployment.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
/// A minimal smart-contract account for the article's lab: one owner key, ERC-1271 signature checks.
contract OwnerWallet {
address public immutable owner;
constructor(address owner_) {
require(owner_ != address(0), "owner required");
owner = owner_;
}
/// ERC-1271: returns 0x1626ba7e when `signature` is the owner's signature over `hash`.
function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) {
if (signature.length != 65) return 0xffffffff;
bytes32 r = bytes32(signature[0:32]);
bytes32 s = bytes32(signature[32:64]);
uint8 v = uint8(signature[64]);
// Reject high-s values (EIP-2), so one owner signature has exactly one valid encoding.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return 0xffffffff;
return ecrecover(hash, v, r, s) == owner ? bytes4(0x1626ba7e) : bytes4(0xffffffff);
}
}
/// Deploys OwnerWallet with CREATE2, so a wallet's address is known before it exists (ERC-6492).
contract OwnerWalletFactory {
function deploy(address owner, bytes32 salt) external returns (address wallet) {
wallet = predict(owner, salt);
if (wallet.code.length == 0) new OwnerWallet{salt: salt}(owner);
}
function predict(address owner, bytes32 salt) public view returns (address) {
bytes32 initCodeHash = keccak256(abi.encodePacked(type(OwnerWallet).creationCode, abi.encode(owner)));
return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initCodeHash)))));
}
}
The driver funds a freshly generated deployer key, deploys the factory, deploys bob's wallet and only predicts carol's, then signs in as alice (a plain account), bob's wallet and carol's undeployed wallet, and finally replays and forges:
// Lab driver: an EOA, a deployed ERC-1271 wallet and an undeployed (ERC-6492) wallet sign in to the
// same server on a local anvil chain; then the attacks the server has to refuse. All keys are generated here.
import { readFileSync } from "node:fs";
import { createPublicClient, createTestClient, createWalletClient, encodeFunctionData, hashMessage, http, parseEther, recoverMessageAddress, serializeErc6492Signature } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { foundry } from "viem/chains";
import { createSiweMessage } from "viem/siwe";
import { createSiweVerifier } from "./siwe-server.mjs";
const transport = http("http://127.0.0.1:8545");
const client = createPublicClient({ chain: foundry, transport });
const deployer = privateKeyToAccount(generatePrivateKey());
await createTestClient({ chain: foundry, mode: "anvil", transport }).setBalance({ address: deployer.address, value: parseEther("10") });
const wallet = createWalletClient({ account: deployer, chain: foundry, transport });
const artifact = (name) => JSON.parse(readFileSync(`/chain/out/OwnerWallet.sol/${name}.json`, "utf8"));
const { abi: factoryAbi, bytecode } = artifact("OwnerWalletFactory");
const deployTx = await wallet.deployContract({ abi: factoryAbi, bytecode: bytecode.object });
const factory = (await client.waitForTransactionReceipt({ hash: deployTx })).contractAddress;
const predict = (owner, salt) => client.readContract({ address: factory, abi: factoryAbi, functionName: "predict", args: [owner, salt] });
const deployWallet = async (owner, salt) => {
const hash = await wallet.writeContract({ address: factory, abi: factoryAbi, functionName: "deploy", args: [owner, salt] });
await client.waitForTransactionReceipt({ hash });
};
const server = createSiweVerifier({ client, domain: "app.example", origin: "https://app.example" });
const messageFor = (address, overrides = {}) =>
createSiweMessage({
domain: "app.example",
address,
statement: "Sign in to app.example.",
uri: "https://app.example/login",
version: "1",
chainId: foundry.id,
nonce: server.issueNonce(),
issuedAt: new Date(),
expirationTime: new Date(Date.now() + 10 * 60_000),
...overrides,
});
// Three users. alice: a plain account. bob: a deployed smart wallet. carol: a smart wallet not deployed yet.
const alice = privateKeyToAccount(generatePrivateKey());
const bob = privateKeyToAccount(generatePrivateKey());
const carol = privateKeyToAccount(generatePrivateKey());
const salt = `0x${"00".repeat(31)}01`;
await deployWallet(bob.address, salt);
const bobWallet = await predict(bob.address, salt);
const carolWallet = await predict(carol.address, salt);
const aliceMessage = messageFor(alice.address);
console.log(`${aliceMessage}\n`);
const attempts = [];
attempts.push(["EOA, personal_sign", aliceMessage, await alice.signMessage({ message: aliceMessage })]);
const bobMessage = messageFor(bobWallet);
attempts.push(["deployed wallet, ERC-1271", bobMessage, await bob.sign({ hash: hashMessage(bobMessage) })]);
const carolMessage = messageFor(carolWallet);
attempts.push([
"undeployed wallet, ERC-6492",
carolMessage,
serializeErc6492Signature({
address: factory,
data: encodeFunctionData({ abi: factoryAbi, functionName: "deploy", args: [carol.address, salt] }),
signature: await carol.sign({ hash: hashMessage(carolMessage) }),
}),
]);
console.log(`carol's wallet has code before sign-in: ${Boolean(await client.getCode({ address: carolWallet }))}`);
for (const [label, message, signature] of attempts) {
let recovered;
try {
recovered = await recoverMessageAddress({ message, signature });
} catch (e) {
recovered = e.shortMessage ?? e.message;
}
const claimed = message.split("\n")[1];
const result = await server.verify(message, signature).then((s) => `session for ${s.address.slice(0, 10)}...`, (e) => `rejected: ${e.message}`);
console.log(`${label.padEnd(28)} ${result}; ecrecover alone gives ${recovered === claimed ? "the same address" : String(recovered).slice(0, 42)}`);
}
console.log(`carol's wallet has code after sign-in: ${Boolean(await client.getCode({ address: carolWallet }))}`);
// What the server must refuse.
const check = async (label, message, signature) => {
const result = await server.verify(message, signature).then(() => "ACCEPTED", (e) => `rejected: ${e.message}`);
console.log(`${label.padEnd(36)} ${result}`);
};
const replayed = messageFor(alice.address);
const replayedSig = await alice.signMessage({ message: replayed });
await server.verify(replayed, replayedSig);
await check("same message and signature again", replayed, replayedSig);
const phish = messageFor(alice.address, { domain: "app-example.login", uri: "https://app-example.login/" });
await check("signed on a look-alike site", phish, await alice.signMessage({ message: phish }));
const otherChain = messageFor(alice.address, { chainId: 1 });
await check("message for chain 1", otherChain, await alice.signMessage({ message: otherChain }));
const stale = messageFor(alice.address, { issuedAt: new Date(Date.now() - 3600_000), expirationTime: new Date(Date.now() + 3600_000) });
await check("issued an hour ago", stale, await alice.signMessage({ message: stale }));
const original = messageFor(alice.address);
const originalSig = await alice.signMessage({ message: original });
await check("statement edited after signing", original.replace("Sign in to app.example.", "Sign in to app.example as admin."), originalSig);
const mallory = privateKeyToAccount(generatePrivateKey());
const claimsBob = messageFor(bobWallet);
await check("bob's wallet, signed by someone else", claimsBob, await mallory.sign({ hash: hashMessage(claimsBob) }));
carol's wallet has code before sign-in: false
EOA, personal_sign session for 0x701111bB...; ecrecover alone gives the same address
deployed wallet, ERC-1271 session for 0x5d3a68A0...; ecrecover alone gives 0xE4e0d879c3a051245cc489dE8Ae281c019691864
undeployed wallet, ERC-6492 session for 0x6F47E5F9...; ecrecover alone gives invalid signature length
carol's wallet has code after sign-in: false
same message and signature again rejected: unknown or already used nonce
signed on a look-alike site rejected: message is for app-example.login
message for chain 1 rejected: message is for chain 1
issued an hour ago rejected: issued too long ago
statement edited after signing rejected: signature does not verify
bob's wallet, signed by someone else rejected: signature does not verify
What the run shows:
- All three account types signed in through one code path. For bob's wallet
ecrecoverreturns the owner's key, a different address from the one in the message; a server that compares that withaddressrejects every smart-wallet user, and one that accepts the owner instead logs the user into the wrong account. For carol's undeployed walletecrecovercannot even parse the wrapped signature. - ERC-6492 verification does not deploy anything. Carol's wallet had no code before and after the sign-in; the deployment happened only inside the simulated call.
- Every forgery failed at the right check: the replay at the nonce, the look-alike site at the domain, the other chain at the chain ID, the stale message at
Issued At, the edited statement and the wrong signer at the signature.
After the signature: sessions
- Bind the session to the address and the chain. EIP-4361 says sessions "MUST be bound to the
addressand not to further resolved resources that can change", such as an ENS name or a token balance. Re-check those when you use them. - Contract wallets can change their mind. ERC-1271 results depend on chain state: owners can be rotated, session keys revoked. The EIP suggests invalidating sessions when the state behind a signature changes; at minimum, keep sessions short for contract accounts.
- Issue your own session credential (a cookie or token) after verification, and never accept the SIWE signature itself as a bearer credential on later requests; it is replayable by design within its validity window except for the nonce you already consumed.
- Expiration Time limits the message, not the session. It bounds when the signature can be presented; your session lifetime is a separate decision.
When SIWE is the right tool
SIWE authenticates an account, not a person. It fits when the Ethereum account is the identity that matters: an application that acts on the user's on-chain assets, checks token-gated membership, or wants the same identity across services without an email address. It is a poor fit as the only login for a general web service: a lost key has no "forgot password", one account per user is not guaranteed, and EIP-4361's own security considerations note the privacy cost of reusing one address everywhere. For ordinary accounts, passkeys give phishing-resistant sign-in without a wallet; for federated identity, OpenID Connect gives you a verified issuer and subject. Many applications combine them: a passkey or OIDC session for the person, and a SIWE signature to link an address to it.
Common mistakes
- Building the message on the server and trusting the client to sign it unchanged. Verify what was signed, field by field; the client chooses what it sends back.
- Comparing
domainwith theHostheader. Behind a proxy, use your configured public domain. - Not storing nonces. A nonce that is never checked against an issued set prevents nothing.
- Verifying smart wallets on the wrong chain. The same address can be a different contract, or no contract, on another chain; resolve on the message's
Chain IDand refuse chains you do not serve. - Using
ecrecovereverywhere. It rejects smart-wallet users, or worse, attributes their sign-in to the owner key. - Case-sensitive address comparison. EIP-55 checksums mix case; compare addresses case-insensitively or after normalization.
Sources
- EIP-4361: Sign-In with Ethereum: message fields, ABNF, relying party and wallet steps, security considerations
- EIP-191: Signed Data Standard
- ERC-1271: Standard Signature Validation Method for Contracts
- ERC-6492: Signature Validation for Predeploy Contracts
- ERC-5573: Sign-In with Ethereum Capabilities, ReCaps (Draft)
- MetaMask API specification:
personal_signparameters - viem SIWE:
createSiweMessage,parseSiweMessage,verifySiweMessage - Foundry: anvil and forge