Aller au contenu
ecdsa.com
← Tous les articles

Sign-In with Ethereum (EIP-4361) : vérifier côté serveur

Signer un message texte prouve le contrôle d’un compte Ethereum ; cela ne dit rien du site, de la session ni de la chaîne visés, à moins que le serveur ne les vérifie. Voici le format du message, un vérificateur côté serveur, et une exécution sur une chaîne locale avec un compte ordinaire, un portefeuille intelligent déployé et un portefeuille qui n’existe pas encore.

En bref. Sign-In with Ethereum (EIP-4361, statut Final) est un message en texte brut aux champs fixes, signé avec personal_sign (EIP-191). Les portefeuilles l’affichent et le refusent si son domain ne correspond pas à la page qui le demande. Le serveur doit faire le reste : émettre un nonce aléatoire et ne l’accepter qu’une seule fois, comparer domain, URI, Chain ID, Version et les champs de date à ses propres valeurs, puis vérifier la signature. N’utilisez ecrecover que pour les comptes ordinaires ; les portefeuilles de contrat intelligent exigent l’appel ERC-1271 isValidSignature sur la chaîne du message, et les portefeuilles pas encore déployés signent avec l’enveloppe ERC-6492. Liez la session à l’adresse, pas à un nom ENS ni à un solde.

Le message

Un message SIWE, créé avec viem pour cet article :

Un message tel que le portefeuille l’affiche (tiré de l’exécution ci-dessous)
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

Le format est fixé par une grammaire ABNF dans l’EIP, et chaque ligne a un rôle (§ Message Fields) :

ChampObligatoireRôle
scheme (avant le domaine, facultatif)nonSchéma de l’origine ; HTTPS s’il est absent
domainouiL’origine qui demande la connexion ; les portefeuilles la comparent à la page
addressouiLe compte qui signe, avec la somme de contrôle EIP-55
statementnonUne ligne lisible par un humain, sans saut de ligne
URIouiLa ressource visée par la connexion
VersionouiDoit valoir 1
Chain IDouiLa chaîne à laquelle la session est liée, sur laquelle les comptes de contrat doivent être résolus
NonceouiAléatoire, au moins 8 caractères alphanumériques, contre le rejeu
Issued AtouiDate de création au format RFC 3339
Expiration Time, Not BeforenonFenêtre de validité du message signé
Request ID, ResourcesnonIdentifiant propre à l’application et URI auxquels l’utilisateur donne accès

Le portefeuille le signe avec personal_sign, c’est-à-dire EIP-191 version 0x45 : keccak256 calculé sur "\x19Ethereum Signed Message:\n", la longueur en octets écrite en décimal et le message. Comme le message est du texte, l’utilisateur peut lire ce qu’il signe, contrairement aux condensats de 32 octets dont traite EIP-712 : ce que vous signez vraiment.

Qui vérifie quoi

L’EIP-4361 répartit le travail. Les portefeuilles doivent analyser le message selon la grammaire ABNF, comparer scheme et domain à l’origine réelle de la requête et rejeter toute discordance, puis afficher domain, address, statement et resources avant de signer. C’est la protection contre l’hameçonnage : une page sur app-example.login ne peut pas obtenir d’un portefeuille de navigateur conforme la signature d’un message qui indique app.example.

La partie de confiance (relying party, RP) doit vérifier « la conformité à l’ABNF » du message, le comparer aux « valeurs attendues après analyse » et vérifier la signature, avec, pour les comptes de contrat, « le contrat effectuant la vérification […] résolu à partir du chain-id indiqué ». Les sessions « DOIVENT être liées à l’adresse ». Rien de tout cela n’est automatique, et une page d’hameçonnage peut toujours obtenir la signature d’un message portant son propre domaine : c’est pourquoi le serveur doit, lui aussi, vérifier le domaine.

Un vérificateur côté serveur

Le vérificateur ci-dessous effectue ces contrôles avant de toucher à la signature, et confie la partie cryptographique à verifySiweMessage de viem. Deux choix sont délibérés : le nonce est consommé dès la première tentative, réussie ou non, et la chaîne est celle à laquelle le client du serveur est connecté, si bien qu’un message destiné à une autre chaîne n’atteint même pas la vérification de la signature.

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

Notez ce que viem vérifie et ce qu’il ne vérifie pas. Dans la version 2.57.7, verifySiweMessage ne compare domain, nonce et scheme que si vous les lui passez, contrôle Expiration Time et Not Before par rapport à l’heure actuelle, puis vérifie la signature. Il ne regarde ni Chain ID, ni URI, ni Version, ni l’ancienneté du champ Issued At. L’appeler avec seulement message et signature revient à accepter un message signé pour n’importe quel domaine avec n’importe quel nonce, ce que confirme ce court test, en même temps qu’un second piège, les fins de ligne :

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]}"]`);
Sortie (node:24.22.0-bookworm-slim avec 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"]

La première ligne est une signature valide sur un message destiné à other.example, acceptée par un appel qui ne précise aucun domaine. La troisième montre pourquoi un message SIWE doit être transmis octet pour octet : l’ABNF utilise LF entre les lignes, et un template, un champ de formulaire ou une couche HTTP qui le remplace par CRLF produit un autre condensat EIP-191, et donc un échec de vérification qui ressemble à une mauvaise signature.

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

Le côté client

La page qui lance une connexion fait trois choses : elle récupère un nonce auprès du serveur, construit le message avec son propre domaine et son propre URI, et demande au portefeuille de le signer. Via l’interface de fournisseur du navigateur (EIP-1193), cette dernière étape est une requête personal_sign dont les paramètres sont, selon la spécification de l’API de MetaMask, d’abord le message en UTF-8 encodé en hexadécimal, puis l’adresse, comme le montre la dernière ligne de la sortie de pitfalls.mjs. Les bibliothèques, comme signMessage de viem, se chargent de l’encodage ; du code écrit à la main qui passe l’adresse en premier, ou la chaîne brute au lieu de sa forme hexadécimale, est une source courante de signatures portant sur les mauvais octets.

Envoyez ensuite au serveur la chaîne exacte du message qui a été signé, et non les champs à partir desquels il a été construit. Le serveur analyse et vérifie cette chaîne ; la reconstruire sur le serveur à partir de champs séparés, c’est laisser chaque différence d’espacement ou d’ordre devenir un échec de vérification ou, pire, un écart entre ce qui a été vérifié et ce qui a été signé.

La liste Resources mérite qu’on s’y arrête. ERC-5573 (« ReCaps », encore à l’état de brouillon) utilise sa dernière entrée, un URI urn:recap:, pour demander des capacités à portée limitée, et ajoute à la déclaration (statement) une phrase générée (« I further authorize the stated URI to perform the following actions on my behalf… »). Si vous l’utilisez, le serveur doit analyser ces capacités et les faire respecter ; un vérificateur SIWE ordinaire les ignore.

Portefeuilles de contrat intelligent : ERC-1271 et ERC-6492

Un portefeuille de contrat intelligent, comme un Safe ou un compte ERC-4337, n’a pas de clé privée propre. Appliqué à sa signature, ecrecover renvoie l’adresse de la clé qui a signé, quelle qu’elle soit (un propriétaire, une clé de session), ou rien du tout, mais jamais l’adresse du portefeuille. ERC-1271 (statut Final) laisse le portefeuille décider : le vérificateur appelle isValidSignature(bytes32 hash, bytes signature) sur le portefeuille, qui renvoie 0x1626ba7e si la signature est valide selon ses propres règles.

Beaucoup de portefeuilles intelligents ne sont déployés qu’à la demande, lors de leur première transaction, alors que les utilisateurs veulent se connecter avant. ERC-6492 (statut Final) enveloppe la signature avec l’adresse de la fabrique et les données d’appel (calldata) qui déploieraient le portefeuille, le tout suivi du suffixe de 32 octets 0x6492…6492. Un vérificateur qui repère ce suffixe simule le déploiement dans un eth_call, puis interroge le futur portefeuille ; l’ERC impose de rechercher le suffixe avant ERC-1271 et avant ecrecover. verifySiweMessage de viem fait les trois via verifyHash.

Pour tester les trois chemins, le banc d’essai a fait tourner une chaîne locale avec anvil, de Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5), et un portefeuille minimal compilé avec solc 0.8.30 : une seule clé de propriétaire, isValidSignature avec ecrecover, et une fabrique CREATE2 pour que l’adresse soit connue avant le déploiement.

OwnerWallet.sol (compilé avec 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)))));
    }
}

Le script pilote approvisionne une clé de déploiement fraîchement générée, déploie la fabrique, déploie le portefeuille de bob et se contente de prédire celui de carol, puis se connecte en tant qu’alice (un compte ordinaire), avec le portefeuille de bob et avec celui, non déployé, de carol, et termine par des rejeux et des falsifications :

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) }));
Sortie (node:24.22.0-bookworm-slim avec 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

Ce que montre l’exécution :

  • Les trois types de comptes se sont connectés par un seul et même chemin de code. Pour le portefeuille de bob, ecrecover renvoie la clé du propriétaire, une adresse différente de celle du message ; un serveur qui la compare à address rejette tous les utilisateurs de portefeuilles intelligents, et un serveur qui accepte le propriétaire à la place connecte l’utilisateur au mauvais compte. Pour le portefeuille non déployé de carol, ecrecover ne parvient même pas à analyser la signature enveloppée.
  • La vérification ERC-6492 ne déploie rien. Le portefeuille de carol n’avait de code ni avant ni après la connexion ; le déploiement n’a eu lieu que dans l’appel simulé.
  • Chaque falsification a échoué au bon contrôle : le rejeu au nonce, le site sosie au domaine, l’autre chaîne à l’ID de chaîne, le message périmé à Issued At, la déclaration modifiée et le mauvais signataire à la signature.

Après la signature : les sessions

  • Liez la session à l’adresse et à la chaîne. L’EIP-4361 indique que les sessions « DOIVENT être liées à address et non à d’autres ressources résolues ensuite, susceptibles de changer », comme un nom ENS ou un solde de jetons. Revérifiez ces dernières au moment de les utiliser.
  • Les portefeuilles de contrat peuvent changer d’avis. Les résultats d’ERC-1271 dépendent de l’état de la chaîne : les propriétaires peuvent être remplacés, les clés de session révoquées. L’EIP suggère d’invalider les sessions quand l’état sur lequel repose une signature change ; à tout le moins, gardez des sessions courtes pour les comptes de contrat.
  • Émettez votre propre identifiant de session (un cookie ou un jeton) après la vérification, et n’acceptez jamais la signature SIWE elle-même comme identifiant au porteur dans les requêtes suivantes : par conception, rien n’empêche de la rejouer pendant sa fenêtre de validité, hormis le nonce que vous avez déjà consommé.
  • La date d’expiration (Expiration Time) limite le message, pas la session. Elle borne la période pendant laquelle la signature peut être présentée ; la durée de vie de votre session est une décision distincte.

Quand SIWE est le bon outil

SIWE authentifie un compte, pas une personne. Il convient quand le compte Ethereum est l’identité qui importe : une application qui agit sur les actifs de l’utilisateur sur la chaîne, vérifie une adhésion conditionnée à la détention de jetons, ou veut la même identité d’un service à l’autre sans adresse e-mail. Il convient mal comme unique moyen de connexion d’un service web généraliste : une clé perdue n’a pas de « mot de passe oublié », rien ne garantit un seul compte par utilisateur, et les considérations de sécurité propres à l’EIP-4361 soulignent le coût, pour la vie privée, de la réutilisation d’une même adresse partout. Pour des comptes utilisateur classiques, les clés d’accès offrent une connexion résistante à l’hameçonnage sans portefeuille ; pour l’identité fédérée, OpenID Connect vous donne un émetteur et un sujet vérifiés. Beaucoup d’applications combinent les deux : une session par clé d’accès ou OIDC pour la personne, et une signature SIWE pour y lier une adresse.

Erreurs fréquentes

  1. Construire le message sur le serveur et compter sur le client pour le signer sans le modifier. Vérifiez ce qui a été signé, champ par champ : c’est le client qui choisit ce qu’il renvoie.
  2. Comparer domain à l’en-tête Host. Derrière un proxy, utilisez le domaine public que vous avez configuré.
  3. Ne pas stocker les nonces. Un nonce qui n’est jamais comparé à l’ensemble des nonces émis n’empêche rien.
  4. Vérifier les portefeuilles intelligents sur la mauvaise chaîne. La même adresse peut correspondre à un autre contrat, ou à aucun contrat, sur une autre chaîne ; résolvez-la sur le Chain ID du message et refusez les chaînes que vous ne prenez pas en charge.
  5. Utiliser ecrecover partout. Il rejette les utilisateurs de portefeuilles intelligents ou, pire, attribue leur connexion à la clé du propriétaire.
  6. Comparer les adresses en tenant compte de la casse. Les sommes de contrôle EIP-55 mélangent majuscules et minuscules ; comparez les adresses sans tenir compte de la casse, ou après normalisation.

Sources