Saltar al contenido
ecdsa.com
← Todos los artículos

Sign-In with Ethereum (EIP-4361): verificar en el servidor

Firmar un mensaje de texto demuestra el control de una cuenta de Ethereum; no dice nada de qué sitio, qué sesión o qué cadena, salvo que el servidor lo compruebe. Aquí tienes el formato del mensaje, un verificador en el servidor y una ejecución en una cadena local con una cuenta normal, una billetera inteligente desplegada y una billetera que aún no existe.

Respuesta breve. Sign-In with Ethereum (EIP-4361, estado Final) es un mensaje de texto plano con campos fijos que se firma con personal_sign (EIP-191). Las billeteras lo muestran y lo rechazan si su domain no coincide con la página que lo solicita. El resto le toca al servidor: emitir un nonce aleatorio y aceptarlo una sola vez, comparar domain, URI, Chain ID, Version y los campos de fecha y hora con sus propios valores, y después verificar la firma. Usa ecrecover solo para cuentas normales; las billeteras de contrato inteligente necesitan isValidSignature de ERC-1271 en la cadena del mensaje, y las billeteras que aún no están desplegadas firman con el envoltorio de ERC-6492. Vincula la sesión a la dirección, no a un nombre ENS ni a un saldo.

El mensaje

Un mensaje SIWE, creado con viem para este artículo:

Un mensaje tal como lo muestra la billetera (de la ejecución de más abajo)
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

El formato lo fija una gramática ABNF de la EIP, y cada línea tiene una función (§ Message Fields):

CampoObligatorioQué hace
scheme (antes del dominio, opcional)noEsquema del origen; HTTPS si falta
domainsíEl origen que solicita el inicio de sesión; las billeteras lo comparan con la página
addresssíLa cuenta que firma, con suma de comprobación EIP-55
statementnoUna línea legible para personas, sin saltos de línea dentro
URIsíEl recurso al que corresponde el inicio de sesión
VersionsíDebe ser 1
Chain IDsíLa cadena a la que se vincula la sesión, en la que deben resolverse las cuentas de contrato
NoncesíAleatorio, de al menos 8 caracteres alfanuméricos, contra la repetición
Issued AtsíFecha y hora de creación, en formato RFC 3339
Expiration Time, Not BeforenoPeriodo de validez del mensaje firmado
Request ID, ResourcesnoIdentificadores de la aplicación y URI a los que el usuario concede acceso

La billetera lo firma con personal_sign, es decir, con la versión 0x45 de EIP-191: keccak256 sobre "\x19Ethereum Signed Message:\n", la longitud en bytes escrita en decimal y el mensaje. Como el mensaje es texto, el usuario puede leer lo que firma, a diferencia de los hashes de 32 bytes que se tratan en EIP-712: qué firmas realmente.

Quién comprueba qué

EIP-4361 reparte el trabajo. Las billeteras deben analizar el mensaje según la ABNF, comparar scheme y domain con el origen real de la solicitud y rechazarlo si no coinciden, y mostrar domain, address, statement y resources antes de firmar. Esa es la protección contra el phishing: una página en app-example.login no puede conseguir que una billetera de navegador conforme a la especificación firme un mensaje que diga app.example.

La relying party (RP), es decir, la parte que confía en la autenticación, debe comprobar el mensaje «en cuanto a su conformidad con la ABNF», compararlo con los «valores esperados tras el análisis» y verificar la firma, con «el contrato que realiza la verificación … resuelto a partir del ID de cadena especificado» en el caso de las cuentas de contrato. Las sesiones «DEBEN estar vinculadas a la dirección». Nada de eso es automático, y una página de phishing todavía puede conseguir una firma sobre un mensaje con su propio dominio; por eso el servidor también debe comprobar el dominio.

Un verificador en el servidor

El verificador de abajo hace esas comprobaciones antes de tocar la firma y usa verifySiweMessage de viem para la parte criptográfica. Hay dos decisiones deliberadas: el nonce se consume en el primer intento, tenga éxito o no, y la cadena es aquella a la que está conectado el cliente del servidor, de modo que un mensaje para otra cadena ni siquiera llega a la comprobación de la firma.

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

Fíjate en lo que viem comprueba y lo que no. En la versión 2.57.7, verifySiweMessage compara domain, nonce y scheme solo cuando se los pasas, comprueba Expiration Time y Not Before con la hora actual y después verifica la firma. No mira Chain ID, URI ni Version, ni la antigüedad de Issued At. Si se llama solo con message y signature, acepta un mensaje firmado para cualquier dominio con cualquier nonce, como confirma esta breve prueba, junto con una segunda trampa, los finales de línea:

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]}"]`);
Salida (node:24.22.0-bookworm-slim con 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 primera línea es una firma válida sobre un mensaje para other.example, aceptada por una llamada que no indica ningún dominio. La tercera muestra por qué un mensaje SIWE debe llegar intacto, byte a byte: la ABNF usa LF entre líneas, y una plantilla, un campo de formulario o una capa HTTP que lo cambie por CRLF produce otro hash EIP-191 y una verificación fallida que parece una firma incorrecta.

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

El lado del cliente

La página que arranca un inicio de sesión hace tres cosas: obtiene un nonce del servidor, construye el mensaje con el dominio y el URI de la propia página y pide a la billetera que lo firme. A través de la interfaz del proveedor del navegador (EIP-1193), ese último paso es una solicitud personal_sign cuyos parámetros son, según la especificación de la API de MetaMask, primero el mensaje como UTF-8 codificado en hex y después la dirección, como muestra la última línea de la salida de pitfalls.mjs. Las bibliotecas, como signMessage de viem, se encargan de la codificación; el código escrito a mano que pasa primero la dirección, o la cadena sin procesar en lugar de hex, es una fuente habitual de firmas sobre bytes equivocados.

Después envía al servidor la cadena exacta del mensaje que se firmó, no los campos con los que se construyó. El servidor analiza y comprueba esa cadena; reconstruirla en el servidor a partir de campos sueltos hace que cualquier diferencia de espacios en blanco o de orden se convierta en un fallo de verificación o, peor aún, en una discrepancia entre lo que se comprobó y lo que se firmó.

La lista Resources merece un comentario. ERC-5573 («ReCaps», todavía en borrador) usa su última entrada, un URI urn:recap:, para solicitar capacidades acotadas y añade a la declaración una frase generada («I further authorize the stated URI to perform the following actions on my behalf…»). Si lo usas, el servidor debe analizar y hacer cumplir esas capacidades; un verificador SIWE básico las ignora.

Billeteras de contrato inteligente: ERC-1271 y ERC-6492

Una billetera de contrato inteligente, como una billetera Safe o una cuenta ERC-4337, no tiene clave privada propia. ecrecover sobre su firma devuelve la dirección de la clave que haya firmado (un propietario, una clave de sesión) o nada en absoluto, nunca la dirección de la billetera. ERC-1271 (estado Final) deja que decida la billetera: el verificador llama a isValidSignature(bytes32 hash, bytes signature) en la billetera, que devuelve 0x1626ba7e si la firma es válida según sus propias reglas.

Muchas billeteras inteligentes se despliegan de forma diferida, con su primera transacción, pero los usuarios quieren iniciar sesión antes. ERC-6492 (estado Final) envuelve la firma con la dirección de la fábrica (factory) y el calldata que desplegaría la billetera, seguidos del sufijo de 32 bytes 0x6492…6492. Un verificador que ve el sufijo simula el despliegue en una llamada eth_call y luego consulta a la futura billetera; la ERC exige buscar el sufijo antes de ERC-1271 y antes de ecrecover. verifySiweMessage de viem resuelve los tres casos mediante verifyHash.

Para probar los tres caminos, en el laboratorio se ejecutó una cadena local con anvil de Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5) y una billetera mínima compilada con solc 0.8.30: una clave de propietario, isValidSignature con ecrecover y una fábrica CREATE2 para que la dirección se conozca antes del despliegue.

OwnerWallet.sol (compilado con 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)))));
    }
}

El script de la prueba financia una clave de despliegue recién generada, despliega la fábrica, despliega la billetera de bob y solo predice la de carol; después inicia sesión como alice (una cuenta normal), como la billetera de bob y como la billetera no desplegada de carol, y por último prueba repeticiones y falsificaciones:

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

Lo que muestra la ejecución:

  • Los tres tipos de cuenta iniciaron sesión por el mismo camino de código. Para la billetera de bob, ecrecover devuelve la clave del propietario, una dirección distinta de la del mensaje; un servidor que la compare con address rechaza a todos los usuarios de billeteras inteligentes, y uno que acepte en su lugar al propietario inicia la sesión del usuario en la cuenta equivocada. Para la billetera no desplegada de carol, ecrecover ni siquiera puede analizar la firma envuelta.
  • La verificación ERC-6492 no despliega nada. La billetera de carol no tenía código ni antes ni después del inicio de sesión; el despliegue ocurrió solo dentro de la llamada simulada.
  • Cada falsificación falló en la comprobación que le correspondía: la repetición en el nonce, el sitio imitador en el dominio, la otra cadena en el ID de cadena, el mensaje antiguo en Issued At, y la declaración modificada y el firmante equivocado en la firma.

Después de la firma: sesiones

  • Vincula la sesión a la dirección y a la cadena. EIP-4361 dice que las sesiones «DEBEN estar vinculadas a address y no a recursos resueltos posteriormente que puedan cambiar», como un nombre ENS o un saldo de tokens. Vuelve a comprobarlos cuando los uses.
  • Las billeteras de contrato pueden cambiar de opinión. Los resultados de ERC-1271 dependen del estado de la cadena: se pueden rotar los propietarios y revocar las claves de sesión. La EIP sugiere invalidar las sesiones cuando cambia el estado que respalda una firma; como mínimo, mantén cortas las sesiones de las cuentas de contrato.
  • Emite tu propia credencial de sesión (una cookie o un token) después de la verificación, y nunca aceptes la propia firma SIWE como credencial de portador en solicitudes posteriores; por diseño, se puede repetir dentro de su periodo de validez, salvo por el nonce ya consumido.
  • La fecha de caducidad (Expiration Time) limita el mensaje, no la sesión. Acota hasta cuándo se puede presentar la firma; la duración de tu sesión es una decisión aparte.

Cuándo SIWE es la herramienta adecuada

SIWE autentica una cuenta, no a una persona. Encaja cuando la cuenta de Ethereum es la identidad que importa: una aplicación que actúa sobre los activos en cadena del usuario, comprueba una membresía condicionada a la posesión de tokens o quiere la misma identidad en varios servicios sin una dirección de correo electrónico. Encaja mal como único método de inicio de sesión de un servicio web general: si se pierde la clave, no hay «¿Olvidaste tu contraseña?», no se garantiza una sola cuenta por usuario, y las propias consideraciones de seguridad de EIP-4361 señalan lo que se pierde en privacidad al reutilizar una misma dirección en todas partes. Para las cuentas de usuario habituales, las llaves de acceso ofrecen un inicio de sesión resistente al phishing sin billetera; para la identidad federada, OpenID Connect te da un emisor y un sujeto verificados. Muchas aplicaciones los combinan: una sesión con llave de acceso o con OIDC para la persona y una firma SIWE para vincularle una dirección.

Errores frecuentes

  1. Construir el mensaje en el servidor y confiar en que el cliente lo firme sin cambios. Verifica lo que se firmó, campo por campo; el cliente decide qué te devuelve.
  2. Comparar domain con el encabezado Host. Detrás de un proxy, usa el dominio público que tengas configurado.
  3. No guardar los nonces. Un nonce que nunca se compara con el conjunto de nonces emitidos no impide nada.
  4. Verificar billeteras inteligentes en la cadena equivocada. La misma dirección puede ser otro contrato, o ningún contrato, en otra cadena; resuelve en el Chain ID del mensaje y rechaza las cadenas a las que no das servicio.
  5. Usar ecrecover para todo. Rechaza a los usuarios de billeteras inteligentes o, peor aún, atribuye su inicio de sesión a la clave del propietario.
  6. Comparar direcciones distinguiendo mayúsculas y minúsculas. Las sumas de comprobación EIP-55 mezclan mayúsculas y minúsculas; compara las direcciones sin distinguir entre mayúsculas y minúsculas, o después de normalizarlas.

Fuentes