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.
Errors returned by decode_signed/1.
Errors returned by encode_signed/1.
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
@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}
@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}
@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"
@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}
@spec header_name() :: String.t()
Returns the canonical SIWX header name.
Examples
iex> X402.Extensions.SIWX.header_name()
"SIGN-IN-WITH-X"
Payment Verification
@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:
| Code | Failed check |
|---|---|
:invalid_siwx_domain_mismatch | domain differs from :domain |
:invalid_siwx_uri_mismatch | uri differs from :uri (trailing slash ignored) |
:invalid_siwx_issued_at | issuedAt is not ISO 8601 |
:invalid_siwx_issued_at_too_old | issuedAt is older than :max_age_seconds |
:invalid_siwx_issued_at_in_future | issuedAt is more than :clock_skew_seconds ahead of :now |
:invalid_siwx_expiration_time | expirationTime is not ISO 8601 |
:invalid_siwx_expired | expirationTime is not after :now |
:invalid_siwx_not_before | notBefore is not ISO 8601 |
:invalid_siwx_not_yet_valid | notBefore is after :now |
:invalid_siwx_nonce | nonce is not 32 lowercase hex characters, or — with :nonce_cache — was not issued or was already used |
:invalid_siwx_chain_id | chainId has a malformed reference |
:invalid_siwx_unsupported_chain | chainId/type is not in :supported_chains |
:invalid_siwx_malformed_signature | the address or signature encoding/length is invalid |
:invalid_siwx_signature | the signature does not verify for address |
:invalid_siwx_verifier_error | the 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
:domain(String.t/0) - Required. The server's configured public host;domainmust equal it exactly.:uri(String.t/0) - Required. The configured URI the proof is bound to;urimust equal it exactly (a single trailing slash is ignored on both sides).:supported_chains- Required. Accepted chains, in the formX402.Extensions.SIWX.challenge/1takes.:now- The current time;DateTime.utc_now/0when omitted. The default value isnil.:max_age_seconds(pos_integer/0) - Maximum age ofissuedAt. The default value is300.:clock_skew_seconds(non_neg_integer/0) - Tolerance for anissuedAtslightly in the future. The default value is60.:nonce_cache- OptionalX402.Extensions.PaymentIdentifier.Cacheadapter tuple tracking issued and used nonces. Without it nonces are only checked for format and the time window. The default value isnil.:evm_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> X402.Extensions.SIWX.verify("%%", domain: "api.example.com", uri: "https://api.example.com", supported_chains: [%{chain_id: "eip155:8453"}])
{:error, :invalid_base64}
Types
@type decode_error() :: :invalid_message | {:invalid_field, atom()}
A decoded SIGN-IN-WITH-X header, tagged with its wire format.
Spec-format proof fields, keyed by their wire (camelCase) names.
@type header_decode_error() :: :invalid_base64 | :invalid_json | :invalid_payload
@type header_encode_error() :: :invalid_payload | :invalid_json
The wallet identity a verified proof establishes.
@type legacy_source() :: :gate
Where a legacy-format proof was observed.
@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.
@type sign_error() :: :unsupported_chain | :invalid_chain_id | :invalid_payload | :invalid_signer | term()
Errors returned by sign/3.
@type signed_decode_error() ::
:invalid_base64 | :invalid_json | :invalid_payload | :payload_too_large
Errors returned by decode_signed/1.
@type signed_encode_error() :: :invalid_payload | :invalid_json
Errors returned by encode_signed/1.
@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.
@type verify_error() :: verify_code() | :invalid_payload | signed_decode_error()
Errors returned by verify/2.
Functions
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:*orsolana:*), an optional:type("eip191"foreip155,"ed25519"forsolana; derived from the chain when omitted), and an optional:signature_schemehint ("eip191","eip1271","eip6492", or"siws").:statement- Human-readable purpose shown by the wallet (info.statement). The default value isnil.:resources(list ofString.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 freshgenerate_nonce/0value when omitted. The default value isnil.:issued_at- Challenge creation time;DateTime.utc_now/0when omitted. The default value isnil.:expiration_seconds(pos_integer/0) - Seconds afterissued_atat which the challenge expires (info.expirationTime). The default value is300.:not_before- Optionalinfo.notBefore. The default value isnil.:request_id- Optional correlation id (info.requestId). The default value isnil.
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
@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}
@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
@spec extension_key() :: String.t()
Returns the extension key on the wire.
Examples
iex> X402.Extensions.SIWX.extension_key()
"sign-in-with-x"
@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
@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", ""]
@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"}
@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 isnil.:signature_scheme- OptionalsignatureSchemehint copied into the proof. The default value isnil.
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}
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}