Skip to main content
Dakota signers can be WebAuthn credentials (passkeys, platform authenticators, or roaming keys like a YubiKey) in addition to raw ES256 keys. A WebAuthn signer endorses an intent by producing a standard WebAuthn assertion — the same object a browser returns from navigator.credentials.get() — rather than a bare ECDSA signature over the intent hash. This page is the reference for the WebAuthn path. If you sign with raw ES256 keys, read Wallet Transaction Signing instead. For the conceptual model, intent catalogue, and signer-group / policy lifecycles (which are identical for both signer types), read Signing & Endorsed Requests.
Why WebAuthn is different. An authenticator never signs an arbitrary hash. It signs authenticatorData ‖ SHA-256(clientDataJSON), and the intent reaches the signature only as the challenge embedded inside clientDataJSON. The verifier therefore needs the whole assertion bundle (authenticatorData, clientDataJSON, signature) — not a DER signature. Everything below exists to thread the intent through that bundle correctly.

The model at a glance

A WebAuthn integration has two phases:
  1. Register — create a WebAuthn credential, extract its COSE public key, and register it as a key_type: WEBAUTHN signer.
  2. Sign — build a transaction intent, JCS-canonicalize it, use those exact bytes as the WebAuthn challenge, produce an assertion, and submit it in an endorsed request.
Once a WebAuthn signer is in a signer group attached to a wallet, it participates in approval thresholds exactly like an ES256 signer. The signer-group and policy mechanics in Signing & Endorsed Requests apply unchanged.
Supported algorithms. A WEBAUTHN signer’s COSE key must be ES256 (EC2 / P-256, COSE alg -7) or RS256 (RSA, 2048-bit minimum, COSE alg -257). Other algorithms and curves — including ES384/ES512, Ed25519, and secp256k1 — are rejected at verification time. Virtually all passkeys are ES256, so request [-7, -257] and you will get an ES256 credential.

1. Register a WEBAUTHN signer

1a. Create the credential (browser)

Run a normal WebAuthn registration ceremony. The relying-party options come from your own server; the only Dakota-specific constraint is pubKeyCredParams (i.e. supportedAlgorithmIDs) limited to ES256 / RS256.
import { create } from '@github/webauthn-json/browser-ponyfill';

// creationOptions come from your relying-party server and set
// rpId, challenge, user, and pubKeyCredParams ([-7, -257]).
const credential = await create({ publicKey: creationOptions });

// credential.id      — base64url credential ID. SAVE THIS — you need it to sign.
// credential.response — contains the attestationObject (CBOR) holding the COSE key.

1b. Extract the COSE public key (server)

The public key Dakota stores is the raw COSE key (CBOR) embedded in the attestation object — not a PKIX/SPKI blob. The simplest way to get it is to verify the registration with @simplewebauthn/server and read the credential public key it returns:
import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: credential,                       // the PublicKeyCredential from 1a
  expectedChallenge,                          // the challenge you issued
  expectedOrigin: 'https://your-app.example',
  expectedRPID: 'your-app.example',
});

// COSE public key (Uint8Array). Field name varies by library version:
//   @simplewebauthn/server v7+ : registrationInfo.credential.publicKey
//   v6 and below               : registrationInfo.credentialPublicKey
const cosePublicKey = verification.registrationInfo.credential.publicKey;

// base64url-encode for the API (Node 14.18+).
const publicKeyB64 = Buffer.from(cosePublicKey).toString('base64url');
If you are not using @simplewebauthn/server, the COSE key is the credentialPublicKey field of attestedCredentialData inside the authData of the attestationObject. Decode the attestation CBOR with any CBOR library and walk the standard authenticator-data layout to extract it. The bytes you register are exactly that COSE key, base64-encoded.

1c. Register the signer

curl -X POST https://api.platform.dakota.xyz/signers \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice'\''s passkey",
    "key_type": "WEBAUTHN",
    "public_key": "<publicKeyB64>"
  }'
Save both the returned signer id (for building signer groups) and the credential.id from step 1a (for signing). Add the signer to a signer group and attach that group to a wallet exactly as you would for an ES256 signer — see the Signer Group Lifecycle.
public_key for a WEBAUTHN signer is the raw CBOR COSE key, base64-encoded. This is a different encoding from key_type: ES256, which expects an X.509 SubjectPublicKeyInfo (PKIX) DER/PEM blob. Sending a PKIX key for a WebAuthn signer (or vice-versa) fails verification later, not at registration.

2. Sign and submit a transaction

The intent and operation schema are identical to the ECDSA flow — only the signing step changes.

2a. Build and canonicalize the intent

import canonicalize from 'canonicalize'; // RFC 8785 JCS; npm install canonicalize

const intent = {
  wallet_id: 'wal_2LfZm5KMnRvLFtRP7nJJug4zJEP',
  caip2: 'eip155:1',
  operation: {
    kind: 'transfer',
    from: '0xYourWalletAddress...',
    to: '0xDestinationAddress...',
    amount: '10.5',          // strings, never numbers
    asset_id: 'USDC',
  },
  idempotency_key: crypto.randomUUID(),
  // context_digest is optional — include it only if an upstream service
  // hands you one. It is part of the intent, so it is covered automatically.
};

// The RFC 8785 canonical bytes. These bytes ARE the WebAuthn challenge.
const canonicalBytes = new TextEncoder().encode(canonicalize(intent));

2b. The challenge — the one thing that trips integrators up

The challenge is the raw bytes of the RFC 8785 (JCS) canonical intent JSONnot SHA-256(intent), and not any other digest. Do not hash the intent before using it as the challenge. The verifier re-derives the hash itself.
What the verifier does, step by step:
decoded        = base64url_decode(clientDataJSON.challenge)   // → canonical intent bytes
intent_obj     = JSON.parse(decoded)
intent_obj     = removeEmptyStrings(intent_obj)               // matches server omitempty
intent_obj     = normalizeDecimals(intent_obj)                // "0.10" → "0.1"
challenge_hash = SHA-256( JCS(intent_obj) )
stored_hash    = SHA-256( JCS(submitted endorsed_request.intent) )
assert  challenge_hash == stored_hash                          // constant-time
Because the verifier re-canonicalizes whatever it decodes from the challenge, minor formatting differences (key order, trailing decimal zeros, empty strings) are tolerated. Signing the output of your JCS library directly is the safest approach.
context_digest participates automatically. It is an optional field on SendTransactionIntent; because it lives inside the intent object, it is part of the canonical JSON the challenge commits to — no separate handling. The hash is a single SHA-256, not a double hash.

2c. Produce the assertion (browser)

import {
  get as getAssertion,
  parseRequestOptionsFromJSON,
} from '@github/webauthn-json/browser-ponyfill';

/**
 * Sign a canonicalized intent with a registered WebAuthn credential.
 * Returns one entry suitable for EndorsedRequest.signatures[].
 */
async function signIntent(
  canonicalBytes: Uint8Array,
  credentialId: string,   // the base64url credential.id from registration
): Promise<string> {
  // parseRequestOptionsFromJSON expects the challenge as a base64url string
  // and decodes it back to the raw bytes the authenticator signs over.
  const requestOptions = parseRequestOptionsFromJSON({
    publicKey: {
      challenge: base64url(canonicalBytes),  // the canonical intent bytes
      allowCredentials: [{ type: 'public-key', id: credentialId }],
      userVerification: 'preferred',
      timeout: 300_000,
    },
  });

  // The browser prompts the user, then signs authenticatorData ‖ SHA-256(clientDataJSON).
  const assertion = await getAssertion(requestOptions);

  // assertion.toJSON() is the CredentialAssertionResponse shape, with every
  // binary field inside `response` already base64url-encoded:
  // { id, rawId, type: "public-key",
  //   response: { authenticatorData, clientDataJSON, signature } }
  const assertionJson = JSON.stringify(assertion.toJSON());

  // A signatures[] entry is base64url( JSON(assertion) ).
  return base64url(new TextEncoder().encode(assertionJson));
}

// RFC 4648 §5 base64url, no padding.
function base64url(bytes: Uint8Array): string {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

2d. Submit the endorsed request

The request body is the endorsed requestintent and signatures at the top level. There is no wrapper object.
curl -X POST https://api.platform.dakota.xyz/wallets/wal_2LfZm5KMnRvLFtRP7nJJug4zJEP/transactions \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "signatures": [
      "<output of signIntent()>"
    ],
    "intent": {
      "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
      "caip2": "eip155:1",
      "operation": {
        "kind": "transfer",
        "from": "0xYourWalletAddress...",
        "to": "0xDestinationAddress...",
        "amount": "10.5",
        "asset_id": "USDC"
      },
      "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
    }
  }'
The intent you submit must be the same object you canonicalized and signed — the verifier independently canonicalizes it and compares its hash against the challenge. The signatures array holds one entry per endorsing signer, up to the signer group’s approval threshold.

End-to-end

import canonicalize from 'canonicalize';

async function sendSignedTransaction(params: {
  walletId: string;
  caip2: string;
  fromAddress: string;
  toAddress: string;
  amount: string;
  assetId: string;
  credentialId: string;   // base64url WebAuthn credential ID from registration
}) {
  const intent = {
    wallet_id: params.walletId,
    caip2: params.caip2,
    operation: {
      kind: 'transfer',
      from: params.fromAddress,
      to: params.toAddress,
      amount: params.amount,
      asset_id: params.assetId,
    },
    idempotency_key: crypto.randomUUID(),
  };

  const canonicalBytes = new TextEncoder().encode(canonicalize(intent));
  const signature = await signIntent(canonicalBytes, params.credentialId);

  return api.post(`/wallets/${params.walletId}/transactions`, {
    signatures: [signature],
    intent,
  });
}

What goes in signatures[]

Each entry is the WebAuthn assertion serialized as JSON, then base64-encoded — there is no CBOR, no custom concatenation, and no proprietary envelope:
signatures[i] = base64url( JSON(CredentialAssertionResponse) )
The decoded JSON:
{
  "id":    "<base64url credential ID>",
  "rawId": "<base64url credential ID>",
  "type":  "public-key",
  "response": {
    "authenticatorData": "<base64url>",
    "clientDataJSON":    "<base64url>",
    "signature":         "<base64url — DER ECDSA for ES256, PKCS#1 v1.5 for RS256>"
  }
}
FieldRequirement
idRequired. Base64url (no padding) — this is validated.
typeRequired. Must be "public-key".
rawIdRecommended (standard toJSON() output).
response.authenticatorDataRequired. Base64url of the raw authenticator data.
response.clientDataJSONRequired. Base64url; decodes to JSON containing type: "webauthn.get" and the challenge.
response.signatureRequired. Base64url; DER for ES256, PKCS#1 v1.5 for RS256.
JSON field order is irrelevant. The fields inside response must be base64url; the outer signatures[] string itself may be standard base64 or base64url, padded or not — the decoder accepts all four.

rpId and origin

Dakota does not re-validate rpId or origin server-side. The verifier extracts authenticatorData, clientDataJSON, and signature, checks the challenge binding, and verifies the signature against the registered COSE key. The rpId and origin are cryptographically bound into the signature but are not compared against an expected value. You still need a valid, stable rpId configured in your relying party to complete the browser ceremony in the first place — use your real domain (and keep it consistent between registration and signing). clientDataJSON.type will be "webauthn.get" naturally.

Troubleshooting

SymptomLikely causeFix
WebAuthn challenge hash does not match intentHashChallenge was set to SHA-256(intent) instead of the raw canonical JSON bytes; or the intent was mutated between signing and submissionUse the canonical bytes directly as the challenge, and submit the same intent object you signed.
failed to parse WebAuthn responseA response field (or id) is not base64url, or authenticatorData / clientDataJSON is malformedUse the standard assertion.toJSON() output; do not re-encode its inner fields. id must be base64url with no padding.
failed to unmarshal WebAuthn signatureThe decoded signatures[] entry is not the assertion JSONEnsure each entry is base64( JSON(assertion) ), not the raw assertion object and not just the signature field.
failed to parse WebAuthn public keyThe registered public_key is a PKIX/PEM key, or the COSE algorithm is unsupportedRegister the raw COSE key (1b), and use an ES256 or RS256 credential.
signature verification failedallowCredentials referenced a credential whose key does not match the registered signerConfirm the credential.id you sign with corresponds to the COSE key you registered.
Multi-signer approvals. When a wallet’s policy requires more than one approval, collect one signatures[] entry per signer (each a base64url assertion JSON) and submit them together. WebAuthn and ES256 signatures can be mixed in the same signatures array.