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.
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.
0x) - recommended for user wallets. The passkey owns a 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.
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.
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:
gasUsed x gasPrice): paid by the relayer EOA (the faucet key here). The 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.
to): comes from the account's own balance 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.
receive() payable: only lets the account receive LIMO (be funded). payable 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.
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.
// 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.
s > n/2, use n - s.A passkey account is not an EVM 0x account. It is a P-256 Cosmos-SDK account:
secp256r1 (P-256), the curve WebAuthn passkeys use.cosmos prefix (cosmos1...). It has no 20-byte EVM equivalent, so it is funded by a Cosmos bank send, not by an EVM transfer.
MsgSend, staking, gov, etc.).from of a native EVM transaction. The EVM path uses ethsecp256k1; the passkey path is the Cosmos path.
SIGN_MODE_DIRECT only. Multisig,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.
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:
SIGN_MODE_DIRECT sign-bytes (these include chain-id, accountnumber, and sequence).
sha256(signBytes).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.
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:
authenticatorData - raw bytes; the verifier checks the UP and UV flag bits.clientDataJSON - raw bytes; the verifier parses type (must be webauthn.get) and challenge (must equal base64url(sha256(signBytes)), no padding).
signature - the DER signature. WebAuthn signs 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.
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()).
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);
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...
x/paymaster/webauthn/authenticator.go (the SimulatedAuthenticator) emits byte-for-byte what the verifier checks. Read it to see precisely how authenticatorData, clientDataJSON, and the signed digest are built.
x/paymaster/webauthn/verify.go and the ante at ante/cosmos/webauthn_sigverify.go.
x/paymaster/webauthn/assertion.go (Marshal / UnmarshalAssertion).evmd/cmd/passkey-signer builds, passkey-signs, and broadcasts a bank tx usinga simulated authenticator. Good for proving the chain end-to-end from a terminal.
evmd/cmd/passkey-helper is the backend behind the live demo.or PIN gesture is mandatory.
SIGN_MODE_DIRECT only. Anything else falls through to thestandard signature path (which rejects the non-standard signature).
sha256 of the sign-bytes, which bindchain-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.