Signers and Verifiers

Signers

Source Code

Signers define who can authorize operations in a smart account. The framework supports two variants that accommodate different authorization patterns and cryptographic schemes.

#[contracttype]
pub enum Signer {
    Delegated(Address),
    External(Address, Bytes),
}

Delegated

Signer::Delegated(Address)

Delegated signers represent any Soroban address (C-account, G-account) and delegate authorization checks to that address using the built-in require_auth_for_args(), with the AuthDigestPreimage as the single argument. This enables using traditional Stellar account address (G-accounts) as signers, but also makes possible powerful composition patterns such as nested smart accounts (one smart account authorizing on behalf of another) and contract-based signers with custom authorization logic. This variant is particularly useful for building multi-level authorization hierarchies.

Restrictions

The Soroban host approves require_auth_for_args without a signature when the address is the direct invoker of the current __check_auth. Delegated signers that could be in that position are therefore rejected:

  • Self-delegation: a smart account cannot be its own delegated signer.
  • Context contracts: authorization fails when a delegated signer's address is the contract of any context being authorized. This covers a delegate contract that calls into the smart account, and the delegate's own __check_auth when two accounts delegate to each other. Two smart accounts built on this library can still list each other as delegated signers: each one is then authorized through the other's remaining signers, never through the delegation back.

do_check_auth runs the context contract check before authenticating each signer. A custom __check_auth that calls authenticate directly, or calls require_auth_for_args itself as in the example below, must call validate_no_delegated_context_contract first.

A delegated signer must not be an account that delegates its own authentication back to the smart account, unless it is another smart account that authenticates through do_check_auth. The context contract check does not detect the following cases, in which the host approves the delegation back without a signature:

  • The account delegates to the smart account through CAP-71 (CustomAccount::delegate_auth). The host runs the smart account's __check_auth from the account's own __check_auth and with the account's contexts, so the account is the invoker without being the contract of any context. The account, and any account relying on its authorization, can then be authorized without any key.
  • The account calls require_auth on the smart account from its own __check_auth without an equivalent check. The smart account can then be authorized without any key.

Transaction Simulation Behavior

Delegated signers also come with a client-side caveat: authorization entries are not automatically included in transaction simulation results and clients must manually construct these entries, which adds additional implementation complexity.

When building transactions in Soroban, clients typically simulate them first to obtain the authorization trees and nonces needed for signing. The simulation mechanism precomputes the SorobanAuthorizedInvocation trees that must be authorized by the Addresses for all require_auth/require_auth_for_args checks to pass.

The usual flow is: simulate, collect the returned auth trees and nonces, have each Address sign those payloads, and then submit the final transaction that combines the simulation output with the corresponding signatures. See the official docs for details.

When require_auth_for_args is called from within __check_auth (as with delegated signers), the authorization entry for that signer is not included in the simulation output.

#[contracttype]
pub struct AuthPayload {
    pub signers: Map<Signer, Bytes>,
    pub context_rule_ids: Vec<u32>,
}

#[contract]
pub struct MySmartAccount;

#[contractimpl]
impl CustomAccountInterface for MySmartAccount {
    fn __check_auth(
        e: Env,
        signature_payload: Hash<32>,
        auth_payload: AuthPayload,
        auth_contexts: Vec<Context>,
    ) -> Result<(), SmartAccountError> {
        let preimage = AuthDigestPreimage {
            account: e.current_contract_address(),
            signature_payload: signature_payload.to_bytes(),
            context_rule_ids: auth_payload.context_rule_ids.clone(),
        };
        for (signer, _) in auth_payload.signers.iter() {
            // Reject a delegated signer that is the contract of a context being
            // authorized, which the host would approve without a signature
            validate_no_delegated_context_contract(&e, &signer, &auth_contexts);
            match signer {
                // ...
                Signer::Delegated(addr) => {
                    let args = (preimage.clone(),).into_val(&e);
                    addr.require_auth_for_args(args);
                }
            }
        }
        // ...
    }
}

Example Scenario

Consider a scenario where a target contract CAGCDFLG4WKPYG... requires authorization from a smart account CBH6XACZFDCJUHX.... The smart account will grant authorization only if the G-account GBDZXYMJ3SLYXCY... has signed, meaning the signers map in AuthPayload has to contain one element that is Signer::Delegated("GBDZXYMJ3SLYXCY...").

When simulating this transaction, the following authorization entry is returned. Note that "auth" contains a single element and the delegated signer address (G-account) is not present at all:

{
  "tx": {
    // ...
    "auth": [
      {
        "credentials": {
          "address": {
            "address": "CBH6XACZFDCJUHX...", // the Smart Account
            "nonce": "7346653005027720525",
            "signature_expiration_ledger": 0,
            "signature": "void"
          }
        },
        "root_invocation": {
          "function": {
            "contract_fn": {
              "contract_address": "CAGCDFLG4WKPYG...", // the Target Contract that's initially invoked
              "function_name": "some_fn",
              "args": [/* fn args if any */]
            }
          },
          "sub_invocations": []
        }
      }
    ]
    // ...
  }
}

The client implementation requires constructing two authorization entries:

  1. Replace "signature": "void" with the proper AuthPayload structure (containing the signers map and context_rule_ids)
  2. Create the missing root-level authorization entry for the delegated signer: its root invocation is __check_auth on the smart account, with the AuthDigestPreimage as the single argument, and it is signed the way that address normally authorizes (an Ed25519 signature for a G-account, its own AuthPayload for a nested smart account)

The argument of the delegated signer's entry must be the preimage struct, encoded as an ScVal::Map, not the 32-byte digest or the host's signature_payload. A missing or wrongly encoded entry fails with Error(Auth, InvalidAction) and a trap inside __check_auth, not with a SmartAccountError.

The following typescript code demonstrates this process:

async function signAndSendTx(
    contract: string,
    fnName: string,
    fnArgs: ScVal[],
    signer: Keypair,
    ruleId: number, // ID of the context rule to use for this authorization (e.g., obtained when the rule was created)
) {
    const baseTx = new TransactionBuilder(...)
        .addOperation(
            Operation.invokeContractFunction({ contract, function: fnName, args: fnArgs }),
        )
        .setTimeout(600)
        .build();

    const simRes = await server.simulateTransaction(baseTx);
    // we assume only one authorization is returned
    const simAuth = simRes.result.auth[0];
    
    const signedAuths: SorobanAuthorizationEntry[] = [];

    // 1) Construct the 1st auth entry: `AuthPayload { signers, context_rule_ids }`
    const sigInnerMap = ScVal.scvMap([
        new xdr.ScMapEntry({
            key: ScVal.scvVec([
                ScVal.scvSymbol("Delegated"),
                Address.fromString(signer.publicKey()).toScVal(), // "GBDZXYMJ3SLYXCY..."
            ]),
            val: ScVal.scvBytes(""),
        }),
    ]);

    // AuthPayload is a named-field struct serialized as ScVal::Map
    const authPayload = ScVal.scvMap([
        new xdr.ScMapEntry({
            key: ScVal.scvSymbol("context_rule_ids"),
            val: ScVal.scvVec([ScVal.scvU32(ruleId)]), // one rule ID per auth context
        }),
        new xdr.ScMapEntry({
            key: ScVal.scvSymbol("signers"),
            val: sigInnerMap,
        }),
    ]);

    simAuth.credentials().address().signature(authPayload);
    simAuth.credentials().address().signatureExpirationLedger(validUntil);
    signedAuths.push(simAuth);

    // 2) Compute the host's `signature_payload` for the smart account's entry
    const smartAccount = Address.fromScAddress(simAuth.credentials().address().address());
    const signaturePayload = hash(
        HashIdPreimage.envelopeTypeSorobanAuthorization(
            new HashIdPreimageSorobanAuthorization({
                networkId,
                nonce: simAuth.credentials().address().nonce(),
                signatureExpirationLedger: validUntil,
                invocation: simAuth.rootInvocation(),
            }),
        ).toXDR(),
    );

    // AuthDigestPreimage is a named-field struct serialized as ScVal::Map (keys sorted)
    const preimage = ScVal.scvMap([
        new xdr.ScMapEntry({
            key: ScVal.scvSymbol("account"),
            val: smartAccount.toScVal(),
        }),
        new xdr.ScMapEntry({
            key: ScVal.scvSymbol("context_rule_ids"),
            val: ScVal.scvVec([ScVal.scvU32(ruleId)]), // same rule IDs as in AuthPayload
        }),
        new xdr.ScMapEntry({
            key: ScVal.scvSymbol("signature_payload"),
            val: ScVal.scvBytes(signaturePayload),
        }),
    ]);

    // 3) Construct the 2nd auth entry: the delegated signer authorizes
    //    `__check_auth` on the smart account with the preimage as its only argument
    const args = new InvokeContractArgs({
        contractAddress: smartAccount.toScAddress(),
        functionName: "__check_auth",
        args: [preimage],
    });
    const invocation = new SorobanAuthorizedInvocation({
        function: SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(args),
        subInvocations: [],
    });
    const signedEntry = await authorizeInvocation(
        signer,
        validUntil,
        invocation,
        signer.publicKey(),
        Networks.TESTNET,
    );
    signedAuths.push(signedEntry);
    
    // rebuild transaction with both auth entries in signedAuths
    // re-simulate
}

After including both authorization entries in signedAuths and re-simulating the transaction, the "auth" array contains now two elements:

{
  "tx": {
    // ...
    "auth": [
      {
        "credentials": {
          "address": {
            "address": "CBH6XACZFDCJUHX...", // the Smart Account
            "nonce": "7346653005027720525",
            "signature_expiration_ledger": 1256083,
            "signature": {
              "map": [
                {
                  "key": { "symbol": "context_rule_ids" },
                  "val": { "vec": [{ "u32": 1 }] } // one rule ID per auth context
                },
                {
                  "key": { "symbol": "signers" },
                  "val": {
                    "map": [
                      {
                        "key": {
                          "vec": [
                            { "symbol": "Delegated" },
                            { "address": "GBDZXYMJ3SLYXCY..." } // the delegated signer (G-account)
                          ]
                        },
                        "val": {
                          "bytes": "" // `Bytes` value is empty for delegated signers (used only for `Signer::External`)
                        }
                      }
                    ]
                  }
                }
              ]
            }
          }
        },
        "root_invocation": {
          // ...
        }
      },
      {
        "credentials": {
          "address": {
            "address": "GBDZXYMJ3SLYXCY...", // the delegated signer
            "nonce": "172051086",
            "signature_expiration_ledger": 1256083,
            "signature": {
              "vec": [
                {
                  "map": [
                    {
                      "key": {
                        "symbol": "public_key"
                      },
                      "val": {
                        "bytes": "479be189dc978b8b3b463a0c6f..."
                      }
                    },
                    {
                      "key": {
                        "symbol": "signature"
                      },
                      "val": {
                        "bytes": "c54aae899cc29374ef81745bf46612...."
                      }
                    }
                  ]
                }
              ]
            }
          }
        },
        "root_invocation": {
          "function": {
            "contract_fn": {
              "contract_address": "CBH6XACZFDCJUHX...", // the Smart Account
              "function_name": "__check_auth",
              "args": [
                {
                  "map": [ // the AuthDigestPreimage
                    {
                      "key": { "symbol": "account" },
                      "val": { "address": "CBH6XACZFDCJUHX..." } // the Smart Account
                    },
                    {
                      "key": { "symbol": "context_rule_ids" },
                      "val": { "vec": [{ "u32": 1 }] } // same rule IDs as in the AuthPayload
                    },
                    {
                      "key": { "symbol": "signature_payload" },
                      "val": { "bytes": "ed0cfe2903d64e5383..." } // the host's signature payload
                    }
                  ]
                }
              ]
            }
          },
          "sub_invocations": []
        }
      }
    ]
    // ...
  }
}

Despite the manual entry construction, delegated signers are valuable both for simple scenarios using traditional Stellar accounts (G-accounts) as signers and for advanced use cases requiring multi-level authorization or custom contract-based signing logic.

External

Each External signer pairs a verifier address with a public key.

Signer::External(Address, Bytes)

In the case of external signers verification is offloaded to specialized verifier contracts. During authorization, the smart account makes a cross-contract call to the verifier contract to validate the signature, as shown in the example below where VerifierClient::new(e, &verifier).verify() invokes the external verifier's verification logic.

#[contracttype]
pub struct AuthPayload {
    pub signers: Map<Signer, Bytes>,
    pub context_rule_ids: Vec<u32>,
}

#[contract]
pub struct MySmartAccount;

#[contractimpl]
impl CustomAccountInterface for MySmartAccount {
    fn __check_auth(
        e: Env,
        signature_payload: Hash<32>,
        signatures: AuthPayload,
        auth_contexts: Vec<Context>,
    ) -> Result<(), SmartAccountError> {
        let preimage = AuthDigestPreimage {
            account: e.current_contract_address(),
            signature_payload: signature_payload.to_bytes(),
            context_rule_ids: signatures.context_rule_ids.clone(),
        };
        for (signer, sig_data) in signatures.signers.iter() {
            match signer {
                Signer::External(verifier, key_data) => {
                    // External signers sign sha256(preimage.to_xdr()), not the raw signature_payload
                    let auth_digest = preimage.digest(&e).to_bytes();
                    if !VerifierClient::new(&e, &verifier).verify(
                        &auth_digest,
                        &key_data.into_val(&e),
                        &sig_data.into_val(&e),
                    ) {
                        panic_with_error!(&e, SmartAccountError::ExternalVerificationFailed)
                    }
                }
                // ...
            }
        }
        // ...
    }
}

This design separates cryptographic logic from the smart account to facilitate support for diverse schemes (ed25519, secp256k1, secp256r1, BLS, RSA, zero-knowledge proofs). It also minimizes setup costs by allowing many accounts to reuse the same verifier contracts.

This separation provides forward compatibility: when new cryptographic curves are added on Soroban, smart accounts can adopt them immediately by referencing the appropriate verifier contract. If signature verification were embedded directly in the account, adopting a new scheme would require either upgrading the account (if upgradeable) or migrating to an entirely new account.

Example Scenario

Consider again a target contract CAGCDFLG4WKPYG... that requires authorization from a smart account CBH6XACZFDCJUHX.... This time the smart account will grant authorization only if the Ed25519 public key 2b6bad0cfdb3d4b6f2cd... has signed, meaning the signers map in AuthPayload has to contain one element that is Signer::External("CDLDYJWEZSM6IAI4...", "2b6bad0cfdb3d4b6f2cd...") and its signature.

In contrast to Delegated signers, constructing the auth entry for an External signer is straightforward:

{
  "tx": {
    // ...
    "auth": [
      {
        "credentials": {
          "address": {
            "address": "CBH6XACZFDCJUHX...", // the Smart Account
            "nonce": "7346653005027720525",
            "signature_expiration_ledger": 1256083,
            "signature": {
              "map": [
                {
                  "key": { "symbol": "context_rule_ids" },
                  "val": { "vec": [{ "u32": 2 }] } // one rule ID per auth context
                },
                {
                  "key": { "symbol": "signers" },
                  "val": {
                    "map": [
                      {
                        "key": {
                          "vec": [
                            { "symbol": "External" },
                            { "address": "CDLDYJWEZSM6IAI4..." }, // the Ed25519 Verifier
                            { "bytes": "2b6bad0cfdb3d4b6f2cd..." } // Signer's public key
                          ]
                        },
                        "val": {
                          "bytes": "6ead27ab6e8cab36..." // Signer's signature over the auth digest
                        }
                      }
                    ]
                  }
                }
              ]
            }
          }
        },
        "root_invocation": {
          // ...
        }
      }
    ]
    // ...
  }
}

See the Verifiers section below for architecture details and the Verifier trait that external signers rely on.

AuthPayload and Off-Chain Clients

The AuthPayload struct is the signature type passed to __check_auth:

#[contracttype]
pub struct AuthPayload {
    pub signers: Map<Signer, Bytes>,
    pub context_rule_ids: Vec<u32>,
}

The context_rule_ids field contains one rule ID per auth context, explicitly selecting which stored context rule applies to each operation. No rule iteration or auto-discovery is performed — clients must know which rule to reference. A mismatch between the length of context_rule_ids and the number of auth contexts is rejected with ContextRuleIdsLengthMismatch.

Auth Digest Computation

Signers do not sign the raw signature_payload. Every signer commits to an AuthDigestPreimage that binds the smart account address and the selected context rule IDs alongside the payload:

#[contracttype]
pub struct AuthDigestPreimage {
    pub account: Address,              // the smart account
    pub signature_payload: BytesN<32>, // from the host
    pub context_rule_ids: Vec<u32>,    // one per auth context
}
  • External signers sign the auth digest, sha256(preimage.to_xdr()).
  • Delegated signers authorize __check_auth on the smart account with the preimage itself as the single argument (see Transaction Simulation Behavior).

Steps:

  1. Compute the 32-byte signature_payload of the smart account's authorization entry: the SHA-256 hash of the XDR-encoded HashIdPreimage::SorobanAuthorization { network_id, nonce, signature_expiration_ledger, invocation }
  2. Build the preimage as an ScVal::Map with the symbol keys account, context_rule_ids and signature_payload (ScMap keys must be sorted, so they appear in this order)
  3. XDR-encode the map and SHA-256 hash it to obtain the auth digest
  4. Have every External signer sign the auth digest

In Rust (Soroban SDK):

use stellar_accounts::smart_account::AuthDigestPreimage;

let preimage = AuthDigestPreimage {
    account: smart_account_address,
    signature_payload, // BytesN<32> from the host
    context_rule_ids,  // Vec<u32>, one per auth context
};
let auth_digest = preimage.digest(e);
// External signers sign auth_digest, not signature_payload

The SmartAccount trait also exposes an auth_digest(preimage) view that returns sha256(preimage.to_xdr()). Simulating it lets a client check its locally computed digest before collecting any signatures.

Up to v0.7.x, the digest was sha256(signature_payload || context_rule_ids.to_xdr()) and Delegated signers authorized that 32-byte digest. Signatures produced that way are rejected by accounts built with v0.8.0 or later. See the migration guide for details.

XDR Encoding

The AuthPayload serializes as an ScVal::Map with two Symbol-keyed entries:

ScVal::Map([
    (Symbol("context_rule_ids"), ScVal::Vec(/* rule IDs, one per auth context */)),
    (Symbol("signers"),          ScVal::Map(/* signer → signature entries */)),
])

Using stellar_xdr:

creds.signature = ScVal::Map(Some(ScMap::sorted_from([
    (
        ScVal::Symbol("context_rule_ids".try_into()?),
        ScVal::Vec(Some(ScVec(VecM::try_from([
            ScVal::U32(rule_id_for_context_0),
            ScVal::U32(rule_id_for_context_1),
        ])?))),
    ),
    (
        ScVal::Symbol("signers".try_into()?),
        sig_map, // Map<Signer, Bytes> entries
    ),
])?));

The AuthDigestPreimage is encoded the same way. Its XDR serialization is what External signers hash, and the map itself is the argument of a Delegated signer's __check_auth entry:

let preimage = ScVal::Map(Some(ScMap::sorted_from([
    (
        ScVal::Symbol("account".try_into()?),
        ScVal::Address(smart_account), // ScAddress of the smart account
    ),
    (
        ScVal::Symbol("context_rule_ids".try_into()?),
        ScVal::Vec(Some(ScVec(VecM::try_from([
            ScVal::U32(rule_id_for_context_0),
            ScVal::U32(rule_id_for_context_1),
        ])?))),
    ),
    (
        ScVal::Symbol("signature_payload".try_into()?),
        ScVal::Bytes(ScBytes(signature_payload.to_vec().try_into()?)),
    ),
])?));

let auth_digest = Sha256::digest(preimage.to_xdr(Limits::none())?);

Client Implementations

The auth_entries test module builds both the smart account's entry and a delegated signer's nested entry in Rust and drives them through the host's __check_auth; it is the reference for client implementations. The smart-account-kit TypeScript SDK implements this flow end to end. Check that its version targets the stellar-accounts release the account was built with.

Signer Management

The SmartAccount trait provides functions for managing signers within context rules:

Adding Signers

fn add_signer(
    e: &Env,
    context_rule_id: u32,
    signer: Signer,
) -> u32;

Adds a signer to an existing context rule and returns the assigned signer ID. The rule must not exceed the maximum of 15 signers.

Batch Adding Signers

Batch adding is not part of the SmartAccount trait. The smart_account module provides a batch_add_signer helper that adds multiple signers to an existing context rule at once, and a contract can expose it through its own entry point:

#[contractimpl]
impl MySmartAccount {
    pub fn batch_add_signer(e: &Env, context_rule_id: u32, signers: Vec<Signer>) {
        // The helper does not perform any authorization check itself.
        e.current_contract_address().require_auth();

        smart_account::batch_add_signer(e, context_rule_id, &signers);
    }
}

Important: When adding signers to rules with threshold policies, administrators must manually update policy thresholds to maintain security guarantees. See the Policies documentation for details.

Removing Signers

fn remove_signer(
    e: &Env,
    context_rule_id: u32,
    signer_id: u32,
);

Removes a signer (identified by its numeric ID) from an existing context rule. The rule must maintain at least one signer OR one policy after removal. The ID is returned by add_signer, and the get_signer_id(signer) view looks it up for an already registered signer.

Important: When removing signers from rules with threshold policies, verify that the threshold remains achievable with the remaining signers. With a weighted threshold policy, lower the threshold before reducing weights (see Required Operation Ordering).

Verifiers

Source Code

Verifiers serve as cryptographic oracles for signature validation: specialized, trusted contracts that validate signatures on behalf of smart accounts. They are the foundation for External signers, providing the actual signature verification logic.

Each signer is represented as a (verifier_address, public_key) pair, where the verifier address points to shared verification logic and the public key identifies the specific signer. A single verifier contract can validate signatures for any number of keys.

This architecture separates verification logic from smart accounts, enabling a clean division of concerns and promoting code reuse across the ecosystem.

The Verifier trait defines the interface for verifier contracts:

pub trait Verifier {
    type KeyData: FromVal<Env, Val>;
    type SigData: FromVal<Env, Val>;

    /// Validates a signature against a hash and public key.
    /// Must use constant-time operations and validate all inputs.
    fn verify(e: &Env, hash: Bytes, key_data: Self::KeyData, sig_data: Self::SigData) -> bool;

    /// Maps key data to a unique canonical byte representation.
    /// Used to detect duplicate cryptographic identities across different byte encodings.
    fn canonicalize_key(e: &Env, key_data: Self::KeyData) -> Bytes;

    /// Batch variant of `canonicalize_key` for processing multiple keys in a single call.
    fn batch_canonicalize_key(e: &Env, key_data: Vec<Self::KeyData>) -> Vec<Bytes>;
}

The trait uses associated types to allow different verifiers to define their own data structures for keys and signatures. The canonicalize_key methods enable the framework to detect duplicate signers even when the same cryptographic identity is represented by different byte sequences.

Advantages of the Verifier Pattern

No Setup Costs: Once a verifier is deployed, new keys can be used immediately without any on-chain setup. Users simply reference the verifier address and provide their public key when creating signers.

Cryptographic Flexibility: The verifier pattern supports diverse cryptographic schemes from standard curves to emerging authentication methods like zero-knowledge proofs and email-based signing. Developers can implement custom verifiers for specialized cryptographic schemes.

Address-less Keys: Keys remain separate from account addresses, maintaining clear boundaries between accounts (which hold assets) and the keys that control them. This separation improves security and flexibility.

Shared Security: Verification logic is centralized in well-audited, immutable contracts. The entire network benefits from shared security guarantees, reduced deployment overhead, community-reviewed implementations, and consistent verification behavior.

Ecosystem Trust: Well-known verifier addresses build trust as they are used across many accounts. A small set of thoroughly audited verifiers can serve the entire ecosystem.

Implementation Recommendations

Verifiers logic should be implemented as pure verification functions with the following characteristics:

  • Stateless: No internal state that could be manipulated
  • Immutable: Not upgradeable once deployed, minimizing the trust
  • Deterministic: Same inputs always produce the same output
  • Efficient: Optimized for gas costs and performance

Example Verifiers

The "accounts" package provides utility functions for implementing Ed25519 and WebAuthn verifiers:

Ed25519

Source Code

The ed25519 verifier utilities package provides standard Ed25519 signature verification for external signers in smart accounts. This enables using Ed25519 keys that are not native Soroban addresses, such as keys generated by external systems or hardware wallets.

Ed25519 public keys must be exactly 32 bytes:

let public_key = Bytes::from_array(e, &[
    0x12, 0x34, 0x56, 0x78, // ... 32 bytes total
]);

Ed25519 signatures must be exactly 64 bytes:

let signature = Bytes::from_array(e, &[
    0xab, 0xcd, 0xef, 0x01, // ... 64 bytes total
]);

WebAuthn (Passkeys)

Source Code

WebAuthn (Web Authentication) is a web standard for passwordless authentication that allows users to authenticate using:

  • Passkeys: Biometric authentication (Face ID, Touch ID, Windows Hello)
  • Hardware Keys: Physical security keys (YubiKey, Titan Key)
  • Platform Authenticators: Built-in device authenticators

WebAuthn uses secp256r1 (P-256) public keys, which must be exactly 65 bytes in uncompressed format:

let public_key = Bytes::from_array(e, &[
    0x04, // Uncompressed point indicator
    // ... 32 bytes for X coordinate
    // ... 32 bytes for Y coordinate
]);

Unlike simpler signature schemes, WebAuthn signature data is a complex structure that must be XDR-encoded. The signature data contains three components:

#[contracttype]
pub struct WebAuthnSigData {
    /// The cryptographic signature (64 bytes for secp256r1)
    pub signature: BytesN<64>,
    /// Raw authenticator data from the WebAuthn response
    pub authenticator_data: Bytes,
    /// Raw client data JSON from the WebAuthn response
    pub client_data: Bytes,
}

Components:

  • signature: The secp256r1 signature (64 bytes)
  • authenticator_data: Binary data from the authenticator (minimum 37 bytes), containing:
    • RP ID hash (32 bytes)
    • Flags byte (1 byte) - indicates user presence, user verification, and backup state
    • Signature counter (4 bytes)
  • client_data: JSON string from the browser/platform (max 1024 bytes), containing:
    • type: Must be "webauthn.get" for authentication
    • challenge: Base64url-encoded (unpadded) auth digest, sha256(preimage.to_xdr()) (see Auth Digest Computation), not the host's signature_payload
    • origin: The origin where authentication occurred

The sig_data parameter passed to the verifier must be the XDR-encoded representation of this structure to ensure proper serialization and deserialization.

See Also