X402.Extensions.SIWX (X402 v0.9.0)

Copy Markdown View Source

The x402 sign-in-with-x extension: CAIP-122 wallet authentication.

Implements the sign-in-with-x extension. A server advertises a challenge under PaymentRequired.extensions (challenge/1); a client proves control of a wallet by signing the CAIP-122 message the challenge describes (sign/3) and sends the proof Base64-encoded in the SIGN-IN-WITH-X header (encode_signed/1); the server decodes (decode_signed/1) and verifies it (verify/2), then decides — from its own payment history — whether the address may skip payment. X402.Plug.PaymentGate wires all of this up through its :siwx option.

# server (PaymentRequired.extensions)
%{"sign-in-with-x" => X402.Extensions.SIWX.challenge(
    domain: "api.example.com",
    uri: "https://api.example.com",
    supported_chains: [%{chain_id: "eip155:8453"}]
  )}

# client
{:ok, signed} = X402.Extensions.SIWX.sign(challenge, signer, chain_id: "eip155:8453")
{:ok, header} = X402.Extensions.SIWX.encode_signed(signed)

# server
{:ok, decoded} = X402.Extensions.SIWX.decode_signed(header)
{:ok, %{address: address}} =
  X402.Extensions.SIWX.verify(decoded,
    domain: "api.example.com",
    uri: "https://api.example.com",
    supported_chains: [%{chain_id: "eip155:8453"}]
  )

Supported chains are eip155:* (EIP-4361 text, EIP-191 personal_sign, verified by X402.Extensions.SIWX.Verifier.Default) and solana:* (Sign-In With Solana text, Ed25519, verified by X402.Extensions.SIWX.Verifier.Ed25519). Message construction lives in X402.Extensions.SIWX.Message, challenge construction in X402.Extensions.SIWX.Challenge.

Legacy formats (deprecated)

Releases before 0.7.0 sent the SIGN-IN-WITH-X header as a Base64 JSON {"message", "signature"} object carrying the signed EIP-4361 text itself. decode_signed/1 still understands that shape — reporting it as {:legacy, proof} — and verify/2 applies the same rules to it. encode_header/1 and decode_header/1 produce and consume it directly, are deprecated, and will be removed in 1.0.0; encode/1 and decode/1 remain as the EIP-4361 text codec they always were. Servers emit a [:x402, :siwx, :legacy] telemetry event (and a one-time warning log) when they receive the legacy format.

Summary

Header Encoding

Decodes a legacy SIGN-IN-WITH-X header into its message and signature.

Decodes a SIGN-IN-WITH-X header value.

Encodes a legacy SIWX header payload with message and signature fields.

Encodes signed proof fields as a SIGN-IN-WITH-X header value.

Returns the canonical SIWX header name.

Payment Verification

Verifies a decoded (or raw) SIGN-IN-WITH-X proof against the server's configuration.

Types

A decoded SIGN-IN-WITH-X header, tagged with its wire format.

Spec-format proof fields, keyed by their wire (camelCase) names.

The wallet identity a verified proof establishes.

Where a legacy-format proof was observed.

Legacy EIP-4361 message payload fields.

Errors returned by sign/3.

Machine-readable verification failure codes from the spec.

Errors returned by verify/2.

Functions

Builds the server-side challenge advertised under PaymentRequired.extensions["sign-in-with-x"].

Decodes an EIP-4361 SIWX message into payload fields.

Encodes a SIWX payload into an EIP-4361 message.

Returns the extension key on the wire.

Generates a challenge nonce: 32 lowercase hex characters from 16 random bytes.

Builds the CAIP-122 message text a wallet signs for a fields map.

Returns the JSON schema of a proof, advertised under schema.

Signs a challenge with an X402.Signer, producing the proof fields.

Validates and normalizes spec-format proof fields.

Header Encoding

decode_header(value)

(since 0.3.0)
This function is deprecated. Use decode_signed/1, which also understands this format; removed in 1.0.0.
@spec decode_header(String.t()) :: {:ok, map()} | {:error, header_decode_error()}

Decodes a legacy SIGN-IN-WITH-X header into its message and signature.

{:ok, encoded} = X402.Extensions.SIWX.encode_header(%{message: "hello", signature: "0xabc"})
X402.Extensions.SIWX.decode_header(encoded)
#=> {:ok, %{"message" => "hello", "signature" => "0xabc"}}

X402.Extensions.SIWX.decode_header("%%")
#=> {:error, :invalid_base64}

decode_signed(value)

(since 0.9.0)
@spec decode_signed(String.t()) :: {:ok, decoded()} | {:error, signed_decode_error()}

Decodes a SIGN-IN-WITH-X header value.

Returns {:ok, {:spec, fields}} for the spec format (a JSON object with the CAIP-122 fields and a signature) and {:ok, {:legacy, %{"message" => ..., "signature" => ...}}} for the deprecated pre-0.7.0 format, whose message must parse as a legacy EIP-4361 text (decode/1). Values above 8 KB are rejected with {:error, :payload_too_large} before decoding.

Examples

iex> X402.Extensions.SIWX.decode_signed("%%")
{:error, :invalid_base64}

iex> X402.Extensions.SIWX.decode_signed(Base.encode64("{"))
{:error, :invalid_json}

iex> X402.Extensions.SIWX.decode_signed(Base.encode64(~s({"domain":"api.example.com"})))
{:error, :invalid_payload}

encode_header(payload)

(since 0.3.0)
This function is deprecated. Use the spec proof format (sign/3, encode_signed/1); removed in 1.0.0.
@spec encode_header(map()) :: {:ok, String.t()} | {:error, header_encode_error()}

Encodes a legacy SIWX header payload with message and signature fields.

{:ok, header} = X402.Extensions.SIWX.encode_header(%{message: "hello", signature: "0xabc"})
{:ok, decoded} = X402.Extensions.SIWX.decode_header(header)
decoded["message"]
#=> "hello"

encode_signed(fields)

(since 0.9.0)
@spec encode_signed(map()) :: {:ok, String.t()} | {:error, signed_encode_error()}

Encodes signed proof fields as a SIGN-IN-WITH-X header value.

The fields must be a complete proof (validate_fields/1 plus a signature), keyed by wire names or their snake_case atoms.

Examples

iex> {:ok, header} = X402.Extensions.SIWX.encode_signed(%{
...>   "domain" => "api.example.com",
...>   "address" => "0x857b06519E91e3A54538791bDbb0E22373e36b66",
...>   "uri" => "https://api.example.com",
...>   "version" => "1",
...>   "chainId" => "eip155:8453",
...>   "type" => "eip191",
...>   "nonce" => "a1b2c3d4e5f67890a1b2c3d4e5f67890",
...>   "issuedAt" => "2024-01-15T10:30:00.000Z",
...>   "signature" => "0xabc"
...> })
iex> {:ok, {:spec, fields}} = X402.Extensions.SIWX.decode_signed(header)
iex> fields["chainId"]
"eip155:8453"

iex> X402.Extensions.SIWX.encode_signed(%{"domain" => "api.example.com"})
{:error, :invalid_payload}

header_name()

(since 0.3.0)
@spec header_name() :: String.t()

Returns the canonical SIWX header name.

Examples

iex> X402.Extensions.SIWX.header_name()
"SIGN-IN-WITH-X"

Payment Verification

verify(header, opts)

(since 0.9.0)
@spec verify(
  decoded() | String.t(),
  keyword()
) :: {:ok, identity()} | {:error, verify_error()}

Verifies a decoded (or raw) SIGN-IN-WITH-X proof against the server's configuration.

Accepts the tuple decode_signed/1 returns or the raw header value (which is decoded first; decode errors are returned as they are). The checks run in the spec's order and each failure carries one of the spec's machine-readable codes:

CodeFailed check
:invalid_siwx_domain_mismatchdomain differs from :domain
:invalid_siwx_uri_mismatchuri differs from :uri (trailing slash ignored)
:invalid_siwx_issued_atissuedAt is not ISO 8601
:invalid_siwx_issued_at_too_oldissuedAt is older than :max_age_seconds
:invalid_siwx_issued_at_in_futureissuedAt is more than :clock_skew_seconds ahead of :now
:invalid_siwx_expiration_timeexpirationTime is not ISO 8601
:invalid_siwx_expiredexpirationTime is not after :now
:invalid_siwx_not_beforenotBefore is not ISO 8601
:invalid_siwx_not_yet_validnotBefore is after :now
:invalid_siwx_noncenonce is not 32 lowercase hex characters, or — with :nonce_cache — was not issued or was already used
:invalid_siwx_chain_idchainId has a malformed reference
:invalid_siwx_unsupported_chainchainId/type is not in :supported_chains
:invalid_siwx_malformed_signaturethe address or signature encoding/length is invalid
:invalid_siwx_signaturethe signature does not verify for address
:invalid_siwx_verifier_errorthe verifier failed or raised

:domain and :uri must be the server's configured public origin — never values derived from the request's Host header, which the caller controls. With :nonce_cache, a nonce must have been recorded as issued (X402.Extensions.SIWX.Server.remember_nonce/2) and is atomically marked used after the signature verifies, so a proof authenticates at most once. Without it a nonce is only checked for format and the time window.

Legacy {:legacy, proof} values are parsed with decode/1 and verified by the same rules over the exact text that was signed (type is "eip191").

Options

Examples

iex> X402.Extensions.SIWX.verify("%%", domain: "api.example.com", uri: "https://api.example.com", supported_chains: [%{chain_id: "eip155:8453"}])
{:error, :invalid_base64}

Types

decode_error()

@type decode_error() :: :invalid_message | {:invalid_field, atom()}

decoded()

@type decoded() ::
  {:spec, fields()} | {:legacy, %{required(String.t()) => String.t()}}

A decoded SIGN-IN-WITH-X header, tagged with its wire format.

encode_error()

@type encode_error() ::
  :invalid_payload | {:missing_fields, [atom()]} | {:invalid_field, atom()}

fields()

@type fields() :: %{optional(String.t()) => String.t() | [String.t()]}

Spec-format proof fields, keyed by their wire (camelCase) names.

header_decode_error()

@type header_decode_error() :: :invalid_base64 | :invalid_json | :invalid_payload

header_encode_error()

@type header_encode_error() :: :invalid_payload | :invalid_json

identity()

@type identity() :: %{address: String.t(), chain_id: String.t(), fields: fields()}

The wallet identity a verified proof establishes.

legacy_source()

@type legacy_source() :: :gate

Where a legacy-format proof was observed.

message_payload()

@type message_payload() :: %{
  domain: String.t(),
  address: String.t(),
  statement: String.t(),
  uri: String.t(),
  version: String.t(),
  chain_id: String.t(),
  nonce: String.t(),
  issued_at: String.t(),
  expiration_time: String.t()
}

Legacy EIP-4361 message payload fields.

sign_error()

@type sign_error() ::
  :unsupported_chain
  | :invalid_chain_id
  | :invalid_payload
  | :invalid_signer
  | term()

Errors returned by sign/3.

signed_decode_error()

@type signed_decode_error() ::
  :invalid_base64 | :invalid_json | :invalid_payload | :payload_too_large

Errors returned by decode_signed/1.

signed_encode_error()

@type signed_encode_error() :: :invalid_payload | :invalid_json

Errors returned by encode_signed/1.

verify_code()

@type verify_code() ::
  :invalid_siwx_domain_mismatch
  | :invalid_siwx_uri_mismatch
  | :invalid_siwx_issued_at
  | :invalid_siwx_issued_at_too_old
  | :invalid_siwx_issued_at_in_future
  | :invalid_siwx_expiration_time
  | :invalid_siwx_expired
  | :invalid_siwx_not_before
  | :invalid_siwx_not_yet_valid
  | :invalid_siwx_nonce
  | :invalid_siwx_signature
  | :invalid_siwx_chain_id
  | :invalid_siwx_unsupported_chain
  | :invalid_siwx_malformed_signature
  | :invalid_siwx_verifier_error

Machine-readable verification failure codes from the spec.

verify_error()

@type verify_error() :: verify_code() | :invalid_payload | signed_decode_error()

Errors returned by verify/2.

Functions

challenge(opts)

(since 0.9.0)
@spec challenge(keyword()) :: map()

Builds the server-side challenge advertised under PaymentRequired.extensions["sign-in-with-x"].

Every call produces a fresh nonce (unless :nonce is given) and timestamps; advertise a new challenge on every 402 response. Raises NimbleOptions.ValidationError for invalid options (programmer error).

Options

  • :domain (String.t/0) - Required. The server's public host (info.domain), e.g. "api.example.com".

  • :uri (String.t/0) - Required. The URI the proof is bound to (info.uri), e.g. "https://api.example.com".

  • :supported_chains - Required. Chains proofs are accepted from: a list of maps or keyword lists with :chain_id (CAIP-2, eip155:* or solana:*), an optional :type ("eip191" for eip155, "ed25519" for solana; derived from the chain when omitted), and an optional :signature_scheme hint ("eip191", "eip1271", "eip6492", or "siws").

  • :statement - Human-readable purpose shown by the wallet (info.statement). The default value is nil.

  • :resources (list of String.t/0) - URIs associated with the request (info.resources); omitted when empty. The default value is [].

  • :version (String.t/0) - CAIP-122 version. Always "1". The default value is "1".

  • :nonce - Explicit nonce; a fresh generate_nonce/0 value when omitted. The default value is nil.

  • :issued_at - Challenge creation time; DateTime.utc_now/0 when omitted. The default value is nil.

  • :expiration_seconds (pos_integer/0) - Seconds after issued_at at which the challenge expires (info.expirationTime). The default value is 300.

  • :not_before - Optional info.notBefore. The default value is nil.

  • :request_id - Optional correlation id (info.requestId). The default value is nil.

Examples

iex> challenge = X402.Extensions.SIWX.challenge(
...>   domain: "api.example.com",
...>   uri: "https://api.example.com",
...>   supported_chains: [[chain_id: "eip155:8453"], [chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"]],
...>   statement: "Sign in to access premium data"
...> )
iex> challenge["supportedChains"]
[
  %{"chainId" => "eip155:8453", "type" => "eip191"},
  %{"chainId" => "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "type" => "ed25519"}
]
iex> challenge["info"]["statement"]
"Sign in to access premium data"
iex> challenge["schema"] == X402.Extensions.SIWX.schema()
true

decode(message)

(since 0.3.0)
@spec decode(String.t()) :: {:ok, message_payload()} | {:error, decode_error()}

Decodes an EIP-4361 SIWX message into payload fields.

Examples

iex> payload = %{
...>   domain: "example.com",
...>   address: "0x1111111111111111111111111111111111111111",
...>   statement: "Access purchased content",
...>   uri: "https://example.com/protected",
...>   version: "1",
...>   chain_id: "eip155:1",
...>   nonce: "abc12345",
...>   issued_at: "2026-02-16T12:00:00Z",
...>   expiration_time: "2026-02-16T13:00:00Z"
...> }
iex> {:ok, message} = X402.Extensions.SIWX.encode(payload)
iex> X402.Extensions.SIWX.decode(message)
{:ok, payload}

encode(payload)

(since 0.3.0)
@spec encode(map()) :: {:ok, String.t()} | {:error, encode_error()}

Encodes a SIWX payload into an EIP-4361 message.

:chain_id accepts either "eip155:<id>" or a positive integer. This is the strict pre-0.7.0 codec (every field, including :statement and :expiration_time, is required); message/1 builds the spec's message from proof fields.

Examples

iex> payload = %{
...>   domain: "example.com",
...>   address: "0x1111111111111111111111111111111111111111",
...>   statement: "Access purchased content",
...>   uri: "https://example.com/protected",
...>   version: "1",
...>   chain_id: "eip155:1",
...>   nonce: "abc12345",
...>   issued_at: "2026-02-16T12:00:00Z",
...>   expiration_time: "2026-02-16T13:00:00Z"
...> }
iex> {:ok, message} = X402.Extensions.SIWX.encode(payload)
iex> is_binary(message)
true

extension_key()

(since 0.9.0)
@spec extension_key() :: String.t()

Returns the extension key on the wire.

Examples

iex> X402.Extensions.SIWX.extension_key()
"sign-in-with-x"

generate_nonce()

(since 0.9.0)
@spec generate_nonce() :: String.t()

Generates a challenge nonce: 32 lowercase hex characters from 16 random bytes.

Examples

iex> nonce = X402.Extensions.SIWX.generate_nonce()
iex> String.length(nonce)
32

message(fields)

(since 0.9.0)
@spec message(map()) ::
  {:ok, String.t()} | {:error, X402.Extensions.SIWX.Message.build_error()}

Builds the CAIP-122 message text a wallet signs for a fields map.

Delegates to X402.Extensions.SIWX.Message.build/1; see it for the exact formats.

Examples

iex> {:ok, text} = X402.Extensions.SIWX.message(%{
...>   "domain" => "api.example.com",
...>   "address" => "BSmWDgE9ex6dZYbiTsJGcwMEgFp8q4aWh92hdErQPeVW",
...>   "uri" => "https://api.example.com",
...>   "version" => "1",
...>   "chainId" => "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
...>   "nonce" => "a1b2c3d4e5f67890a1b2c3d4e5f67890",
...>   "issuedAt" => "2024-01-15T10:30:00.000Z"
...> })
iex> String.split(text, "\n") |> Enum.take(3)
["api.example.com wants you to sign in with your Solana account:", "BSmWDgE9ex6dZYbiTsJGcwMEgFp8q4aWh92hdErQPeVW", ""]

schema()

(since 0.9.0)
@spec schema() :: map()

Returns the JSON schema of a proof, advertised under schema.

Examples

iex> X402.Extensions.SIWX.schema()["properties"]["issuedAt"]
%{"type" => "string", "format" => "date-time"}

sign(challenge, signer, opts)

(since 0.9.0)
@spec sign(map(), X402.Signer.t(), keyword()) ::
  {:ok, fields()} | {:error, sign_error()}

Signs a challenge with an X402.Signer, producing the proof fields.

challenge is the advertised extension value (%{"info" => ..., "supportedChains" => ...}) or its bare info map. The server's fields are copied verbatim, the signer's address (or :address) and the chain's chainId / type are added, and the CAIP-122 message is signed: eip155:* chains through X402.Signer.sign_message/2 (EIP-191), solana:* chains through X402.Signer.sign_ed25519/2 with the signature Base58-encoded.

Returns {:error, :unsupported_chain} when the chain's namespace is not supported or the challenge's supportedChains does not list it, {:error, :invalid_chain_id} for a malformed reference, {:error, :invalid_payload} when the challenge lacks required info fields, and signer errors (:unsupported_signer, :missing_dependency, ...) as they are.

Options

  • :chain_id (String.t/0) - Required. CAIP-2 chain the proof is for; must be one the challenge supports.

  • :address - Address to place in the proof; the signer's address when omitted. The default value is nil.

  • :signature_scheme - Optional signatureScheme hint copied into the proof. The default value is nil.

Examples

iex> {:ok, signer} = X402.Signer.SolanaKey.new(:binary.copy(<<1>>, 32))
iex> challenge = X402.Extensions.SIWX.challenge(
...>   domain: "api.example.com",
...>   uri: "https://api.example.com",
...>   supported_chains: [%{chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}]
...> )
iex> {:ok, signed} = X402.Extensions.SIWX.sign(challenge, signer, chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
iex> {signed["address"], signed["type"]}
{"AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9", "ed25519"}

iex> {:ok, signer} = X402.Signer.SolanaKey.new(:binary.copy(<<1>>, 32))
iex> challenge = X402.Extensions.SIWX.challenge(
...>   domain: "api.example.com",
...>   uri: "https://api.example.com",
...>   supported_chains: [%{chain_id: "eip155:8453"}]
...> )
iex> X402.Extensions.SIWX.sign(challenge, signer, chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
{:error, :unsupported_chain}

validate_fields(fields)

(since 0.9.0)
@spec validate_fields(map()) :: {:ok, fields()} | {:error, :invalid_payload}

Validates and normalizes spec-format proof fields.

Requires domain, address, uri, version ("1"), chainId, type, nonce, and issuedAt as non-empty strings; expirationTime, notBefore, requestId, statement, signatureScheme, and signature must be non-empty strings when present and resources a list of strings. Unknown keys are dropped. Accepts wire (camelCase string) keys or their snake_case atoms and always returns wire keys.

Examples

iex> {:ok, fields} = X402.Extensions.SIWX.validate_fields(%{
...>   domain: "api.example.com",
...>   address: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
...>   uri: "https://api.example.com",
...>   version: "1",
...>   chain_id: "eip155:8453",
...>   type: "eip191",
...>   nonce: "a1b2c3d4e5f67890a1b2c3d4e5f67890",
...>   issued_at: "2024-01-15T10:30:00.000Z",
...>   resources: ["https://api.example.com/premium-data"]
...> })
iex> Map.keys(fields) |> Enum.sort()
["address", "chainId", "domain", "issuedAt", "nonce", "resources", "type", "uri", "version"]

iex> X402.Extensions.SIWX.validate_fields(%{"domain" => "api.example.com"})
{:error, :invalid_payload}