Skip to main content

Rust

The Rust implementation of Sign in with Ethereum can be found here:

signinwithethereum/siwe-rs

Getting Started

signinwithethereum on crates.io

signinwithethereum on crates.io

Installation

Add the crate to your Cargo.toml:

[dependencies]
signinwithethereum = "0.7"

Features

Enable optional features as needed:

FeatureDescription
serdeSerialization/deserialization via serde
alloyEIP-1271 contract wallet and EIP-6492 counterfactual wallet signature verification
typed-builderBuilder pattern for VerificationOpts
[dependencies]
signinwithethereum = { version = "0.7", features = ["serde", "alloy"] }

API Reference

Message Struct

The core type representing an EIP-4361 message. Implements FromStr for parsing and Display for serialization.

Fields

FieldTypeRequiredDescription
schemeOption<String>URI scheme of the request origin (e.g. "https")
domainAuthorityRFC 3986 authority requesting the signing
address[u8; 20]Ethereum address (raw bytes, validated as EIP-55)
statementOption<String>Human-readable ASCII assertion
uriUriStringRFC 3986 URI referring to the resource
versionVersionMust be V1 for EIP-4361
chain_idu64EIP-155 Chain ID
nonceStringAlphanumeric token, minimum 8 characters
issued_atTimeStampRFC 3339 timestamp
expiration_timeOption<TimeStamp>When the message expires
not_beforeOption<TimeStamp>When the message becomes valid
request_idOption<String>System-specific identifier
resourcesVec<UriString>List of resource URIs

Parsing

Parse an EIP-4361 formatted string into a Message via FromStr:

use signinwithethereum::Message;

let msg = r#"example.com wants you to sign in with your Ethereum account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

Sign in to Example

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z"#;

let message: Message = msg.parse().unwrap();

The parser validates:

  • EIP-55 checksummed address
  • Alphanumeric nonce (minimum 8 characters)
  • RFC 3339 timestamps
  • RFC 3986 URI and domain
  • Optional scheme:// prefix per EIP-4361
  • Printable ASCII statement (no control characters)

Serialization

Message implements Display, producing the EIP-4361 string representation:

use signinwithethereum::Message;

let msg = "example.com wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://example.com\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24Z";
let message: Message = msg.parse().unwrap();
let formatted = message.to_string();

With the serde feature, Message serializes as its EIP-4361 string:

use signinwithethereum::Message;

let message: Message = msg_str.parse().unwrap();
let json = serde_json::to_string(&message).unwrap();
let deserialized: Message = serde_json::from_str(&json).unwrap();

Methods

verify_eip191(&self, sig: &[u8; 65]) -> Result<Vec<u8>, VerificationError>

Verifies an EIP-191 signature against the message's address field. Returns the recovered public key on success.

use signinwithethereum::Message;
use hex::FromHex;

let message: Message = msg_str.parse().unwrap();
let signature = <[u8; 65]>::from_hex("6228b3ec...051c").unwrap();

let public_key: Vec<u8> = message.verify_eip191(&signature).unwrap();
async verify(&self, sig: &[u8], opts: &VerificationOpts) -> Result<(), VerificationError>

Full verification: time constraints, field binding checks, and signature authentication. Verification order follows EIP-6492:

  1. EIP-6492 — if the signature has the magic suffix, verify via the universal off-chain validator
  2. EOA — try standard ecrecover for 65-byte signatures
  3. EIP-1271 — fall back to on-chain isValidSignature if EOA verification fails (requires alloy feature)
use hex::FromHex;
use signinwithethereum::{Message, VerificationOpts};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};

#[tokio::main]
async fn main() {
let msg = r#"localhost:4361 wants you to sign in with your Ethereum account:
0x6Da01670d8fc844e736095918bbE11fE8D564163

SIWE Notepad Example

URI: http://localhost:4361
Version: 1
Chain ID: 1
Nonce: kEWepMt9knR6lWJ6A
Issued At: 2021-12-07T18:28:18.807Z"#;

let message: Message = msg.parse().unwrap();
let signature = <[u8; 65]>::from_hex(
"6228b3ecd7bf2df018183aeab6b6f1db1e9f4e3cbe24560404112e25363540eb\
679934908143224d746bbb5e1aa65ab435684081f4dbb74a0fec57f98f40f5051c"
).unwrap();

let opts = VerificationOpts {
domain: Some("localhost:4361".parse().unwrap()),
nonce: Some("kEWepMt9knR6lWJ6A".into()),
timestamp: Some(
OffsetDateTime::parse("2021-12-08T00:00:00Z", &Rfc3339).unwrap()
),
..Default::default()
};

message.verify(&signature, &opts).await.unwrap();
}
valid_now(&self) -> bool

Checks whether the message is valid at the current time (not expired, past not_before).

valid_at(&self, t: &OffsetDateTime) -> bool

Checks whether the message is valid at a specific point in time. Uses the half-open interval [not_before, expiration_time).

eip191_bytes(&self) -> Result<Vec<u8>, fmt::Error>

Returns the EIP-191 Personal-Signature pre-hash signing input bytes.

eip191_hash(&self) -> Result<[u8; 32], fmt::Error>

Returns the Keccak-256 hash of the EIP-191 signing input.

VerificationOpts Struct

Configuration for Message::verify(). All fields are optional — only set the fields you want to validate against.

FieldTypeDescription
domainOption<Authority>Expected domain (origin binding)
nonceOption<String>Expected nonce (replay resistance)
timestampOption<OffsetDateTime>Time to check against (defaults to now)
uriOption<UriString>Expected URI
chain_idOption<u64>Expected chain ID
schemeOption<String>Expected scheme
rpc_urlOption<String>RPC URL for EIP-1271/EIP-6492 checks (requires alloy feature)
note

The rpc_url field is only available when the alloy feature is enabled.

With the typed-builder feature, you can use the builder pattern:

use signinwithethereum::VerificationOpts;

let opts = VerificationOpts::builder()
.domain(Some("example.com".parse().unwrap()))
.nonce(Some("abc12345".into()))
.build();

generate_nonce() -> String

Generates a cryptographically secure random nonce (17 alphanumeric characters).

use signinwithethereum::generate_nonce;

let nonce = generate_nonce();

Error Types

ParseError

Errors raised during message parsing:

VariantDescription
DomainDomain field is non-conformant
FormatGeneral formatting error
AddressAddress field is non-conformant
UriURI field is non-conformant
TimeStampTimestamp is non-conformant
ParseIntErrorChain ID is non-conformant

VerificationError

Errors raised during signature verification:

VariantDescription
CryptoInvalid k256 signature
SerializationMessage serialization failed
SignerRecovered address does not match
TimeMessage expired or not yet valid
DomainMismatchDomain does not match expected value
NonceMismatchNonce does not match expected value
UriMismatchURI does not match expected value
ChainIdMismatchChain ID does not match expected value
SchemeMismatchScheme does not match expected value
ContractCallContract wallet verification failed (requires alloy)
RpcRequiredEIP-6492 signature detected but no RPC URL configured
SignatureLengthSignature is not 65 bytes and alloy feature is not enabled

Smart Contract Wallets (EIP-1271 / EIP-6492)

With the alloy feature enabled, verify() supports signatures from:

  • EIP-1271 — deployed contract wallets (e.g. Safe, Argent)
  • EIP-6492 — counterfactual (not yet deployed) contract wallets

Provide an rpc_url in the verification options:

use signinwithethereum::{Message, VerificationOpts};

let opts = VerificationOpts {
domain: Some("example.com".parse().unwrap()),
nonce: Some(stored_nonce),
rpc_url: Some("https://eth.llamarpc.com".into()),
..Default::default()
};

message.verify(&signature, &opts).await.unwrap();

Backend Integration

Axum Example

use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
use signinwithethereum::{generate_nonce, Message, VerificationOpts};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;

type NonceStore = Arc<Mutex<HashMap<String, ()>>>;

#[tokio::main]
async fn main() {
let nonces: NonceStore = Arc::new(Mutex::new(HashMap::new()));

let app = Router::new()
.route("/api/nonce", get(get_nonce))
.route("/api/verify", post(verify))
.with_state(nonces);

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

async fn get_nonce(State(nonces): State<NonceStore>) -> Json<NonceResponse> {
let nonce = generate_nonce();
nonces.lock().await.insert(nonce.clone(), ());
Json(NonceResponse { nonce })
}

async fn verify(
State(nonces): State<NonceStore>,
Json(body): Json<VerifyRequest>,
) -> Result<Json<VerifyResponse>, StatusCode> {
let message: Message = body.message.parse()
.map_err(|_| StatusCode::BAD_REQUEST)?;

// Consume nonce (one-time use)
if nonces.lock().await.remove(&message.nonce).is_none() {
return Err(StatusCode::BAD_REQUEST);
}

let signature = hex::decode(
body.signature.strip_prefix("0x").unwrap_or(&body.signature)
).map_err(|_| StatusCode::BAD_REQUEST)?;

let opts = VerificationOpts {
domain: Some("localhost:3000".parse().unwrap()),
nonce: Some(message.nonce.clone()),
..Default::default()
};

message.verify(&signature, &opts).await
.map_err(|_| StatusCode::UNAUTHORIZED)?;

Ok(Json(VerifyResponse {
address: signinwithethereum::eip55(&message.address),
chain_id: message.chain_id,
}))
}

#[derive(Serialize)]
struct NonceResponse { nonce: String }

#[derive(Deserialize)]
struct VerifyRequest { message: String, signature: String }

#[derive(Serialize)]
struct VerifyResponse { address: String, chain_id: u64 }

Migrating from siwe crate

This crate is the actively maintained successor to the siwe crate (v0.6), which is no longer maintained.

Cargo.toml

- siwe = "0.6"
+ signinwithethereum = "0.7"

Code changes

Rename the import:

- use siwe::{Message, VerificationOpts};
+ use signinwithethereum::{Message, VerificationOpts};

If you used the ethers feature for EIP-1271 contract wallet verification, switch to alloy:

- siwe = { version = "0.6", features = ["ethers"] }
+ signinwithethereum = { version = "0.7", features = ["alloy"] }

And replace the provider in VerificationOpts:

  let opts = VerificationOpts {
- rpc_provider: Some("https://eth.llamarpc.com".try_into().unwrap()),
+ rpc_url: Some("https://eth.llamarpc.com".into()),
..Default::default()
};

The Message struct now has a scheme: Option<String> field. If you construct Message values directly (rather than parsing), add it:

  let msg = Message {
+ scheme: None,
domain: "example.com".parse().unwrap(),
// ...
};

See the CHANGELOG for the full list of breaking changes.

Changelog

See the full CHANGELOG for version history.

v0.7.0 Highlights

  • alloy replaces ethers — The deprecated ethers dependency has been replaced with alloy v1. Pass an RPC URL string instead of a provider.
  • EIP-6492 support — Verify signatures from counterfactual (undeployed) smart contract wallets.
  • Scheme parsing — Messages with optional scheme:// prefixes are now parsed correctly.
  • Stricter validation — Nonces must be alphanumeric, statements must be printable ASCII.
  • Fixed temporal boundariesvalid_at() now uses [not_before, expiration_time).

Testing

cargo test

To run tests that require on-chain verification (EIP-1271 / EIP-6492), enable the alloy feature and provide an Ethereum mainnet RPC URL:

ETH_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY" cargo test --features alloy

Resources