Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
and counterfactual, not-yet-deployed wallets ([ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) —
against a digest, an EIP-191 personal message or EIP-712 typed data with
`Ethers.Signature.verify_hash/4`, `verify_message/4` and `verify_typed_data/4`
- Add Sign-In with Ethereum ([EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)) support with
`Ethers.Siwe`: build (`new/1`), render (`to_message/1`), parse (`parse/1`) and validate
(`validate/2`) SIWE messages, generate session nonces (`generate_nonce/0`), and verify a
message and its signature end-to-end with `Ethers.Siwe.verify/3` — smart-contract wallets
included via the universal signature verification above
- Add [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data support: construct typed
structured data with `Ethers.TypedData`, hash it (`encode_type`, `type_hash`, `hash_struct`,
`domain_separator`, `hash`), sign it via `Ethers.sign_typed_data/2` with the `Ethers.Signer.Local`
Expand Down
121 changes: 121 additions & 0 deletions guides/siwe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Sign-In with Ethereum

[EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) (Sign-In with Ethereum, or SIWE) is the
standard way for a wallet to authenticate to an off-chain service. The backend issues a
single-use nonce, the wallet signs a structured plaintext message containing it, and the
backend verifies the returned message and signature to establish a session — no passwords, no
OAuth provider, the Ethereum account *is* the identity.

Ethers models the message with `Ethers.Siwe.Message` and covers the whole lifecycle in
`Ethers.Siwe`: building (`new/1`), rendering (`to_message/1`), parsing (`parse/1`), stateless
validation (`validate/2`) and full verification including the signature (`verify/3`).

## The flow

1. The client asks your backend for a nonce. The backend generates one with
`Ethers.Siwe.generate_nonce/0` and stores it in the session.
2. The client builds an EIP-4361 message containing that nonce and asks the wallet to sign it
with `personal_sign`.
3. The client posts the message and signature back. The backend calls `Ethers.Siwe.verify/3`,
which parses the message, validates its fields (validity window, expected domain and nonce)
and verifies the signature — including EIP-1271 and ERC-6492 smart-contract wallets.
4. On success, the backend trusts `message.address` and stores it in the session.

## Phoenix example

Issue the nonce:

```elixir
def nonce(conn, _params) do
nonce = Ethers.Siwe.generate_nonce()

conn
|> put_session(:siwe_nonce, nonce)
|> json(%{nonce: nonce})
end
```

Verify the callback:

```elixir
def verify(conn, %{"message" => raw_message, "signature" => signature}) do
case Ethers.Siwe.verify(raw_message, signature,
domain: "example.com",
nonce: get_session(conn, :siwe_nonce)
) do
{:ok, message} ->
conn
|> delete_session(:siwe_nonce)
|> put_session(:address, message.address)
|> json(%{ok: true, address: message.address})

{:error, reason} ->
conn
|> put_status(401)
|> json(%{error: inspect(reason)})
end
end
```

`verify/3` needs no RPC round-trip for regular (EOA) wallets. To also support
smart-contract wallets (Safe, Coinbase Smart Wallet, ERC-4337 accounts — deployed or
counterfactual), pass `rpc_opts:` so the ERC-6492 universal validator can be called on the
chain the message is bound to:

```elixir
Ethers.Siwe.verify(raw_message, signature,
domain: "example.com",
nonce: get_session(conn, :siwe_nonce),
rpc_opts: [url: "https://eth.llamarpc.com"]
)
```

## Building a message server-side

If your backend constructs the message itself (instead of a JS client library):

```elixir
message =
Ethers.Siwe.new!(
domain: "example.com",
address: user_address,
statement: "Sign in to Example",
uri: "https://example.com",
chain_id: 1,
nonce: Ethers.Siwe.generate_nonce(),
expiration_time: DateTime.add(DateTime.utc_now(), 300)
)

raw = Ethers.Siwe.to_message(message)
# => "example.com wants you to sign in with your Ethereum account:\n0x..."
```

The rendered string is what the wallet signs. `issued_at` defaults to the current time and
`version` to `"1"`.

## Validating without verifying

`Ethers.Siwe.validate/2` performs only the stateless field checks — validity window
(`expiration_time` is an exclusive bound, `not_before` inclusive), expected `domain:`,
`nonce:`, `address:` and `scheme:` — without touching the signature. This is useful in tests
(inject `time:`) or when the signature was already checked elsewhere:

```elixir
:ok =
Ethers.Siwe.validate(message,
domain: "example.com",
nonce: session_nonce,
time: DateTime.utc_now()
)
```

## Security notes

- **Always check the nonce** against the one your backend issued and invalidate it after use —
it is the replay protection.
- **Always pin the domain** to your origin. A message signed for another site must not
authenticate here.
- Set a short `expiration_time` when you build messages server-side.
- `parse/1` is strict: field order, EIP-55 checksummed addresses, RFC 3339 timestamps and
RFC 3986 domain/URIs are enforced, and a parsed message re-renders byte-for-byte identical —
so verifying the re-rendered message is safe.
Loading
Loading