# `X402.Extensions.SIWX`
[🔗](https://github.com/cardotrejos/x402/blob/v0.9.0/lib/x402/extensions/siwx.ex#L1)

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

Implements the
[sign-in-with-x extension](https://github.com/x402-foundation/x402/blob/main/specs/extensions/sign-in-with-x.md).
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.

# `decode_header`
*since 0.3.0* 

> This function is deprecated. Use decode_signed/1, which also understands this format; removed in 1.0.0.

```elixir
@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`
*since 0.9.0* 

```elixir
@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`
*since 0.3.0* 

> This function is deprecated. Use the spec proof format (sign/3, encode_signed/1); removed in 1.0.0.

```elixir
@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`
*since 0.9.0* 

```elixir
@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* 

```elixir
@spec header_name() :: String.t()
```

Returns the canonical SIWX header name.

## Examples

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

# `verify`
*since 0.9.0* 

```elixir
@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` (`t:String.t/0`) - Required. The server's configured public host; `domain` must equal it exactly.

* `:uri` (`t:String.t/0`) - Required. The configured URI the proof is bound to; `uri` must equal it exactly
  (a single trailing slash is ignored on both sides).

* `:supported_chains` - Required. Accepted chains, in the form `X402.Extensions.SIWX.challenge/1` takes.

* `:now` - The current time; `DateTime.utc_now/0` when omitted. The default value is `nil`.

* `:max_age_seconds` (`t:pos_integer/0`) - Maximum age of `issuedAt`. The default value is `300`.

* `:clock_skew_seconds` (`t:non_neg_integer/0`) - Tolerance for an `issuedAt` slightly in the future. The default value is `60`.

* `:nonce_cache` - Optional `X402.Extensions.PaymentIdentifier.Cache` adapter tuple
  tracking issued and used nonces. Without it nonces are only checked
  for format and the time window. The default value is `nil`.

* `:evm_verifier` - `X402.Extensions.SIWX.Verifier` for `eip155:*` proofs. The default value is `X402.Extensions.SIWX.Verifier.Default`.

* `:ed25519_verifier` - `X402.Extensions.SIWX.Verifier` for `solana:*` proofs. The default value is `X402.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}

# `decode_error`

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

# `decoded`

```elixir
@type decoded() ::
  {:spec, fields()} | {:legacy, %{required(String.t()) =&gt; String.t()}}
```

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

# `encode_error`

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

# `fields`

```elixir
@type fields() :: %{optional(String.t()) =&gt; String.t() | [String.t()]}
```

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

# `header_decode_error`

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

# `header_encode_error`

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

# `identity`

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

The wallet identity a verified proof establishes.

# `legacy_source`

```elixir
@type legacy_source() :: :gate
```

Where a legacy-format proof was observed.

# `message_payload`

```elixir
@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`

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

Errors returned by `sign/3`.

# `signed_decode_error`

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

Errors returned by `decode_signed/1`.

# `signed_encode_error`

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

Errors returned by `encode_signed/1`.

# `verify_code`

```elixir
@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`

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

Errors returned by `verify/2`.

# `challenge`
*since 0.9.0* 

```elixir
@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` (`t:String.t/0`) - Required. The server's public host (`info.domain`), e.g. `"api.example.com"`.

* `:uri` (`t: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 `t:String.t/0`) - URIs associated with the request (`info.resources`); omitted when empty. The default value is `[]`.

* `:version` (`t: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` (`t: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`
*since 0.3.0* 

```elixir
@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`
*since 0.3.0* 

```elixir
@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* 

```elixir
@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* 

```elixir
@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`
*since 0.9.0* 

```elixir
@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* 

```elixir
@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`
*since 0.9.0* 

```elixir
@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` (`t: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`
*since 0.9.0* 

```elixir
@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}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
