Перейти к содержимому
ecdsa.com
← Все статьи

Sign-In with Ethereum (EIP-4361): проверка на сервере

Подпись текстового сообщения доказывает, что подписант управляет аккаунтом Ethereum, но ничего не говорит о том, для какого сайта, какой сессии и какой сети она предназначена, если этого не проверит сервер. Здесь разобраны формат сообщения и код проверки на сервере, а также прогон на локальном блокчейне с обычным аккаунтом, развёрнутым смарт-кошельком и кошельком, которого ещё не существует.

Коротко. Sign-In with Ethereum (EIP-4361, статус Final) — текстовое сообщение с фиксированным набором полей, которое подписывают через personal_sign (EIP-191). Кошельки показывают его пользователю и отказываются подписывать, если domain в нём не совпадает со страницей, которая запрашивает подпись. Всё остальное должен сделать сервер: выдать случайный nonce и принять его только один раз, сверить domain, URI, Chain ID, Version и поля времени со своими значениями и только после этого проверить подпись. Используйте ecrecover только для обычных аккаунтов: кошелькам на смарт-контрактах нужен вызов ERC-1271 isValidSignature в сети, указанной в сообщении, а кошельки, которые ещё не развёрнуты, подписывают с обёрткой ERC-6492. Привязывайте сессию к адресу, а не к имени ENS или балансу.

Сообщение

Сообщение SIWE, созданное с помощью viem для этой статьи:

Сообщение в том виде, в каком его показывает кошелёк (из прогона ниже)
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

Формат задан грамматикой ABNF в самом EIP, и у каждой строки своя задача (§ Message Fields):

ПолеОбязательноеНазначение
scheme (перед доменом, необязательное)нетСхема источника; если поле отсутствует — HTTPS
domainдаИсточник, который запрашивает вход; кошельки сверяют его со страницей
addressдаАккаунт, который подписывает сообщение, в записи с контрольной суммой EIP-55
statementнетОдна строка для человека, без переносов внутри
URIдаРесурс, для которого выполняется вход
VersionдаДолжно быть 1
Chain IDдаСеть, к которой привязана сессия; контрактные аккаунты нужно проверять именно в ней
NonceдаСлучайная строка не менее чем из 8 буквенно-цифровых символов; защищает от повторного использования
Issued AtдаВремя создания в формате RFC 3339
Expiration Time, Not BeforeнетПериод, в течение которого подписанное сообщение действительно
Request ID, ResourcesнетИдентификатор, заданный приложением, и URI, к которым пользователь предоставляет доступ

Кошелёк подписывает сообщение через personal_sign, то есть по EIP-191 версии 0x45: keccak256 от "\x19Ethereum Signed Message:\n", длины сообщения в байтах в десятичной записи и самого сообщения. Поскольку сообщение текстовое, пользователь может прочитать то, что подписывает, — в отличие от 32-байтных хешей, о которых идёт речь в статье EIP-712: что именно вы подписываете.

Кто что проверяет

EIP-4361 распределяет работу. Кошельки должны разбирать сообщение по грамматике ABNF, сравнивать scheme и domain с фактическим источником запроса и при несовпадении отклонять сообщение, а перед подписанием показывать domain, address, statement и resources. В этом и состоит защита от фишинга: страница на app-example.login не получит от браузерного кошелька, который соблюдает стандарт, подпись под сообщением, где указан app.example.

Доверяющая сторона (relying party) должна проверить сообщение «на соответствие ABNF» и на совпадение с «ожидаемыми значениями после разбора», а также проверить подпись, причём для контрактных аккаунтов «контракт, выполняющий проверку, … определяется по указанному chain-id». Сессии «ДОЛЖНЫ быть привязаны к адресу». Ничего из этого не происходит само собой, а фишинговая страница всё равно может получить подпись под сообщением со своим доменом, поэтому домен должен проверять и сервер.

Проверка на сервере

Код ниже выполняет эти проверки до того, как взяться за подпись, а криптографию поручает функции verifySiweMessage из viem. Два решения приняты намеренно: nonce расходуется при первой же попытке, успешной или нет, а сетью считается та, к которой подключён клиент сервера, поэтому сообщение для другой сети не доходит даже до проверки подписи.

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
    },
  };
}

Обратите внимание, что viem проверяет, а что — нет. В версии 2.57.7 функция verifySiweMessage сравнивает domain, nonce и scheme, только если их передать, сверяет Expiration Time и Not Before с текущим временем и затем проверяет подпись. На Chain ID, URI, Version и возраст Issued At она не смотрит. Вызов только с message и signature принимает сообщение, подписанное для любого домена с любым nonce. Это подтверждает короткий тест ниже, а заодно показывает вторую ловушку — окончания строк:

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]}"]`);
Вывод (node:24.22.0-bookworm-slim с 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"]

Первая строка — верная подпись под сообщением для other.example, которую принял вызов без указания домена. Третья показывает, почему сообщение SIWE нужно передавать байт в байт: грамматика ABNF разделяет строки символом LF, и если шаблон, поле формы или HTTP-слой заменит его на CRLF, получится другой хеш EIP-191, а проверка не пройдёт и со стороны будет выглядеть как неверная подпись.

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("")}`);
Вывод (node:24.22.0-bookworm-slim)
first   ef56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b
second  f56353692864c3d4da72cbdea645d3aac4543c9527e869572a71cbd5bf1d340b2488493e175223f8453b8e6e4afae8b7
second equals first shifted by one character: true
alphabet: 0123456789abcdef

На стороне клиента

Страница, которая начинает вход, делает три вещи: получает nonce от сервера, собирает сообщение с собственными доменом и URI и просит кошелёк его подписать. В интерфейсе провайдера в браузере (EIP-1193) последний шаг — это запрос personal_sign, параметры которого, согласно спецификации API MetaMask, идут в таком порядке: сначала сообщение в кодировке UTF-8, записанное в hex, затем адрес, как и показывает последняя строка вывода pitfalls.mjs. Кодирование берут на себя библиотеки, например signMessage из viem; самописный код, который передаёт адрес первым или исходную строку вместо hex, — частая причина того, что подписанными оказываются не те байты.

Затем отправьте на сервер ту самую строку сообщения, которая была подписана, а не поля, из которых её собрали. Сервер разбирает и проверяет именно эту строку; если собирать её на сервере заново из отдельных полей, любое расхождение в пробелах или порядке строк превратится в непройденную проверку или, что хуже, в несовпадение между тем, что проверено, и тем, что подписано.

Отдельно стоит сказать о списке Resources. ERC-5573 («ReCaps», пока в статусе черновика) использует его последний элемент, URI вида urn:recap:, чтобы запросить полномочия с ограниченной областью действия, и добавляет в заявление сгенерированное предложение («I further authorize the stated URI to perform the following actions on my behalf…»). Если вы его используете, сервер должен разбирать эти полномочия и следить за их соблюдением; обычный код проверки SIWE их игнорирует.

Кошельки на смарт-контрактах: ERC-1271 и ERC-6492

У кошелька на смарт-контракте, например Safe или аккаунта ERC-4337, нет собственного закрытого ключа. ecrecover для его подписи возвращает адрес того ключа, который её создал (ключа владельца, сессионного ключа), или вообще ничего, но никогда не адрес кошелька. ERC-1271 (статус Final) оставляет решение за кошельком: проверяющая сторона вызывает у кошелька isValidSignature(bytes32 hash, bytes signature), и кошелёк возвращает 0x1626ba7e, если подпись верна по его собственным правилам.

Многие смарт-кошельки развёртываются отложенно, при первой транзакции, а войти пользователи хотят ещё до неё. ERC-6492 (статус Final) оборачивает подпись вместе с адресом фабрики и calldata вызова, который развернул бы кошелёк, и дописывает в конец 32-байтный суффикс 0x6492…6492. Проверяющая сторона, увидев суффикс, имитирует развёртывание в eth_call и затем обращается к будущему кошельку; ERC требует проверять наличие суффикса до ERC-1271 и до ecrecover. Функция verifySiweMessage из viem справляется со всеми тремя случаями через verifyHash.

Чтобы проверить все три пути, на стенде запустили локальный блокчейн на anvil из Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5) и минимальный кошелёк, скомпилированный solc 0.8.30: один ключ владельца, isValidSignature на основе ecrecover и фабрика CREATE2, чтобы адрес был известен до развёртывания.

OwnerWallet.sol (скомпилирован 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)))));
    }
}

Управляющий скрипт пополняет баланс только что сгенерированного аккаунта, с которого развёртываются контракты, развёртывает фабрику, затем кошелёк bob, а для кошелька carol только вычисляет адрес. После этого он входит от имени alice (обычный аккаунт), кошелька bob и неразвёрнутого кошелька carol и, наконец, пробует повторы и подделки:

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) }));
Вывод (node:24.22.0-bookworm-slim с 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

Что показал прогон:

  • Все три типа аккаунтов вошли через один и тот же путь в коде. Для кошелька bob ecrecover возвращает ключ владельца, то есть не тот адрес, что указан в сообщении; сервер, который сравнивает этот результат с address, отклоняет всех пользователей смарт-кошельков, а сервер, который вместо этого принимает владельца, впускает пользователя не в тот аккаунт. Для неразвёрнутого кошелька carol ecrecover не может даже разобрать обёрнутую подпись.
  • Проверка по ERC-6492 ничего не развёртывает. У кошелька carol не было кода ни до, ни после входа: развёртывание произошло только внутри имитированного вызова.
  • Каждая подделка отсеялась на нужной проверке: повтор — на nonce, сайт-двойник — на домене, другая сеть — на chain ID, устаревшее сообщение — на Issued At, изменённое заявление и чужой подписант — на подписи.

После подписи: сессии

  • Привязывайте сессию к адресу и сети. EIP-4361 гласит, что сессии «ДОЛЖНЫ быть привязаны к address, а не к ресурсам, которые определяются через него дальше и могут измениться», — например, к имени ENS или балансу токенов. Перепроверяйте такие данные, когда их используете.
  • Контрактный кошелёк может передумать. Результат ERC-1271 зависит от состояния блокчейна: владельцев могут сменить, сессионные ключи — отозвать. EIP предлагает аннулировать сессии, когда меняется состояние, на котором основана подпись; как минимум делайте сессии контрактных аккаунтов короткими.
  • Выдавайте собственные учётные данные сессии (cookie или токен) после проверки и никогда не принимайте в последующих запросах саму подпись SIWE как учётные данные на предъявителя: в пределах срока действия её по самой конструкции можно предъявить повторно, и мешает этому только nonce, который вы уже израсходовали.
  • Время истечения (Expiration Time) ограничивает сообщение, а не сессию. Оно определяет, до какого момента подпись можно предъявить; срок жизни сессии вы выбираете отдельно.

Когда SIWE — подходящий инструмент

SIWE аутентифицирует аккаунт, а не человека. Он уместен, когда удостоверение, которое важно приложению, — это и есть аккаунт Ethereum: например, приложение совершает действия с активами пользователя в блокчейне, проверяет членство по владению токенами или хочет, чтобы у пользователя было одно удостоверение во всех сервисах без адреса электронной почты. Как единственный способ входа в обычный веб-сервис он подходит плохо: для потерянного ключа нет функции «Забыли пароль?», нет гарантии, что у одного пользователя будет только один аккаунт, а в разделе о безопасности самого EIP-4361 отмечено, чем грозит конфиденциальности использование одного адреса повсюду. Для обычных учётных записей ключи доступа дают вход без кошелька, устойчивый к фишингу; для федеративных удостоверений OpenID Connect даёт проверенного издателя и субъекта. Многие приложения сочетают оба подхода: сессия по ключу доступа или OIDC — для человека, а подпись SIWE — чтобы привязать к ней адрес.

Типичные ошибки

  1. Сборка сообщения на сервере в расчёте на то, что клиент подпишет его без изменений. Проверяйте то, что было подписано, поле за полем: что отправить обратно, решает клиент.
  2. Сравнение domain с заголовком Host. За прокси-сервером используйте публичный домен из своей конфигурации.
  3. Nonce нигде не хранятся. Nonce, который никто не сверяет с множеством выданных значений, ничего не предотвращает.
  4. Проверка смарт-кошельков не в той сети. В другой сети по тому же адресу может находиться другой контракт или не быть контракта вовсе; ищите контракт в сети из Chain ID сообщения и отклоняйте сети, которые вы не обслуживаете.
  5. ecrecover во всех случаях. Такой код отклоняет пользователей смарт-кошельков или, хуже того, приписывает их вход ключу владельца.
  6. Сравнение адресов с учётом регистра. Контрольная сумма EIP-55 использует буквы разного регистра; сравнивайте адреса без учёта регистра или после нормализации.

Источники