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:
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):
| Feld | Pflicht | Wozu es dient |
|---|---|---|
scheme (vor der Domain, optional) | nein | Schema des Origins; HTTPS, wenn es fehlt |
domain | ja | Der Origin, der die Anmeldung anfordert; Wallets gleichen ihn mit der Seite ab |
address | ja | Das signierende Konto, mit EIP-55-Prüfsumme |
statement | nein | Eine menschenlesbare Zeile, ohne Zeilenumbruch darin |
URI | ja | Die Ressource, für die die Anmeldung gilt |
Version | ja | Muss 1 sein |
Chain ID | ja | Die Chain, an die die Sitzung gebunden ist und auf der Contract-Konten aufgelöst werden müssen |
Nonce | ja | Zufällig, mindestens 8 alphanumerische Zeichen, gegen Replays |
Issued At | ja | Erstellungszeitpunkt nach RFC 3339 |
Expiration Time, Not Before | nein | Gültigkeitszeitraum der signierten Nachricht |
Request ID, Resources | nein | Kennungen 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.
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:
// 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]}"]`);
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.
// 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("")}`);
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.
// 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:
// 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) }));
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
ecrecoverden Schlüssel des Eigentümers, also eine andere Adresse als die in der Nachricht; ein Server, der dieses Ergebnis mitaddressvergleicht, 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 kannecrecoverdie 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
addressgebunden 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
- 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.
domainmit dem HeaderHostvergleichen. Hinter einem Proxy nimm die öffentliche Domain aus deiner Konfiguration.- Nonces nicht speichern. Eine Nonce, die nie mit der Menge der ausgegebenen Nonces abgeglichen wird, verhindert nichts.
- 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 IDder Nachricht auf und lehne Chains ab, die du nicht bedienst. - Überall
ecrecoververwenden. Die Funktion weist Nutzer von Smart Wallets ab oder ordnet, schlimmer noch, ihre Anmeldung dem Eigentümerschlüssel zu. - 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
- EIP-4361: Sign-In with Ethereum: Nachrichtenfelder, ABNF, Schritte für Relying Party und Wallet, Sicherheitshinweise
- EIP-191: Signed Data Standard
- ERC-1271: Standard Signature Validation Method for Contracts
- ERC-6492: Signature Validation for Predeploy Contracts
- ERC-5573: Sign-In with Ethereum Capabilities, ReCaps (Entwurf)
- API-Spezifikation von MetaMask: Parameter von
personal_sign - viem SIWE:
createSiweMessage,parseSiweMessage,verifySiweMessage - Foundry: anvil und forge