Pular para o conteúdo
ecdsa.com
← Todos os artigos

Sign-In with Ethereum (EIP-4361): verifique no servidor

Assinar uma mensagem de texto prova o controle de uma conta Ethereum; não diz nada sobre qual site, qual sessão ou qual cadeia, a menos que o servidor verifique. Veja o formato da mensagem, um verificador no servidor e uma execução em uma cadeia local com uma conta comum, uma carteira inteligente implantada e uma carteira que ainda não existe.

Resposta curta. O Sign-In with Ethereum (EIP-4361, Final) é uma mensagem em texto simples com campos fixos, assinada com personal_sign (EIP-191). As carteiras exibem a mensagem e a recusam se o domain dela não corresponder à página que faz o pedido. O servidor deve fazer o resto: emitir um nonce aleatório e aceitá-lo uma única vez, conferir domain, URI, Chain ID, Version e os campos de data e hora com os próprios valores e, só então, verificar a assinatura. Use ecrecover apenas para contas comuns; carteiras de contrato inteligente exigem o isValidSignature da ERC-1271 na cadeia da mensagem, e carteiras que ainda não foram implantadas assinam com o wrapper da ERC-6492. Vincule a sessão ao endereço, e não a um nome ENS ou a um saldo.

A mensagem

Uma mensagem SIWE, criada com o viem para este artigo:

Uma mensagem como a carteira a exibe (da execução abaixo)
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

O formato é fixado por uma gramática ABNF na EIP, e cada linha tem uma função (§ Message Fields):

CampoObrigatórioPara que serve
scheme (antes do domínio, opcional)nãoEsquema da origem; HTTPS quando ausente
domainsimA origem que solicita o login; as carteiras a comparam com a página
addresssimA conta que assina, com checksum EIP-55
statementnãoUma linha legível por humanos, sem quebra de linha no meio
URIsimO recurso ao qual o login se destina
VersionsimDeve ser 1
Chain IDsimA cadeia à qual a sessão fica vinculada e na qual as contas de contrato devem ser resolvidas
NoncesimAleatório, com pelo menos 8 caracteres alfanuméricos, contra repetição
Issued AtsimData e hora de criação, em RFC 3339
Expiration Time, Not BeforenãoJanela de validade da mensagem assinada
Request ID, ResourcesnãoIdentificadores da aplicação e URIs às quais o usuário concede acesso

A carteira assina a mensagem com personal_sign, ou seja, a versão 0x45 da EIP-191: o keccak256 de "\x19Ethereum Signed Message:\n", do comprimento em bytes escrito em decimal e da mensagem. Como a mensagem é texto, o usuário consegue ler o que assina, ao contrário dos hashes de 32 bytes discutidos em EIP-712: o que você realmente assina.

Quem verifica o quê

A EIP-4361 divide o trabalho. As carteiras devem analisar a mensagem segundo a ABNF, comparar scheme e domain com a origem real da requisição e rejeitar qualquer divergência, além de exibir domain, address, statement e resources antes de assinar. Essa é a proteção contra phishing: uma página em app-example.login não consegue obter, de uma carteira de navegador que segue a especificação, uma assinatura sobre uma mensagem que diga app.example.

A parte confiável (relying party, RP) deve verificar a mensagem “quanto à conformidade com a ABNF”, compará-la com os “valores esperados após a análise” e verificar a assinatura, com “o contrato que realiza a verificação … resolvido a partir do ID da cadeia especificado” no caso de contas de contrato. As sessões “DEVEM ser vinculadas ao endereço”. Nada disso é automático, e uma página de phishing ainda pode coletar uma assinatura sobre uma mensagem com o domínio da própria página; por isso o servidor também precisa verificar o domínio.

Um verificador no servidor

O verificador abaixo faz essas verificações antes de tocar na assinatura e usa o verifySiweMessage do viem para a parte criptográfica. Duas decisões são deliberadas: o nonce é consumido na primeira tentativa, com ou sem sucesso, e a cadeia é aquela à qual o cliente do servidor está conectado, de modo que uma mensagem para outra cadeia nem chega à verificação da assinatura.

siwe-server.mjs (viem 2.57.7, Node.js 24.22)
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
    },
  };
}

Repare no que o viem verifica e no que não verifica. Na versão 2.57.7, o verifySiweMessage compara domain, nonce e scheme apenas quando você os passa, confere Expiration Time e Not Before com o horário atual e então verifica a assinatura. Ele não olha Chain ID, URI, Version nem a idade de Issued At. Chamado só com message e signature, ele aceita uma mensagem assinada para qualquer domínio e com qualquer nonce, o que este teste curto confirma, junto com uma segunda armadilha, os finais de linha:

pitfalls.mjs
// 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]}"]`);
Saída (node:24.22.0-bookworm-slim com anvil 1.8.5)
verifySiweMessage({ message, signature })                 -> true
verifySiweMessage({ message, signature, domain, nonce }) -> false
same message with CRLF line endings                      -> false
personal_sign params: ["0x6f746865722e6578616d706c...", "0xC527ED169430Ca38aADCEb03cd04f6F390b4840F"]

A primeira linha é uma assinatura válida sobre uma mensagem para other.example, aceita por uma chamada que não informa nenhum domínio. A terceira mostra por que uma mensagem SIWE precisa chegar intacta, byte a byte: a ABNF usa LF entre as linhas, e um template, um campo de formulário ou uma camada HTTP que o troque por CRLF produz outro hash EIP-191 e uma verificação com falha que parece uma assinatura inválida.

nonce-check.mjs
// 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("")}`);
Saída (node:24.22.0-bookworm-slim)
first   ef56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b
second  f56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b7
second equals first shifted by one character: true
alphabet: 0123456789abcdef

O lado do cliente

A página que inicia o login faz três coisas: busca um nonce no servidor, monta a mensagem com o domínio e a URI da própria página e pede à carteira que a assine. Pela interface de provedor do navegador (EIP-1193), esse último passo é uma requisição personal_sign cujos parâmetros, segundo a especificação da API do MetaMask, são primeiro a mensagem em UTF-8 codificada em hex e depois o endereço, como mostra a última linha da saída de pitfalls.mjs. Funções de biblioteca como o signMessage do viem fazem essa codificação; código escrito à mão que passa o endereço primeiro, ou a string crua em vez de hex, é uma fonte comum de assinaturas sobre os bytes errados.

Depois, envie ao servidor a string exata da mensagem que foi assinada, e não os campos a partir dos quais ela foi montada. O servidor analisa e verifica essa string; reconstruí-la no servidor a partir de campos separados faz com que cada diferença de espaços em branco ou de ordem vire uma falha de verificação ou, pior, uma divergência entre o que foi verificado e o que foi assinado.

A lista Resources merece um comentário. A ERC-5573 (“ReCaps”, ainda um rascunho) usa a última entrada dela, uma URI urn:recap:, para pedir capacidades com escopo definido e acrescenta uma frase gerada à declaração (“I further authorize the stated URI to perform the following actions on my behalf…”). Se você usar esse mecanismo, o servidor precisa analisar e aplicar essas capacidades; um verificador SIWE simples as ignora.

Carteiras de contrato inteligente: ERC-1271 e ERC-6492

Uma carteira de contrato inteligente, como uma Safe ou uma conta ERC-4337, não tem chave privada própria. O ecrecover aplicado à assinatura dela retorna o endereço da chave que assinou, seja ela qual for (a chave de um proprietário, uma chave de sessão), ou nada, mas nunca o endereço da carteira. A ERC-1271 (Final) deixa a decisão para a carteira: o verificador chama isValidSignature(bytes32 hash, bytes signature) na carteira, que retorna 0x1626ba7e se a assinatura for válida pelas regras dela.

Muitas carteiras inteligentes só são implantadas quando necessário, na primeira transação, mas os usuários querem fazer login antes disso. A ERC-6492 (Final) encapsula a assinatura com o endereço do contrato de fábrica e o calldata que implantaria a carteira, seguidos do sufixo de 32 bytes 0x6492…6492. Um verificador que vê o sufixo simula a implantação em um eth_call e depois consulta a futura carteira; a ERC exige procurar o sufixo antes da ERC-1271 e antes do ecrecover. O verifySiweMessage do viem cobre os três casos por meio de verifyHash.

Para testar os três caminhos, o laboratório rodou uma cadeia local com o anvil do Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5) e uma carteira mínima compilada com o solc 0.8.30: uma chave de proprietário, isValidSignature com ecrecover e uma fábrica CREATE2, para que o endereço seja conhecido antes da implantação.

OwnerWallet.sol (compilado com forge 1.8.5, solc 0.8.30)
// 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)))));
    }
}

O script principal financia uma chave de implantação recém-gerada, implanta o contrato de fábrica, implanta a carteira de bob e apenas prevê o endereço da de carol; depois faz login como alice (uma conta comum), como a carteira de bob e como a carteira ainda não implantada de carol e, por fim, tenta repetições e falsificações:

run.mjs
// 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) }));
Saída (node:24.22.0-bookworm-slim com anvil 1.8.5)
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

O que a execução mostra:

  • Os três tipos de conta fizeram login pelo mesmo caminho de código. Para a carteira de bob, o ecrecover retorna a chave do proprietário, um endereço diferente do que está na mensagem; um servidor que compara esse resultado com address rejeita todo usuário de carteira inteligente, e um que, em vez disso, aceita o proprietário abre a sessão do usuário na conta errada. Para a carteira não implantada de carol, o ecrecover nem consegue interpretar a assinatura encapsulada.
  • A verificação ERC-6492 não implanta nada. A carteira de carol não tinha código nem antes nem depois do login; a implantação aconteceu apenas dentro da chamada simulada.
  • Cada falsificação falhou na verificação certa: a repetição no nonce, o site imitador no domínio, a outra cadeia no ID da cadeia, a mensagem antiga em Issued At, a declaração editada e o signatário errado na assinatura.

Depois da assinatura: sessões

  • Vincule a sessão ao endereço e à cadeia. A EIP-4361 diz que as sessões “DEVEM ser vinculadas ao address, e não a outros recursos resolvidos a partir dele que podem mudar”, como um nome ENS ou um saldo de tokens. Verifique esses dados de novo quando for usá-los.
  • Carteiras de contrato podem mudar de ideia. Os resultados da ERC-1271 dependem do estado da cadeia: proprietários podem ser trocados, e chaves de sessão, revogadas. A EIP sugere invalidar as sessões quando o estado por trás de uma assinatura muda; no mínimo, mantenha curtas as sessões de contas de contrato.
  • Emita sua própria credencial de sessão (um cookie ou token) após a verificação e nunca aceite a própria assinatura SIWE como credencial do tipo bearer em requisições posteriores; por concepção, dentro da janela de validade, nada além do nonce que você já consumiu impede que ela seja repetida.
  • O campo Expiration Time (data de expiração) limita a mensagem, não a sessão. Ele delimita o período em que a assinatura pode ser apresentada; a duração da sessão é uma decisão separada.

Quando o SIWE é a ferramenta certa

O SIWE autentica uma conta, não uma pessoa. Ele é adequado quando a conta Ethereum é a identidade que importa: uma aplicação que age sobre os ativos on-chain do usuário, verifica uma associação condicionada à posse de tokens (token-gated) ou quer a mesma identidade em vários serviços sem endereço de e-mail. Como único login de um serviço web comum, é uma escolha ruim: uma chave perdida não tem “esqueci minha senha”, não há garantia de uma conta por usuário, e as próprias considerações de segurança da EIP-4361 apontam o custo de privacidade de reutilizar o mesmo endereço em todo lugar. Para contas de usuário tradicionais, as chaves de acesso (passkeys) oferecem login resistente a phishing sem carteira; para identidade federada, o OpenID Connect fornece um emissor e um titular verificados. Muitas aplicações combinam as duas coisas: uma sessão com chave de acesso ou OIDC para a pessoa e uma assinatura SIWE para vincular um endereço a ela.

Erros comuns

  1. Montar a mensagem no servidor e confiar que o cliente vai assiná-la sem alterações. Verifique o que foi assinado, campo por campo; é o cliente que escolhe o que envia de volta.
  2. Comparar domain com o cabeçalho Host. Atrás de um proxy, use o domínio público que você configurou.
  3. Não armazenar os nonces. Um nonce que nunca é conferido com o conjunto dos nonces emitidos não impede nada.
  4. Verificar carteiras inteligentes na cadeia errada. O mesmo endereço pode ser outro contrato, ou nenhum contrato, em outra cadeia; resolva no Chain ID da mensagem e recuse as cadeias que você não atende.
  5. Usar ecrecover em todos os casos. Ele rejeita usuários de carteiras inteligentes ou, pior, atribui o login deles à chave do proprietário.
  6. Comparar endereços diferenciando maiúsculas de minúsculas. O checksum EIP-55 mistura maiúsculas e minúsculas; compare os endereços sem diferenciar maiúsculas de minúsculas ou após a normalização.

Fontes