Zum Inhalt springen
ecdsa.com
← Alle Artikel

Sign-In with Ethereum (EIP-4361): auf dem Server prüfen

Eine signierte Textnachricht beweist die Kontrolle über ein Ethereum-Konto. Über die Website, die Sitzung oder die Chain sagt sie nichts, solange der Server das nicht prüft. Hier findest du das Nachrichtenformat, einen Verifizierer für den Server und einen Lauf auf einer lokalen Chain mit einem gewöhnlichen Konto, einer bereitgestellten Smart Wallet und einer Wallet, die es noch gar nicht gibt.

Kurz gesagt. Sign-In with Ethereum (EIP-4361, Status „Final“) ist eine reine Textnachricht mit festen Feldern, signiert mit personal_sign (EIP-191). Wallets zeigen sie an und lehnen sie ab, wenn das Feld domain nicht zur anfragenden Seite passt. Den Rest muss der Server erledigen: eine zufällige Nonce ausgeben und nur einmal akzeptieren, domain, URI, Chain ID, Version und die Zeitfelder mit den eigenen Werten abgleichen und dann die Signatur prüfen. Verwende ecrecover nur für gewöhnliche Konten; Smart-Contract-Wallets brauchen isValidSignature nach ERC-1271 auf der Chain der Nachricht, und noch nicht bereitgestellte Wallets signieren mit dem Wrapper aus ERC-6492. Binde die Sitzung an die Adresse, nicht an einen ENS-Namen oder ein Guthaben.

Die Nachricht

Eine SIWE-Nachricht, für diesen Artikel mit viem erzeugt:

Eine Nachricht, wie die Wallet sie anzeigt (aus dem Lauf weiter unten)
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

Eine ABNF-Grammatik im EIP legt das Format fest, und jede Zeile hat eine Aufgabe (§ Message Fields):

FeldPflichtWozu es dient
scheme (vor der Domain, optional)neinSchema des Origins; HTTPS, wenn es fehlt
domainjaDer Origin, der die Anmeldung anfordert; Wallets gleichen ihn mit der Seite ab
addressjaDas signierende Konto, mit EIP-55-Prüfsumme
statementneinEine menschenlesbare Zeile, ohne Zeilenumbruch darin
URIjaDie Ressource, für die die Anmeldung gilt
VersionjaMuss 1 sein
Chain IDjaDie Chain, an die die Sitzung gebunden ist und auf der Contract-Konten aufgelöst werden müssen
NoncejaZufällig, mindestens 8 alphanumerische Zeichen, gegen Replays
Issued AtjaErstellungszeitpunkt nach RFC 3339
Expiration Time, Not BeforeneinGültigkeitszeitraum der signierten Nachricht
Request ID, ResourcesneinKennungen der Anwendung und URIs, auf die der Nutzer Zugriff gewährt

Die Wallet signiert die Nachricht mit personal_sign, also nach EIP-191 in der Version 0x45: keccak256 über "\x19Ethereum Signed Message:\n", die Bytelänge als Dezimalzahl und die Nachricht. Weil es sich um Text handelt, kann der Nutzer lesen, was er signiert – anders als bei den 32-Byte-Hashes, um die es in EIP-712: was du wirklich signierst geht.

Wer was prüft

EIP-4361 teilt die Arbeit auf. Wallets müssen die Nachricht gegen die ABNF parsen, scheme und domain mit dem tatsächlichen Origin der Anfrage vergleichen und eine Abweichung ablehnen sowie vor dem Signieren domain, address, statement und resources anzeigen. Darin besteht der Schutz vor Phishing: Eine Seite auf app-example.login bekommt von einer standardkonformen Browser-Wallet keine Signatur für eine Nachricht, in der app.example steht.

Die Relying Party muss die Nachricht „auf Konformität mit der ABNF“ prüfen, sie mit den „erwarteten Werten nach dem Parsen“ abgleichen und die Signatur prüfen, wobei bei Contract-Konten „der Contract, der die Prüfung durchführt, … über die angegebene chain-id aufgelöst“ wird. Sitzungen „MÜSSEN an die Adresse gebunden“ werden. Nichts davon geschieht automatisch, und eine Phishing-Seite kann trotzdem eine Signatur für eine Nachricht mit ihrer eigenen Domain einsammeln – deshalb muss auch der Server die Domain prüfen.

Ein Verifizierer auf dem Server

Der folgende Verifizierer erledigt diese Prüfungen, bevor er die Signatur überhaupt anrührt, und überlässt die Kryptografie verifySiweMessage von viem. Zwei Entscheidungen wurden bewusst getroffen: Die Nonce wird beim ersten Versuch verbraucht, ob er gelingt oder nicht, und als Chain gilt die, mit der der Client des Servers verbunden ist – eine Nachricht für eine andere Chain kommt also gar nicht erst bis zur Signaturprüfung.

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

Achte darauf, was viem prüft und was nicht. In Version 2.57.7 vergleicht verifySiweMessage die Felder domain, nonce und scheme nur, wenn du sie übergibst, gleicht Expiration Time und Not Before mit der aktuellen Zeit ab und prüft dann die Signatur. Chain ID, URI, Version und das Alter von Issued At sieht sich die Funktion nicht an. Rufst du sie nur mit message und signature auf, akzeptiert sie eine Nachricht, die für eine beliebige Domain mit einer beliebigen Nonce signiert wurde. Das bestätigt dieser kurze Test, zusammen mit einer zweiten Falle, den Zeilenenden:

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

In der ersten Zeile akzeptiert ein Aufruf, der keine Domain nennt, eine gültige Signatur für eine Nachricht an other.example. Die dritte zeigt, warum eine SIWE-Nachricht Byte für Byte unverändert ankommen muss: Die ABNF trennt die Zeilen mit LF, und ein Template, ein Formularfeld oder eine HTTP-Schicht, die daraus CRLF macht, erzeugt einen anderen EIP-191-Hash und damit eine fehlgeschlagene Prüfung, die wie eine falsche Signatur aussieht.

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

Die Clientseite

Die Seite, die eine Anmeldung startet, erledigt drei Dinge: eine Nonce vom Server holen, die Nachricht mit der eigenen Domain und URI der Seite bauen und die Wallet bitten, sie zu signieren. Über die Provider-Schnittstelle des Browsers (EIP-1193) ist dieser letzte Schritt eine Anfrage personal_sign, deren Parameter laut der API-Spezifikation von MetaMask zuerst die Nachricht als hex-kodiertes UTF-8 und danach die Adresse sind – so wie es die letzte Zeile der Ausgabe von pitfalls.mjs zeigt. Bibliotheksfunktionen wie signMessage von viem übernehmen die Kodierung; selbst geschriebener Code, der die Adresse zuerst oder den rohen String statt Hex übergibt, ist eine häufige Ursache für Signaturen über die falschen Bytes.

Schicke dem Server danach genau den String der Nachricht, der signiert wurde, nicht die Felder, aus denen er gebaut wurde. Der Server parst und prüft diesen String; baut er ihn aus einzelnen Feldern nach, kann jeder Unterschied bei Whitespace oder Reihenfolge zu einer fehlgeschlagenen Prüfung werden oder, schlimmer noch, zu einer Abweichung zwischen dem, was geprüft, und dem, was signiert wurde.

Die Liste Resources verdient ein paar Worte. ERC-5573 („ReCaps“, noch ein Entwurf) nutzt ihren letzten Eintrag, eine URI urn:recap:, um eng gefasste Berechtigungen (Capabilities) anzufordern, und ergänzt das Statement um einen generierten Satz („I further authorize the stated URI to perform the following actions on my behalf…“). Wenn du das nutzt, muss der Server diese Berechtigungen parsen und durchsetzen; ein gewöhnlicher SIWE-Verifizierer ignoriert sie.

Smart-Contract-Wallets: ERC-1271 und ERC-6492

Eine Smart-Contract-Wallet, etwa ein Safe oder ein ERC-4337-Konto, hat keinen eigenen privaten Schlüssel. ecrecover liefert für ihre Signatur die Adresse des Schlüssels, der tatsächlich signiert hat (eines Eigentümers, eines Session Keys), oder gar nichts, aber nie die Adresse der Wallet. ERC-1271 (Status „Final“) überlässt die Entscheidung der Wallet: Der Verifizierer ruft isValidSignature(bytes32 hash, bytes signature) auf der Wallet auf, und diese gibt 0x1626ba7e zurück, wenn die Signatur nach ihren eigenen Regeln gültig ist.

Viele Smart Wallets werden erst bei Bedarf bereitgestellt, mit ihrer ersten Transaktion, doch Nutzer wollen sich schon vorher anmelden. ERC-6492 (Status „Final“) verpackt die Signatur zusammen mit der Factory-Adresse und der Calldata, die die Wallet bereitstellen würde, gefolgt vom 32-Byte-Suffix 0x6492…6492. Ein Verifizierer, der das Suffix erkennt, simuliert das Deployment in einem eth_call und fragt danach die künftige Wallet; ERC-6492 verlangt, das Suffix vor ERC-1271 und vor ecrecover zu prüfen. verifySiweMessage von viem erledigt alle drei über verifyHash.

Um alle drei Wege zu testen, nutzte der Testaufbau eine lokale Chain mit anvil aus Foundry 1.8.5 (ghcr.io/foundry-rs/foundry:v1.8.5) und eine minimale, mit solc 0.8.30 kompilierte Wallet: ein Eigentümerschlüssel, isValidSignature mit ecrecover und eine CREATE2-Factory, damit die Adresse schon vor dem Deployment feststeht.

OwnerWallet.sol (kompiliert mit 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)))));
    }
}

Das Treiberskript versorgt einen frisch erzeugten Deployer-Schlüssel mit Guthaben, stellt die Factory bereit, stellt bobs Wallet bereit und berechnet die Adresse von carols Wallet nur voraus. Dann meldet es sich als alice (ein gewöhnliches Konto), mit bobs Wallet und mit carols noch nicht bereitgestellter Wallet an und versucht zum Schluss Replays und Fälschungen:

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

Was der Lauf zeigt:

  • Alle drei Kontotypen haben sich über denselben Codepfad angemeldet. Für bobs Wallet liefert ecrecover den Schlüssel des Eigentümers, also eine andere Adresse als die in der Nachricht; ein Server, der dieses Ergebnis mit address vergleicht, weist alle ab, die eine Smart Wallet nutzen, und einer, der stattdessen den Eigentümer akzeptiert, meldet den Nutzer im falschen Konto an. Bei carols noch nicht bereitgestellter Wallet kann ecrecover die verpackte Signatur nicht einmal parsen.
  • Die Prüfung nach ERC-6492 stellt nichts bereit. Carols Wallet hatte vor und nach der Anmeldung keinen Code; das Deployment fand nur innerhalb des simulierten Aufrufs statt.
  • Jede Fälschung scheiterte an der richtigen Prüfung: das Replay an der Nonce, die nachgeahmte Website an der Domain, die andere Chain an der Chain-ID, die veraltete Nachricht an Issued At, das nachträglich bearbeitete Statement und der falsche Signierer an der Signatur.

Nach der Signatur: Sitzungen

  • Binde die Sitzung an die Adresse und die Chain. EIP-4361 schreibt vor, dass Sitzungen „an die address gebunden werden MÜSSEN und nicht an weitere aufgelöste Ressourcen, die sich ändern können“, etwa einen ENS-Namen oder ein Token-Guthaben. Prüfe solche Werte erneut, wenn du sie verwendest.
  • Contract-Wallets können es sich anders überlegen. Die Ergebnisse von ERC-1271 hängen vom Zustand der Chain ab: Eigentümer lassen sich austauschen, Session Keys widerrufen. Das EIP empfiehlt, Sitzungen ungültig zu machen, wenn sich der Zustand hinter einer Signatur ändert; halte Sitzungen für Contract-Konten zumindest kurz.
  • Stelle nach der Prüfung ein eigenes Credential für die Sitzung aus (ein Cookie oder ein Token) und akzeptiere bei späteren Anfragen nie die SIWE-Signatur selbst als Bearer-Credential: Innerhalb ihres Gültigkeitszeitraums lässt sie sich konstruktionsbedingt erneut einspielen, und dagegen steht nur die Nonce, die du bereits verbraucht hast.
  • Der Ablaufzeitpunkt (Expiration Time) begrenzt die Nachricht, nicht die Sitzung. Er legt fest, bis wann die Signatur vorgelegt werden kann; die Lebensdauer deiner Sitzung ist eine eigene Entscheidung.

Wann SIWE das richtige Tool ist

SIWE authentifiziert ein Konto, keine Person. Es passt, wenn das Ethereum-Konto die Identität ist, auf die es ankommt: bei einer Anwendung, die mit den On-Chain-Assets des Nutzers arbeitet, eine Mitgliedschaft über Token-Besitz prüft (Token-Gating) oder über mehrere Dienste hinweg dieselbe Identität ohne E-Mail-Adresse haben will. Als einzige Anmeldung für einen allgemeinen Webdienst taugt es wenig: Für einen verlorenen Schlüssel gibt es kein „Passwort vergessen“, ein Konto pro Nutzer ist nicht garantiert, und die Sicherheitshinweise von EIP-4361 selbst nennen den Preis für die Privatsphäre, wenn eine Adresse überall wiederverwendet wird. Für normale Benutzerkonten bieten Passkeys eine phishingresistente Anmeldung ohne Wallet; für föderierte Identität liefert dir OpenID Connect einen geprüften Aussteller und ein geprüftes Subject. Viele Anwendungen kombinieren beides: eine Passkey- oder OIDC-Sitzung für die Person und eine SIWE-Signatur, die eine Adresse mit dieser Sitzung verknüpft.

Häufige Fehler

  1. Die Nachricht auf dem Server bauen und darauf vertrauen, dass der Client sie unverändert signiert. Prüfe Feld für Feld, was signiert wurde; was der Client zurückschickt, bestimmt er selbst.
  2. domain mit dem Header Host vergleichen. Hinter einem Proxy nimm die öffentliche Domain aus deiner Konfiguration.
  3. Nonces nicht speichern. Eine Nonce, die nie mit der Menge der ausgegebenen Nonces abgeglichen wird, verhindert nichts.
  4. Smart Wallets auf der falschen Chain prüfen. Dieselbe Adresse kann auf einer anderen Chain ein anderer Contract oder gar kein Contract sein; löse sie auf der Chain ID der Nachricht auf und lehne Chains ab, die du nicht bedienst.
  5. Überall ecrecover verwenden. Die Funktion weist Nutzer von Smart Wallets ab oder ordnet, schlimmer noch, ihre Anmeldung dem Eigentümerschlüssel zu.
  6. Adressen unter Beachtung der Groß- und Kleinschreibung vergleichen. EIP-55-Prüfsummen mischen Groß- und Kleinbuchstaben; vergleiche Adressen unabhängig von der Schreibweise oder nach einer Normalisierung.

Quellen