Limonata← back to site
Testnet (live today)Mainnet (the plan)Passkey (Face ID)Encrypted mempool

Signing with a passkey (Face ID / fingerprint) on Limonata

Limonata can verify WebAuthn passkey signatures natively. A passkey is a P-256 (secp256r1) key created and held inside a device secure element (Apple Secure Enclave, Android StrongBox, Windows Hello, or a security key). The biometric (Face ID, Touch ID, fingerprint) never leaves the device; it only unlocks the key so it can sign. Limonata's ante handler checks the assertion on-chain, so a user can transact with no seed phrase and no browser extension.

This document is the developer guide: how it works, the exact wire format, and how to add "sign with Face ID" to your own app.

There is a live, working demo at limonata.xyz/passkey.


Two ways to use passkeys on Limonata

There are two distinct integrations. Pick based on whether you want an EVM 0x account (visible in MetaMask + the EVM explorer) or a native Cosmos account.

small contract wallet at a deterministic 0x address. It shows in the EVM explorer, can be watched in MetaMask, holds LIMO, and calls any contract. The on-chain WebAuthn check uses the 0x100 precompile. A relayer EOA submits and pays for the tx; the user only signs a WebAuthn challenge and pays no gas.

secp256r1 account (cosmos1...), verified by the chain's ante. No contract, but the account is Cosmos-only (not in MetaMask / the EVM explorer).

Section A is below; section B (the native path) follows it.


A) Smart account (EVM 0x) - recommended

Contracts

PasskeyAccount is a minimal contract wallet that stores the owner passkey public key (P-256 x,y) and exposes execute(to, value, data, auth). It authorizes the call by recomputing the operation challenge and verifying the WebAuthn assertion with the WebAuthn library, which calls the native RIP-7212 precompile at 0x100. A PasskeyAccountFactory deploys accounts at a CREATE2 address derived purely from the public key, so the address is known (and fundable) before deployment.

The challenge bound by the signature is:

challenge = keccak256(abi.encode(block.chainid, account, nonce, to, value, keccak256(data)))

so a signature cannot be replayed across chains, accounts, nonces, or calls. Anyone (a relayer) may submit the tx; only the passkey holder can authorize it.

Live factory on limonata_10777-1: 0x55052a71aacddee0282F74bcC0BAE7B0Df9fae9b. Contracts: WebAuthn.sol, PasskeyAccount.sol, PasskeyAccountFactory.sol.

Who pays gas (and the payable confusion)

In plain EVM, gas is always charged to the EOA that signs and originates the transaction (tx.origin) - never to a contract's balance, no matter how much it holds or whether any function is payable. A PasskeyAccount is a contract, so it can never be a transaction's from; and a passkey user has no EOA at all, only a WebAuthn signature. So a relayer EOA submits the execute() call and pays the gas. The passkey signature authorizes the call (the relayer cannot forge it), and that is what makes a relayer safe.

Three separate things, easy to conflate:

chain base fee is 0 and the relayer uses a tiny 1000 wei gas price, so the fee is ~0 and the user pays nothing. It is not reimbursed (plain relayer sponsorship), and is unrelated to payable.

via to.call{value: value}(data) inside execute(). execute() is deliberately not payable - the relayer calls it with value = 0, so the relayer never supplies the amount; the account does. This is why the account must be funded first.

governs receiving value, never paying gas, and is not on the execute() path.

An account paying its own gas IS possible with ERC-4337 (an EntryPoint + paymaster pulls gas from the account/paymaster deposit) - but this design has no EntryPoint; it is a plain relayer.

Relayer API (reference)

The reference relayer is the same Go service as the native path (evmd/cmd/passkey-helper); the live demo proxies these under /api/passkey/sa/*:

POST /sa/address  { pubkey_b64 }                  -> { address, balance_limo, deployed, relayer }
POST /sa/fund     { pubkey_b64 }                  -> faucet-funds the 0x address (demo only)
POST /sa/prepare  { pubkey_b64, to, value_wei }   -> { address, nonce, needs_deploy, challenge_hex, challenge_b64 }
POST /sa/submit   { pubkey_b64, to, value_wei,    -> { ok, hash, status }   (deploys if needed, then execute)
                    authenticator_data_b64, client_data_json_b64,
                    challenge_index, type_index, r_hex, s_hex }

pubkey_b64 is the passkey public key, either 65-byte uncompressed (0x04||X||Y, the easiest to get from the browser) or 33-byte compressed.

Browser flow

// 1) Create the passkey; derive the uncompressed P-256 key from the SPKI (last 65 bytes).
const cred = await navigator.credentials.create({ publicKey: {
  challenge: crypto.getRandomValues(new Uint8Array(32)),
  rp: { name: "Your app", id: location.hostname },
  user: { id: crypto.getRandomValues(new Uint8Array(16)), name: "user", displayName: "user" },
  pubKeyCredParams: [{ type: "public-key", alg: -7 }],
  authenticatorSelection: { userVerification: "required" }, attestation: "none",
}});
const spki = new Uint8Array(cred.response.getPublicKey());
const pubkey_b64 = btoa(String.fromCharCode(...spki.slice(spki.length - 65))); // 0x04||X||Y

// 2) Prepare the operation -> get the challenge to sign.
const prep = await post("/api/passkey/sa/prepare", { pubkey_b64, to: recipient, value_wei: "10000000000000000" });
const challenge = Uint8Array.from(atob(prep.challenge_b64), c => c.charCodeAt(0));

// 3) Sign with Face ID.
const a = await navigator.credentials.get({ publicKey: {
  challenge, allowCredentials: [{ type: "public-key", id: credId }],
  userVerification: "required", rpId: location.hostname,
}});

// 4) WebAuthn returns a DER signature; parse to (r,s) and normalize to low-s, then submit.
//    (See the demo page source for parseDER + low-s; r_hex/s_hex are 0x 32-byte values.)
const [r_hex, s_hex] = rsFromDER(a.response.signature);
const cdj = new TextDecoder().decode(a.response.clientDataJSON);
await post("/api/passkey/sa/submit", {
  pubkey_b64, to: recipient, value_wei: "10000000000000000",
  authenticator_data_b64: b64(a.response.authenticatorData),
  client_data_json_b64: b64(a.response.clientDataJSON),
  challenge_index: cdj.indexOf('"challenge":"'),
  type_index: cdj.indexOf('"type":"webauthn.get"'),
  r_hex, s_hex,
});

Two browser-side gotchas, both handled in the demo source:

leading zero byte, left-pad to 32 bytes.


B) Native Cosmos passkey account

The account model (read this first)

A passkey account is not an EVM 0x account. It is a P-256 Cosmos-SDK account:

(cosmos1...). It has no 20-byte EVM equivalent, so it is funded by a Cosmos bank send, not by an EVM transfer.

ethsecp256k1; the passkey path is the Cosmos path.

unordered, and amino-JSON passkey txs are not supported.

Gas: the chain's minimum gas price is effectively zero, so a native Cosmos passkey tx costs a negligible fee paid by the passkey account itself (the fee sits in AuthInfo; /prepare with "max" sends the balance minus that fee). This differs from the EVM smart-account path above, where a relayer EOA pays the gas. Protocol-level gas sponsorship is a separate, not-yet-default feature; do not assume it.


How verification works (the protocol side)

When the passkey param is enabled and a tx carries a passkey-packed signature, a dedicated ante decorator runs (otherwise the standard signature path handles everything, so normal txs are unaffected). The decorator:

  1. Recomputes the tx's SIGN_MODE_DIRECT sign-bytes (these include chain-id, account

number, and sequence).

  1. Sets the expected challenge to sha256(signBytes).
  2. Verifies the WebAuthn assertion against the account's secp256r1 public key.

Because the challenge is bound to the sign-bytes, an assertion cannot be replayed against a different tx, chain, account, or sequence. User-verification (UV) is required: the authenticator must report that the user passed a biometric or PIN, not just "user present".

The passkey path is live on testnet and audit-gated before mainnet.


The wire format (the WAS1 signature blob)

A passkey tx is a perfectly normal Cosmos TxRaw. The only special part is the single entry in signatures: instead of a raw 64-byte signature, it is a packed WebAuthn assertion that begins with the ASCII magic prefix WAS1:

WAS1 | uint32_be(len authenticatorData) | authenticatorData
     | uint32_be(len clientDataJSON)     | clientDataJSON
     | signature (ASN.1 DER ECDSA, exactly as a WebAuthn authenticator returns it)

The three pieces come straight from navigator.credentials.get(...).response:

challenge (must equal base64url(sha256(signBytes)), no padding).

sha256( authenticatorData || sha256(clientDataJSON) ), which is exactly what the verifier recomputes.

The public key registered on the account is the 33-byte compressed P-256 key, which is the byte layout of a cosmos-sdk secp256r1.PubKey. The account's pubkey is set from the tx's SignerInfo on the first transaction it sends, like any Cosmos account.


Address derivation

The bech32 address is derived from the compressed P-256 key with the standard ADR-28 hash:

typ      = "cosmos.crypto.secp256r1.PubKey"
addr32   = sha256( sha256(typ) || compressedPubKey33 )      // 32 bytes, no truncation
address  = bech32("cosmos", addr32)                          // cosmos1...

In Go this is simply sdk.AccAddress(secp256r1PubKey.Address()).


Integration path A: use a helper service (fastest)

The browser cannot, on its own, build byte-correct Cosmos SIGN_MODE_DIRECT sign-bytes, so the simplest integration is a small backend that does the protobuf work while the browser does only what must happen on-device (create the passkey, produce the assertion). The reference helper is in this repo at evmd/cmd/passkey-helper; run your own instance and expose these three calls. The live demo proxies to it under /api/passkey/*.

POST /prepare  { pubkey_b64, to, amount }
  -> { address, account_number, sequence, body_b64, authinfo_b64, challenge_b64 }
     (amount may be "max" to send the whole balance minus the fee)

POST /submit   { body_b64, authinfo_b64,
                 authenticator_data_b64, client_data_json_b64, signature_b64 }
  -> { ok, code, hash, log }

POST /fund     { pubkey_b64 }            // demo-only: seeds a new account from the faucet
  -> { funded_address, funder_address, amount, hash }

The browser half:

// 1) Create the passkey (once). Face ID / fingerprint prompts here.
const cred = await navigator.credentials.create({ publicKey: {
  challenge: crypto.getRandomValues(new Uint8Array(32)),
  rp: { name: "Your app", id: location.hostname },
  user: { id: crypto.getRandomValues(new Uint8Array(16)), name: "user", displayName: "user" },
  pubKeyCredParams: [{ type: "public-key", alg: -7 }],   // ES256 / P-256
  authenticatorSelection: { userVerification: "required" },
  attestation: "none",
}});

// The compressed P-256 key = last 65 bytes of the SPKI are 0x04||X||Y; compress to 33.
const spki = new Uint8Array(cred.response.getPublicKey());
const pt = spki.slice(spki.length - 65);                 // 0x04 || X(32) || Y(32)
const pub = new Uint8Array(33);
pub[0] = (pt[33 + 31] & 1) ? 0x03 : 0x02;                // prefix from Y parity
pub.set(pt.slice(1, 33), 1);                             // X
const pubkey_b64 = btoa(String.fromCharCode(...pub));
const credId = new Uint8Array(cred.rawId);

// 2) Ask the backend to build the tx, then sign the challenge on-device.
const prep = await (await fetch("/api/passkey/prepare", { method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ pubkey_b64, to: recipient, amount: "max" }) })).json();

const challenge = Uint8Array.from(atob(prep.challenge_b64), c => c.charCodeAt(0));
const a = await navigator.credentials.get({ publicKey: {
  challenge,                                              // = sha256(signBytes)
  allowCredentials: [{ type: "public-key", id: credId }],
  userVerification: "required", rpId: location.hostname,
}});
const r = a.response;
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));

// 3) Submit. The backend packs the WAS1 blob and broadcasts.
const out = await (await fetch("/api/passkey/submit", { method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    body_b64: prep.body_b64, authinfo_b64: prep.authinfo_b64,
    authenticator_data_b64: b64(r.authenticatorData),
    client_data_json_b64: b64(r.clientDataJSON),
    signature_b64: b64(r.signature),
  }) })).json();
console.log(out.ok ? "tx " + out.hash : "rejected: " + out.log);

Integration path B: build it fully client-side

If you would rather not run a backend, build the sign-bytes in the browser with cosmjs and pack the blob yourself. The only P-256-specific parts are the pubkey Any and the address.

import { makeAuthInfoBytes, makeSignDoc } from "@cosmjs/proto-signing";
import { TxBody, TxRaw, SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx";
import { SignMode } from "cosmjs-types/cosmos/tx/signing/v1beta1/signing";
import { PubKey } from "cosmjs-types/cosmos/crypto/secp256r1/keys";
import { sha256 } from "@cosmjs/crypto";

// pubkey Any for a P-256 passkey:
const pubkeyAny = {
  typeUrl: "/cosmos.crypto.secp256r1.PubKey",
  value: PubKey.encode({ key: compressedPubKey33 }).finish(),
};

const bodyBytes = TxBody.encode(TxBody.fromPartial({ messages: [msgSendAny] })).finish();
const authInfoBytes = makeAuthInfoBytes(
  [{ pubkey: pubkeyAny, sequence }], fee, gasLimit, undefined, undefined,
  SignMode.SIGN_MODE_DIRECT);
const signDoc = makeSignDoc(bodyBytes, authInfoBytes, chainId, accountNumber);
const signBytes = SignDoc.encode(signDoc).finish();
const challenge = sha256(signBytes);                      // pass this to credentials.get

// ... call navigator.credentials.get({ publicKey: { challenge, ... } }) ...

// pack WAS1 = "WAS1" | u32be(len ad) | ad | u32be(len cdj) | cdj | derSig
function u32be(n){ const b=new Uint8Array(4); new DataView(b.buffer).setUint32(0,n); return b; }
const ad = new Uint8Array(r.authenticatorData), cdj = new Uint8Array(r.clientDataJSON), sig = new Uint8Array(r.signature);
const was1 = new Uint8Array([...new TextEncoder().encode("WAS1"),
  ...u32be(ad.length), ...ad, ...u32be(cdj.length), ...cdj, ...sig]);

const txBytes = TxRaw.encode(TxRaw.fromPartial({ bodyBytes, authInfoBytes, signatures: [was1] })).finish();
// broadcast txBytes via CometBFT RPC /broadcast_tx_sync (base64) or a tendermint client.

Deriving the address client-side:

import { sha256 } from "@cosmjs/crypto";
import { toBech32 } from "@cosmjs/encoding";
const typ = new TextEncoder().encode("cosmos.crypto.secp256r1.PubKey");
const addr = sha256(new Uint8Array([...sha256(typ), ...compressedPubKey33]));  // 32 bytes
const bech = toBech32("cosmos", addr);                                          // cosmos1...

Reference implementations

SimulatedAuthenticator) emits byte-for-byte what the verifier checks. Read it to see precisely how authenticatorData, clientDataJSON, and the signed digest are built.

ante/cosmos/webauthn_sigverify.go.

a simulated authenticator. Good for proving the chain end-to-end from a terminal.


Constraints and security notes

or PIN gesture is mandatory.

standard signature path (which rejects the non-standard signature).

chain-id, account number, and sequence.

type, and the UV/UP flags, but does not pin the relying-party id or origin. Treat that as the client's responsibility for now (set rp.id / rpId to your domain); the mainnet audit may tighten this server-side.

Do not assume final mainnet semantics until the audit lands.

Canonical source: PASSKEY.md · explorer