EIPs

EIP-4361 — Sign-In with Ethereum

eips.ethereum.org/EIPS/eip-4361

SIWE. An off-chain authentication message that binds a wallet signature to a specific domain, URI, chain, nonce, and timestamp. Used by apps that want “log in with your wallet” semantics without running an Ethereum tx.

import { build_siwe_message, generate_siwe_nonce } from "@ethernauta/eip/4361";
import { AddressSchema } from "@ethernauta/core";
import { parse } from "valibot";

const signer_address = parse(AddressSchema, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");

const message = build_siwe_message({
  domain: "example.com",
  address: signer_address,
  uri: "https://example.com",
  version: "1",
  chainId: "1",
  nonce: generate_siwe_nonce(),
  issuedAt: new Date().toISOString(),
});

void message;

Surface

ExportTypePurpose
build_siwe_message(fields) => stringCompose the canonical SIWE string.
parse_siwe_message(message: string) => SiweMessageParse a SIWE string back into fields.
is_siwe_message(message: string) => booleanPredicate.
generate_siwe_nonce() => stringCryptographically random nonce.
SiweMessagetypeParsed SIWE fields.
SiweMessageSchemaValibot schemaValidate parsed input.

Signing and verifying

Signing is just personal_sign over the SIWE string:

import { personal_sign } from "@ethernauta/eip/191";
import type { Provider } from "@ethernauta/eip/1193";
import { create_provider, encode_chain_id } from "@ethernauta/transport";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { AddressSchema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
declare const provider: Provider; // see /eips/6963 for discovery
const { signer } = create_provider(provider);

const account = parse(AddressSchema, "0x70997970C51812dc3A010C7d01b50e0d17dc79C8");
const message = "example.com wants you to sign in...";

const signature = await personal_sign([message, account])(
  signer({ chain_id: CHAIN_ID }),
);

void signature;

Verification uses the SIWE-specific verifier (it cross-checks the embedded fields):

import { verify_siwe_message } from "@ethernauta/crypto";
import { create_reader, encode_chain_id, http } from "@ethernauta/transport";
import { eip155_1 } from "@ethernauta/chain/eip155-1";
import { BytesSchema } from "@ethernauta/core";
import { parse } from "valibot";

const CHAIN_ID = encode_chain_id({ namespace: "eip155", reference: eip155_1.chainId });
const reader = create_reader([
  { chainId: CHAIN_ID, transports: [http("https://ethereum-rpc.publicnode.com")] },
]);

const message = "example.com wants you to sign in...";
const signature = parse(BytesSchema, "0x");
const expected_nonce = "abc123";

const result = await verify_siwe_message({
  message,
  signature,
  expected: { domain: "example.com", nonce: expected_nonce },
})(reader({ chain_id: CHAIN_ID }));

if (!result.ok) {
  switch (result.reason) {
    case "expired":
      break;
    case "domain_mismatch":
      break;
    default:
      break;
  }
}

VerifySiweMessageFailureReason enumerates every typed rejection mode.

See also