Server-side Sign-In-With-X: challenge issuance, proof verification, and payer records.
Bundles the pieces a resource server needs to let a wallet that already
paid skip payment: it builds the challenge advertised on 402 responses
(challenge/1), verifies SIGN-IN-WITH-X proofs (verify/2), records
which address paid for which resource after settlement
(record_payment/4), and combines verification with that history
(authenticate/3). X402.Plug.PaymentGate drives it through its :siwx
option; use it directly from other frameworks.
{:ok, siwx} =
X402.Extensions.SIWX.Server.new(
domain: "api.example.com",
uri: "https://api.example.com",
supported_chains: [%{chain_id: "eip155:8453"}],
nonce_cache: {X402.Extensions.PaymentIdentifier.ETSCache, MyApp.SIWXNonces}
)
# on every 402
{:ok, challenge} = X402.Extensions.SIWX.Server.challenge(siwx)
# on a request carrying SIGN-IN-WITH-X
case X402.Extensions.SIWX.Server.authenticate(siwx, header, resource_url) do
{:ok, %{address: address}} -> serve(conn)
{:error, :not_authorized} -> respond_402(conn)
{:error, code} -> respond_402(conn, error: code)
end
# after a successful settlement
:ok = X402.Extensions.SIWX.Server.record_payment(siwx, payer, resource_url, settle_response)Nonces
Each challenge carries a fresh nonce. With a :nonce_cache (any
X402.Extensions.PaymentIdentifier.Cache adapter) issued nonces are
recorded and consumed on verification, so a proof authenticates exactly
once and only against a challenge this server issued. Without a cache —
the default — a proof is only bound by its issuedAt window
(:max_age_seconds) and can be replayed within it; configure a cache in
production.
Storage
Payer records live in an X402.Extensions.SIWX.Storage adapter keyed by
{address, resource}; X402.Extensions.SIWX.ETSStorage (its default
process) is used unless :storage is set. Pass {module, server} to
address a specific storage process; the module must then expose the
server-taking get/3, put/5, and delete/3 like ETSStorage does.
EVM addresses are lowercased for storage and lookup; case-sensitive
addresses such as Solana public keys are left unchanged. Verified
identities retain the address spelling from the proof.
Summary
Types
A verified identity together with the payment record that authorizes it.
A storage module, or a module paired with the server it should address.
Functions
Verifies a proof and checks that its address has a payment record for
resource.
Looks up the payment record for an address and resource.
Builds a fresh challenge and, with a :nonce_cache, records its nonce as
issued.
Validates server options.
Validates server options, raising NimbleOptions.ValidationError on
failure.
Records that address paid for resource, granting access for :ttl_ms.
Records a challenge nonce as issued in the :nonce_cache.
Revokes the payment record for an address and resource.
Verifies a SIGN-IN-WITH-X proof against the server configuration.
Types
@type session() :: %{ address: String.t(), chain_id: String.t(), fields: X402.Extensions.SIWX.fields(), access: X402.Extensions.SIWX.Storage.access_record() }
A verified identity together with the payment record that authorizes it.
A storage module, or a module paired with the server it should address.
@type t() :: %{ domain: String.t(), uri: String.t(), supported_chains: [map()], statement: String.t() | nil, resources: [String.t()], expiration_seconds: pos_integer(), max_age_seconds: pos_integer(), clock_skew_seconds: non_neg_integer(), nonce_cache: X402.Extensions.PaymentIdentifier.Cache.adapter() | nil, storage: storage(), ttl_ms: pos_integer(), verifier: module(), ed25519_verifier: module() }
Validated server configuration, built by new/1.
Functions
@spec authenticate(t(), X402.Extensions.SIWX.decoded() | String.t(), String.t()) :: {:ok, session()} | {:error, X402.Extensions.SIWX.verify_error() | :not_authorized}
Verifies a proof and checks that its address has a payment record for
resource.
Returns the identity with the access record under :access,
{:error, :not_authorized} when the proof is valid but no record exists
(respond with a fresh challenge and payment requirements), or the
verification error otherwise.
@spec authorized(t(), String.t(), String.t()) :: {:ok, X402.Extensions.SIWX.Storage.access_record()} | {:error, :not_authorized}
Looks up the payment record for an address and resource.
Builds a fresh challenge and, with a :nonce_cache, records its nonce as
issued.
Returns the map to advertise under PaymentRequired.extensions["sign-in-with-x"].
Fails only when the nonce could not be recorded; a challenge whose nonce
is unknown to the server would never verify, so it is better not to
advertise one.
Examples
iex> siwx = X402.Extensions.SIWX.Server.new!(
...> domain: "api.example.com",
...> uri: "https://api.example.com",
...> supported_chains: [%{chain_id: "eip155:8453"}]
...> )
iex> {:ok, challenge} = X402.Extensions.SIWX.Server.challenge(siwx)
iex> {challenge["info"]["domain"], challenge["supportedChains"]}
{"api.example.com", [%{"chainId" => "eip155:8453", "type" => "eip191"}]}
@spec new(keyword()) :: {:ok, t()} | {:error, NimbleOptions.ValidationError.t()}
Validates server options.
Options
:domain(String.t/0) - Required. Public host advertised in the challenge and required in proofs.:uri(String.t/0) - Required. Public URI advertised in the challenge and required in proofs.:supported_chains- Required. Accepted chains, in the formX402.Extensions.SIWX.challenge/1takes.:statement- Human-readable statement included in the challenge. The default value isnil.:resources(list ofString.t/0) - Resource URIs included in the challenge. The default value is[].:expiration_seconds(pos_integer/0) - Lifetime of an advertised challenge (expirationTime - issuedAt). The default value is300.:max_age_seconds(pos_integer/0) - Maximum age of a proof'sissuedAt. The default value is300.:clock_skew_seconds(non_neg_integer/0) - Tolerance for a proof'sissuedAtslightly in the future. The default value is60.:nonce_cache-X402.Extensions.PaymentIdentifier.Cacheadapter tuple (or anETSCacheserver name) that records issued nonces and consumes them on verification. Strongly recommended: without it proofs can be replayed within:max_age_seconds. The default value isnil.:storage-X402.Extensions.SIWX.Storagemodule, or{module, server}to address a specific storage process. The default value isX402.Extensions.SIWX.ETSStorage.:ttl_ms(pos_integer/0) - How long a recorded payment grants access, in milliseconds. The default value is86400000.:verifier-X402.Extensions.SIWX.Verifierforeip155:*proofs. The default value isX402.Extensions.SIWX.Verifier.Default.:ed25519_verifier-X402.Extensions.SIWX.Verifierforsolana:*proofs. The default value isX402.Extensions.SIWX.Verifier.Ed25519.
Examples
iex> {:ok, siwx} = X402.Extensions.SIWX.Server.new(
...> domain: "api.example.com",
...> uri: "https://api.example.com",
...> supported_chains: [%{chain_id: "eip155:8453"}]
...> )
iex> {siwx.storage, siwx.nonce_cache, siwx.ttl_ms}
{X402.Extensions.SIWX.ETSStorage, nil, 86_400_000}
iex> {:error, %NimbleOptions.ValidationError{}} =
...> X402.Extensions.SIWX.Server.new(domain: "api.example.com")
Validates server options, raising NimbleOptions.ValidationError on
failure.
Records that address paid for resource, granting access for :ttl_ms.
payment_proof is stored alongside the record (the facilitator's settle
response, typically) and returned by authenticate/3 under
access.payment_proof.
Records a challenge nonce as issued in the :nonce_cache.
A no-op (:ok) without a cache. challenge/1 calls this for you; use it
when advertising a challenge built some other way.
Revokes the payment record for an address and resource.
@spec verify(t(), X402.Extensions.SIWX.decoded() | String.t()) :: {:ok, X402.Extensions.SIWX.identity()} | {:error, X402.Extensions.SIWX.verify_error()}
Verifies a SIGN-IN-WITH-X proof against the server configuration.
Accepts the raw header value or the tuple X402.Extensions.SIWX.decode_signed/1
returns; see X402.Extensions.SIWX.verify/2 for the checks and error
codes. With a :nonce_cache the nonce is consumed on success.