siwe

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

README

github.com/ubgo/auth/plugin/siwe

Sign-In with Ethereum (ERC-4361). The guide is docs/plugins/siwe.md.

Install

go get github.com/ubgo/auth/plugin/siwe

A separate module: it depends on a secp256k1 library (decred's), never go-ethereum, so an app without wallets never compiles it.

What it does

  • POST /auth/siwe/nonce {walletAddress, chainId} gives a single-use, expiring nonce bound to the wallet and chain; POST /auth/siwe/verify {message, signature} checks the message (site, chain, time window, nonce) and the signature, then signs in the wallet's user or creates one.
  • POST /auth/siwe/link, GET /auth/siwe/wallets and POST /auth/siwe/unlink manage the several wallets of a signed-in user, in auth_siwe_wallet.
  • Signature checking is a Verifier; the default, PersonalSign, recovers an EIP-191 signature. A smart-contract wallet (EIP-1271, EIP-6492) needs your RPC client behind WithVerifier.
  • ENS lookup is the optional NameResolver; the module ships no RPC client and no vendor connector.
  • siwe/siwetest signs messages with a fixed test key, for tests.

Options

RequireEmail(), NoSignUp(), WithVerifier(v), WithNameResolver(r), WithNonceTTL(d), Origins(...), Chains(...). Every option refuses a nil or invalid argument by name.

Errors

siwe.message_invalid, siwe.domain_invalid, siwe.chain_invalid, siwe.message_expired, siwe.nonce_invalid (400), siwe.signature_invalid (401), siwe.address_invalid, siwe.email_required, siwe.email_not_accepted (400), siwe.sign_up_disabled (403), siwe.wallet_linked_elsewhere (409), siwe.wallet_not_found (404).

Tests

Unit tests in this module with real signatures from a fixed key, replayed nonces, a wrong domain, an expired message and a different checksum case; the flows in examples/siwe-sign-in.

Documentation

Overview

Package siwe signs users in with an Ethereum wallet: Sign-In with Ethereum, ERC-4361.

The flow is two calls. POST /siwe/nonce hands out a single-use nonce for a wallet and chain; the client builds the ERC-4361 message with it, the wallet signs it, and POST /siwe/verify checks the message (domain, URI, chain, time window, nonce) and the signature, then signs in the wallet's user or creates one. Wallets live in the plugin's own table, auth_siwe_wallet, several per user: a signed-in user adds another with POST /siwe/link.

Signature checking is a Verifier: the default recovers an EIP-191 personal_sign signature with secp256k1, which covers every externally owned account; a smart-contract wallet needs an RPC call, which the app plugs in with WithVerifier. ENS names are an optional NameResolver for the same reason: the module ships no RPC client and no vendor connector.

Index

Constants

View Source
const (
	EndpointNonce   auth.EndpointID = "siwe.nonce"
	EndpointVerify  auth.EndpointID = "siwe.verify"
	EndpointLink    auth.EndpointID = "siwe.link"
	EndpointWallets auth.EndpointID = "siwe.wallets"
	EndpointUnlink  auth.EndpointID = "siwe.unlink"
)

Operation ids, exported so a hook or a rate rule can name them. They are wire names, stable across releases.

View Source
const (
	// DefaultNonceTTL is how long a nonce works: long enough for a person to
	// read the wallet's prompt and approve it, short enough that a message
	// left unsigned is useless soon after. better-auth uses the same.
	DefaultNonceTTL = 15 * time.Minute
	// ClockSkew is how far in the future a message's Issued At may be, and
	// how far past its Expiration Time it is still accepted, for a client
	// clock a little off the server's.
	ClockSkew = time.Minute
)

Defaults and limits.

View Source
const (
	TableNameWallet  = "auth_siwe_wallet"
	TableAliasWallet = "sw"
	ColID            = "id"
	ColUserID        = "user_id"
	// ColAddress is the normalized address: "0x" and 40 lower-case hex
	// digits (NormalizeAddress), so every spelling of a wallet is one row.
	ColAddress   = "address"
	ColChainID   = "chain_id"
	ColPrimary   = "is_primary"
	ColCreatedAt = "created_at"
	ColLastUsed  = "last_used_at"
)

The plugin's table: one row per (wallet, chain), owned by one user.

View Source
const MaxMessageLength = 8 << 10

MaxMessageLength bounds the message the verify operation parses. A real sign-in message is a few hundred bytes; 8 KiB leaves room for a long statement and resource list while bounding the work an anonymous caller can ask for.

View Source
const MinNonceLength = 8

MinNonceLength is ERC-4361's lower bound on a nonce: at least eight alphanumeric characters.

View Source
const PluginName = "siwe"

PluginName is the plugin's permanent identity: its route prefix and its owner name in the migration ledger.

View Source
const PurposeNonce auth.Purpose = "siwe.nonce"

PurposeNonce is the verification purpose of a nonce. Its Identifier is "<normalized address>:<chain id>": the nonce is bound to the wallet and chain it was asked for, so a nonce issued for one wallet cannot sign in another. Its token is the nonce itself, stored hashed like every token.

View Source
const Version = "1"

Version is the only message version ERC-4361 defines.

Variables

View Source
var (
	TableWallet     = sqlb.Table{Name: TableNameWallet, Alias: TableAliasWallet}
	WalletID        = sqlb.Col[string](TableWallet, ColID)
	WalletUserID    = sqlb.Col[string](TableWallet, ColUserID)
	WalletAddress   = sqlb.Col[string](TableWallet, ColAddress)
	WalletChainID   = sqlb.Col[int64](TableWallet, ColChainID)
	WalletPrimary   = sqlb.Col[bool](TableWallet, ColPrimary)
	WalletCreatedAt = sqlb.Col[time.Time](TableWallet, ColCreatedAt)
	WalletLastUsed  = sqlb.Col[*time.Time](TableWallet, ColLastUsed)
)

Typed columns, for an app that reads the table (reads are open; only the plugin writes it).

View Source
var (
	// ErrMessageInvalid is a message that is not ERC-4361, or whose address,
	// chain or nonce do not match the request.
	ErrMessageInvalid = auth.NewError("siwe.message_invalid", http.StatusBadRequest, "The sign-in message is not valid.")
	// ErrDomainInvalid is a message whose domain, scheme or URI is not this
	// app's: a message signed for another site, which a phishing page would
	// relay.
	ErrDomainInvalid = auth.NewError("siwe.domain_invalid", http.StatusBadRequest, "The sign-in message is for another site.")
	// ErrChainInvalid is a chain this app does not accept (Chains).
	ErrChainInvalid = auth.NewError("siwe.chain_invalid", http.StatusBadRequest, "This network is not accepted.")
	// ErrMessageExpired is a message outside its time window: issued in the
	// future, older than the nonce, past its Expiration Time or before its
	// Not Before.
	ErrMessageExpired = auth.NewError("siwe.message_expired", http.StatusBadRequest, "The sign-in message has expired or is not valid yet.")
	// ErrNonceInvalid is a nonce that is unknown, spent, expired, or was
	// issued for another wallet or chain.
	ErrNonceInvalid = auth.NewError("siwe.nonce_invalid", http.StatusBadRequest, "The sign-in request has expired. Please try again.")
	// ErrSignatureInvalid is a signature that is not the message address's.
	ErrSignatureInvalid = auth.NewError("siwe.signature_invalid", http.StatusUnauthorized, "The signature is not valid.")
	// ErrAddressInvalidInput is a walletAddress that is not an address.
	ErrAddressInvalidInput = auth.NewError("siwe.address_invalid", http.StatusBadRequest, "That is not an Ethereum address.")
	// ErrEmailRequired is a first sign-in without an email under
	// RequireEmail.
	ErrEmailRequired = auth.NewError("siwe.email_required", http.StatusBadRequest, "An email address is required to create the account.")
	// ErrEmailNotAccepted is an email given to an app without the email
	// plugin: there is nowhere to keep it, and dropping it silently would
	// hide a wiring mistake.
	ErrEmailNotAccepted = auth.NewError("siwe.email_not_accepted", http.StatusBadRequest, "This app does not take an email address here.")
	// ErrSignUpDisabled is a first sign-in under NoSignUp: only a wallet a
	// signed-in user linked can sign in.
	ErrSignUpDisabled = auth.NewError("siwe.sign_up_disabled", http.StatusForbidden, "This wallet is not linked to an account.")
	// ErrWalletLinkedElsewhere is a link of a wallet another user owns.
	ErrWalletLinkedElsewhere = auth.NewError("siwe.wallet_linked_elsewhere", http.StatusConflict, "This wallet belongs to another account.")
	// ErrWalletNotFound is an unlink of a wallet the caller does not own.
	ErrWalletNotFound = auth.NewError("siwe.wallet_not_found", http.StatusNotFound, "No such wallet.")
)

Errors the operations answer. None depends on whether a wallet has an account, so the answers reveal nothing about who has signed in before.

View Source
var (
	// ErrNoOrigin is a configuration with neither auth.WithBaseURL nor
	// Origins: no message domain could ever match.
	ErrNoOrigin = errors.New("siwe: messages need auth.WithBaseURL or siwe.Origins to check their domain against")
	// ErrRequireEmailNoKind is RequireEmail without the email plugin.
	ErrRequireEmailNoKind = errors.New("siwe: RequireEmail needs the email plugin, which holds the address")
)

Configuration errors auth.New returns.

View Source
var ErrAddressInvalid = errors.New("siwe: not an Ethereum address, or its EIP-55 checksum does not match")

ErrAddressInvalid refuses a value that is not an Ethereum address: not "0x" and 40 hex digits, or mixed case that fails its EIP-55 checksum. A mixed-case address carries a checksum, and one that does not match is a typo or a tampered message, never a different spelling of the same wallet.

View Source
var ErrMessageMalformed = errors.New("siwe: not an ERC-4361 message")

ErrMessageMalformed is ParseMessage's error for text that is not an ERC-4361 message. The wrapped detail names the first part that failed.

View Source
var ErrSignatureMismatch = errors.New("siwe: the signature is not the address's")

ErrSignatureMismatch is what a Verifier returns when the signature is not the message signer's. The plugin answers it as ErrSignatureInvalid; any other error from a Verifier is an operational failure (an RPC node that did not answer) and is returned as it is, so an outage is not reported to the person as a bad signature.

Functions

func ChecksumAddress

func ChecksumAddress(normalized string) string

ChecksumAddress returns the EIP-55 mixed-case spelling of a lower-case normalized address (NormalizeAddress's output): each letter is upper case when the matching nibble of Keccak-256(lower-case hex) is 8 or more. It is what a wallet shows and what an ERC-4361 message carries. The input is assumed normalized; anything else gives an undefined spelling.

func DecodeSignature

func DecodeSignature(s string) ([]byte, error)

DecodeSignature reads a signature written as hex, with or without "0x", the form wallets return it in. ErrSignatureMismatch for anything else.

func NormalizeAddress

func NormalizeAddress(s string) (string, error)

NormalizeAddress returns the canonical stored form of an address: "0x" and 40 lower-case hex digits. It is how the plugin compares and stores addresses, so "0xAbC…" and "0xabc…" are one wallet.

It accepts an all-lower-case or all-upper-case address, which carries no checksum, and a mixed-case one only when its EIP-55 checksum matches: ERC-4361 requires the checksummed form in a message, and a mixed-case value with a wrong checksum is refused rather than silently lowered.

func PersonalSignHash

func PersonalSignHash(text string) []byte

PersonalSignHash is the EIP-191 version 0x45 digest a wallet signs for personal_sign: Keccak-256 of the prefix, the decimal byte length and the text.

func RecoverAddress

func RecoverAddress(text string, sig []byte) (string, error)

RecoverAddress returns the normalized address that signed text with personal_sign, or ErrSignatureMismatch for a signature that does not recover (a wrong length, a v that is not a recovery id, a high s).

Types

type LinkRequest

type LinkRequest struct {
	Message   string `json:"message"`
	Signature string `json:"signature"`
}

LinkRequest is a signed message for the signed-in user's new wallet.

type Message

type Message struct {
	// Scheme is the optional URI scheme before the domain ("https"); empty
	// when the message starts with the domain.
	Scheme string
	// Domain is the RFC 3986 authority asking for the sign-in: a host with an
	// optional port, such as "app.example.com" or "localhost:3000".
	Domain string
	// Address is the signing account in its EIP-55 checksummed form, as the
	// message carries it.
	Address string
	// Statement is the optional human-readable line the wallet shows; it has
	// no line break.
	Statement string
	// URI is the resource the sign-in is for, usually the page's origin.
	URI string
	// Version is always "1".
	Version string
	// ChainID is the EIP-155 chain the account is on (1 is Ethereum mainnet).
	ChainID int64
	// Nonce is the single-use value the nonce operation handed out.
	Nonce string
	// IssuedAt is when the message was made.
	IssuedAt time.Time
	// ExpirationTime and NotBefore are the optional validity window; nil
	// when the message has none.
	ExpirationTime *time.Time
	NotBefore      *time.Time
	// RequestID is an optional system-specific identifier.
	RequestID string
	// Resources are optional URIs the person agrees to, one per line.
	Resources []string
}

Message is one ERC-4361 "Sign-In with Ethereum" message. ParseMessage reads it from the text a wallet signed; String writes it back in the same form, so a client in Go (or a test) builds the exact text to sign.

func ParseMessage

func ParseMessage(text string) (Message, error)

ParseMessage reads an ERC-4361 message. It checks the grammar (the order of the lines, an address, a version of "1", a numeric chain, an alphanumeric nonce, RFC 3339 times, absolute URIs) and nothing about the deployment: whether the domain, chain and times are acceptable is the plugin's check.

func (Message) String

func (m Message) String() string

String writes the message in ERC-4361 form: the exact text a wallet signs. ParseMessage(m.String()) returns m for any message ParseMessage accepted.

type NameResolver

type NameResolver interface {
	// LookupName returns the name of the normalized address on chainID, or
	// a zero WalletName when it has none.
	LookupName(ctx context.Context, address string, chainID int64) (WalletName, error)
}

NameResolver looks up a wallet's public name, such as its ENS name and avatar, for the profile of a user the plugin creates. It is optional and the app supplies it, because resolving ENS needs an RPC endpoint and the module ships no client. A lookup that fails is logged and the user is created without the name: a name service outage must not stop a sign-in.

type NameResolverFunc

type NameResolverFunc func(ctx context.Context, address string, chainID int64) (WalletName, error)

NameResolverFunc adapts a function to NameResolver.

func (NameResolverFunc) LookupName

func (f NameResolverFunc) LookupName(ctx context.Context, address string, chainID int64) (WalletName, error)

LookupName calls f.

type NonceRequest

type NonceRequest struct {
	WalletAddress string `json:"walletAddress"`
	ChainID       int64  `json:"chainId"`
}

NonceRequest names the wallet and chain the nonce is for (better-auth's field names).

type NonceResult

type NonceResult struct {
	Nonce string `json:"nonce"`
}

NonceResult carries the nonce to put in the message.

type Option

type Option func(*Plugin)

Option configures the plugin; New applies them in order.

func Chains

func Chains(ids ...int64) Option

Chains limits sign-in to these EIP-155 chain ids (1 is Ethereum mainnet). By default any chain is accepted, since the same key controls the same address on every chain; an app whose wallets are contracts on one chain should name it. Chains() with none keeps the default.

func NoSignUp

func NoSignUp() Option

NoSignUp lets only a wallet already linked to a user sign in: a first sign-in answers ErrSignUpDisabled. Users add wallets with POST /siwe/link after signing in another way.

func Origins

func Origins(origins ...string) Option

Origins replaces the sites a message may be for. Each is an origin, "https://app.example.com": a message's URI must have one of these origins, its domain must be that origin's host and port, and its scheme, when it names one, that origin's scheme. By default the sites are the core's trusted origins (auth.Auth.TrustsOrigin: the BaseURL's origin, auth.WithTrustedOrigins and their patterns). Origins() with none keeps the default.

func RequireEmail

func RequireEmail() Option

RequireEmail makes a first sign-in carry an email address, stored unverified on the new user (better-auth's anonymous: false). Without it a wallet alone is an account. It needs the email plugin.

func WithNameResolver

func WithNameResolver(r NameResolver) Option

WithNameResolver looks up a new user's name and avatar, such as from ENS.

func WithNonceTTL

func WithNonceTTL(d time.Duration) Option

WithNonceTTL replaces DefaultNonceTTL. It must be positive.

func WithVerifier

func WithVerifier(v Verifier) Option

WithVerifier replaces PersonalSign, the EIP-191 recovery, as the check of a signature: for smart-contract wallets (EIP-1271, EIP-6492), which need the chain.

type PersonalSign

type PersonalSign struct{}

PersonalSign is the default Verifier: EIP-191 personal_sign recovery, what every externally owned account (MetaMask, a hardware wallet, a key in a script) produces. It hashes "\x19Ethereum Signed Message:\n" + the message's byte length + the message with Keccak-256, recovers the public key from the 65-byte signature, and compares the address it derives with the message's. The comparison is of public values, so it needs no constant time. A signature whose s is in the upper half of the curve order is refused (EIP-2), so one signature has one valid encoding.

It cannot verify a smart-contract wallet: those have no key to recover. Wrap it in a Verifier of your own that tries EIP-1271 through your RPC client when PersonalSign answers ErrSignatureMismatch.

func (PersonalSign) VerifySignature

func (PersonalSign) VerifySignature(_ context.Context, m SignedMessage) error

VerifySignature recovers the signer and compares it with m.Message.Address.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the Sign-In with Ethereum method. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin: personal_sign verification, any chain, the core's trusted origins, and a wallet alone may create an account.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init keeps the core for the table name and the clock.

func (p *Plugin) Link(ctx context.Context, in LinkRequest) (Wallet, error)

Link adds a signed message's wallet to the signed-in user.

func (*Plugin) Name

func (p *Plugin) Name() string

Name returns PluginName.

func (*Plugin) Nonce

func (p *Plugin) Nonce(ctx context.Context, address string, chainID int64) (string, error)

Nonce asks for a nonce for address on chainID, the typed form of POST /siwe/nonce.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists nonce, verify, link, wallets and unlink.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateLimits

func (p *Plugin) RateLimits() []auth.RateRule

RateLimits bounds the two anonymous operations per IP: nonce, which writes a row, and verify, which recovers a signature.

func (*Plugin) SignInCredentials

func (p *Plugin) SignInCredentials(ctx context.Context, userID, exceptID string) (int, error)

SignInCredentials counts userID's wallets, leaving out exceptID, for the core's last-sign-in-method check.

func (*Plugin) SignsIn

func (p *Plugin) SignsIn() bool

SignsIn reports that a wallet is a way in, so an app with SIWE alone passes the core's "someone can sign in" check, and a user's last wallet cannot be unlinked while it is their only way in.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares auth_siwe_wallet, so auth.New refuses another owner.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates auth_siwe_wallet: (address, chain_id) is unique, so one wallet on one chain belongs to one user, and user_id is indexed for the list. MySQL has no CREATE INDEX IF NOT EXISTS, so there the index is declared inside CREATE TABLE.

func (p *Plugin) Unlink(ctx context.Context, id string) error

Unlink removes one of the signed-in user's wallets.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that wallet rows belong to the user in user_id, so DeleteUser removes them.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate refuses a configuration no message could pass: no site to check the domain against, or RequireEmail without the email plugin.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, in VerifyRequest) (SignInResult, error)

Verify checks a signed message and signs its wallet's user in, the typed form of POST /siwe/verify.

func (*Plugin) Wallets

func (p *Plugin) Wallets(ctx context.Context) ([]Wallet, error)

Wallets lists the signed-in user's wallets.

type SignInResult

type SignInResult struct {
	User auth.UserView `json:"user"`
	// Wallet is the wallet that signed.
	Wallet Wallet `json:"wallet"`
	// Created is true when this sign-in created the user.
	Created              bool       `json:"created"`
	SecondFactorRequired bool       `json:"secondFactorRequired"`
	Token                auth.Token `json:"-"`
}

SignInResult is the output of verify. Token is for Go callers and bearer transports, never encoded into JSON.

type SignedMessage

type SignedMessage struct {
	// Text is the message exactly as it was signed; the signature covers
	// these bytes, never a re-serialization of Message.
	Text string
	// Message is Text parsed; Message.Address is the claimed signer and
	// Message.ChainID the chain to check it on.
	Message Message
	// Signature is the decoded signature: 65 bytes (r, s, v) for an
	// externally owned account; a smart-contract wallet's (EIP-1271, EIP-6492)
	// may be longer.
	Signature []byte
}

SignedMessage is what a Verifier checks: the exact text the wallet signed, the message parsed from it, and the signature bytes.

type UnlinkRequest

type UnlinkRequest struct {
	ID string `json:"id"`
}

UnlinkRequest names the wallet row to remove.

type Verifier

type Verifier interface {
	VerifySignature(ctx context.Context, m SignedMessage) error
}

Verifier decides whether a signature over a sign-in message is the claimed address's. It is the seam for every signing scheme the default does not cover: a smart-contract wallet (EIP-1271) or a not yet deployed one (EIP-6492) must be checked by calling the chain, which needs an RPC client this module deliberately does not ship. better-auth has the app supply verifyMessage for the same reason.

Return nil when the signature is valid, ErrSignatureMismatch (or an error wrapping it) when it is not, and any other error for a failure to decide.

type VerifierFunc

type VerifierFunc func(ctx context.Context, m SignedMessage) error

VerifierFunc adapts a function to Verifier.

func (VerifierFunc) VerifySignature

func (f VerifierFunc) VerifySignature(ctx context.Context, m SignedMessage) error

VerifySignature calls f.

type VerifyRequest

type VerifyRequest struct {
	Message       string `json:"message"`
	Signature     string `json:"signature"`
	WalletAddress string `json:"walletAddress,omitempty"`
	ChainID       int64  `json:"chainId,omitempty"`
	Email         string `json:"email,omitempty"`
}

VerifyRequest is the signed message. WalletAddress and ChainID are optional (better-auth sends them); when given they must match the message. Email is the new user's address under RequireEmail, and is ignored when the wallet already has a user: a sign-in is not a place to edit an account.

type Wallet

type Wallet struct {
	ID         string     `json:"id"`
	Address    string     `json:"address"`
	ChainID    int64      `json:"chainId"`
	Primary    bool       `json:"primary"`
	CreatedAt  time.Time  `json:"createdAt"`
	LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}

Wallet is one linked wallet on one chain. Address is the EIP-55 checksummed form, as wallets display it.

type WalletName

type WalletName struct {
	Name   string
	Avatar string
}

WalletName is a resolved name: the user's display name and image.

type WalletsResult

type WalletsResult struct {
	Wallets []Wallet `json:"wallets"`
}

WalletsResult lists the signed-in user's wallets, oldest first.

Directories

Path Synopsis
Package siwetest signs Sign-In with Ethereum messages with a fixed key, for tests and examples that need a real wallet signature without a wallet.
Package siwetest signs Sign-In with Ethereum messages with a fixed key, for tests and examples that need a real wallet signature without a wallet.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL