Passkeys (WebAuthn)
This is a wallet‑centric guide (per FLIP 264: WebAuthn Credential Support) that covers end‑to‑end WebAuthn integration for Flow:
- Register a passkey and add a Flow account key
- Sign a transaction with the user’s passkey (includes conversion, extension, and submission)
It accompanies the PoC in fcl-js/packages/passkey-wallet
for reference and cites the FLIP where behavior is normative.
What you’ll learn
After completing this guide, you'll be able to:
- Register a WebAuthn credential and derive a Flow‑compatible public key
- Generate the correct challenge for signing transactions (wallet sets SHA2‑256(signable))
- Convert a WebAuthn ECDSA DER signature into Flow’s raw r||s format and attach the FLIP signature extension
Prerequisites
- Working knowledge of modern frontend (React/Next.js) and basic backend
- Familiarity with WebAuthn/Passkeys concepts and platform constraints
- FCL installed and configured for your app
- A plan for secure backend entropy (32‑byte minimum) and nonce persistence
- Flow accounts and keys: Signature and Hash Algorithms
Registration
When a user registers a passkey via navigator.credentials.create() with { publicKey }
, the authenticator returns an attestation containing the new credential’s public key. On Flow, you can register that public key on an account as ECDSA_P256
or ECDSA_secp256k1
. This guide demonstrates ECDSA_P256
paired with SHA2_256
hashing.
High‑level steps:
- On the server, generate
PublicKeyCredentialCreationOptions
and send to the client. - On the client, call
navigator.credentials.create()
and return the credential to the server. - Verify attestation if necessary and extract the COSE public key (P‑256 in this guide). Convert it to raw uncompressed 64‑byte
X||Y
hex expected by Flow. - Submit a transaction to add the key to the Flow account with weight and algorithms:
- Signature algorithm:
ECDSA_P256
- Hash algorithm:
SHA2_256
- Signature algorithm:
Libraries like SimpleWebAuthn can parse the COSE key and produce the raw public key bytes required for onchain registration. Ensure you normalize into the exact raw byte format Flow expects before writing to the account key.
Build creation options and create credential
Minimum example — wallet‑mode registration (challenge can be constant per FLIP):
This builds PublicKeyCredentialCreationOptions
for a wallet RP with a constant registration challenge and ES256 (P‑256) so the resulting public key can be registered on a Flow account.
_28// In a wallet (RP = wallet origin). The challenge satisfies API & correlates request/response._28// Use a stable, opaque user.id per wallet user (do not randomize per request)._28_28const rp = { name: "Passkey Wallet", id: window.location.hostname } as const_28const user = {_28 id: getStableUserIdBytes(), // Uint8Array (16–64 bytes) stable per user_28 name: "flow-user",_28 displayName: "Flow User",_28} as const_28_28const creationOptions: PublicKeyCredentialCreationOptions = {_28 challenge: new TextEncoder().encode("flow-wallet-register"), // constant is acceptable in wallet-mode; wallet providers may choose and use a constant value as needed for correlation_28 rp,_28 user,_28 pubKeyCredParams: [_28 { type: "public-key", alg: -7 }, // ES256 (P-256 + SHA-256)_28 // Optionally ES256K if you support secp256k1 Flow keys:_28 // { type: "public-key", alg: -47 },_28 ],_28 authenticatorSelection: { userVerification: "preferred" },_28 timeout: 60_000,_28 attestation: "none",_28}_28_28const credential = await navigator.credentials.create({ publicKey: creationOptions })_28_28// Send to wallet-core (or local) to extract COSE P-256 public key (verify attestation if necessary)_28// Then register the raw uncompressed key bytes on the Flow account as ECDSA_P256/SHA2_256 (this guide’s choice)
Extract and normalize public key
Client-side example — extract COSE public key (no verification) and derive raw uncompressed 64-byte X||Y hex suitable for Flow key registration:
This parses the attestationObject
to locate the COSE EC2 credentialPublicKey
, reads the x/y coordinates, and returns raw uncompressed 64-byte X||Y
hex suitable for Flow key registration. Attestation verification is intentionally omitted here.
_44// Uses a small CBOR decoder (e.g., 'cbor' or 'cbor-x') to parse attestationObject_44import * as CBOR from 'cbor'_44_44function toHex(bytes: Uint8Array): string {_44 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')_44}_44_44function extractCosePublicKeyFromAttestation(attObj: Uint8Array): Uint8Array {_44 // attestationObject is a CBOR map with 'authData'_44 const decoded: any = CBOR.decode(attObj)_44 const authData = new Uint8Array(decoded.authData)_44_44 // Parse authData (WebAuthn spec):_44 // rpIdHash(32) + flags(1) + signCount(4) = 37 bytes header_44 let offset = 37_44 // aaguid (16)_44 offset += 16_44 // credentialId length (2 bytes, big-endian)_44 const credIdLen = (authData[offset] << 8) | authData[offset + 1]_44 offset += 2_44 // credentialId (credIdLen bytes)_44 offset += credIdLen_44 // The next CBOR structure is the credentialPublicKey (COSE key)_44 return authData.slice(offset)_44}_44_44function coseEcP256ToUncompressedXYHex(coseKey: Uint8Array): string {_44 // COSE EC2 key is a CBOR map; for P-256, x = -2, y = -3_44 const m: Map<number, any> = CBOR.decode(coseKey)_44 const x = new Uint8Array(m.get(-2))_44 const y = new Uint8Array(m.get(-3))_44 if (x.length !== 32 || y.length !== 32) throw new Error('Invalid P-256 coordinate lengths')_44 const xy = new Uint8Array(64)_44 xy.set(x, 0)_44 xy.set(y, 32)_44 return toHex(xy) // 64-byte X||Y hex, no 0x or 0x04 prefix_44}_44_44// Usage_44const cred = (await navigator.credentials.create({ publicKey: creationOptions })) as PublicKeyCredential_44const att = cred.response as AuthenticatorAttestationResponse_44const attObj = new Uint8Array(att.attestationObject as ArrayBuffer)_44const cosePubKey = extractCosePublicKeyFromAttestation(attObj)_44const publicKeyHex = coseEcP256ToUncompressedXYHex(cosePubKey)
Add key to account
Now that you have the user's public key, provision a Flow account with that key. Creating accounts requires payment; in practice, account instantiation typically occurs on the wallet provider's backend service.
In the PoC demo, we used a test API to provision an account with the public key:
_22const ACCOUNT_API = "https://wallet.example.com/api/accounts/provision"_22_22export async function createAccountWithPublicKey(_22 publicKeyHex: string,_22 _opts?: {signAlgo?: number; hashAlgo?: number; weight?: number}_22): Promise<string> {_22 const trimmed = publicKeyHex_22 const body: ProvisionAccountRequest = {_22 publicKey: trimmed,_22 signatureAlgorithm: "ECDSA_P256",_22 hashAlgorithm: "SHA2_256",_22 }_22 const res = await fetch(ACCOUNT_API, {_22 method: "POST",_22 headers: {Accept: "application/json", "Content-Type": "application/json"},_22 body: JSON.stringify(body),_22 })_22 if (!res.ok) throw new Error(`Account API error: ${res.status}`)_22 const json = (await res.json()) as ProvisionAccountResponse_22 if (!json?.address) throw new Error("Account API missing address in response")_22 return json.address_22}
In production, this would be a service owned by the wallet provider that creates the account and attaches the user's public key, for reasons outlined in WebAuthn Credential Support (FLIP) (e.g., payment handling, abuse prevention, telemetry, and correlation as needed).
Signing
Generate the challenge
- Assertion (transaction signing): Wallet sets
challenge
to the SHA2‑256 of the signable transaction message (payload or envelope per signer role). No server‑sent challenge is used. Flow includes a domain‑separation tag in the signable bytes.
Minimal example — derive signable message and hash (per FLIP):
Compute the signer‑specific signable message and hash it with SHA2‑256 to produce the WebAuthn challenge
(no server‑generated nonce is used in wallet mode).
_23// Imports for helpers used to build the signable message_23import { encodeMessageFromSignable, encodeTransactionPayload } from '@onflow/fcl'_23// Hash/encoding utilities (example libs)_23import { sha256 } from '@noble/hashes/sha256'_23import { hexToBytes } from '@noble/hashes/utils'_23_23// Inputs:_23// - signable: object containing the voucher/payload bytes (e.g., from a ready payload)_23// - address: the signing account address (hex string)_23_23declare const signable: any_23declare const address: string_23_23// 1) Encode the signable message for this signer (payload vs envelope)_23const msgHex = encodeMessageFromSignable(signable, address)_23const payloadMsgHex = encodeTransactionPayload(signable.voucher)_23const role = msgHex === payloadMsgHex ? "payload" : "envelope"_23_23// 2) Compute SHA2-256(msgHex) -> 32-byte challenge (Flow keys commonly use SHA2_256)_23const signableHash: Uint8Array = sha256(hexToBytes(msgHex))_23_23// 3) Call navigator.credentials.get with challenge = signableHash_23// (see next subsection for a full getAssertion example)
encodeMessageFromSignable
and encodeTransactionPayload
are FCL‑specific helpers. If you are not using FCL, construct the Flow signable transaction message yourself (payload for proposer/authorizer, envelope for payer), then compute SHA2‑256(messageBytes)
for the challenge. The payload encoding shown here applies regardless of wallet implementation; the helper calls are simply conveniences from FCL.
Request assertion
Minimal example — wallet assertion:
Build PublicKeyCredentialRequestOptions and request an assertion using the transaction hash as challenge
. rpId
must match the wallet domain. When the wallet has mapped the active account to a credential, include allowCredentials
with that credential ID to avoid extra prompts; omitting it is permissible for discoverable credentials. You will invoke navigator.credentials.get().
_23// signableHash is SHA2-256(signable message: payload or envelope)_23declare const signableHash: Uint8Array_23declare const credentialId: Uint8Array // Credential ID for the active account (from prior auth)_23_23const requestOptions: PublicKeyCredentialRequestOptions = {_23 challenge: signableHash,_23 rpId: window.location.hostname,_23 userVerification: "preferred",_23 timeout: 60_000,_23 allowCredentials: [_23 {_23 type: "public-key",_23 id: credentialId,_23 },_23 ],_23}_23_23const assertion = (await navigator.credentials.get({_23 publicKey: requestOptions,_23})) as PublicKeyCredential_23_23const { authenticatorData, clientDataJSON, signature } =_23 assertion.response as AuthenticatorAssertionResponse
Wallets typically know which credential corresponds to the user’s active account (selected during authentication/authorization), so they should pass that credential via allowCredentials
to scope selection and minimize prompts. For discoverable credentials, omitting allowCredentials
is also valid and lets the authenticator surface available credentials. See WebAuthn Credential Support (FLIP) for wallet‑mode guidance.
Convert and attach signature
WebAuthn assertion signatures are ECDSA P‑256 over SHA‑256 and are typically returned in ASN.1/DER form. Flow expects raw 64‑byte signatures: r
and s
each 32 bytes, concatenated (r || s
).
- Convert the DER
signature
to Flow rawr||s
(64 bytes) and attach withaddr
andkeyId
. - Build the signature extension as specified:
extension_data = 0x01 || RLP([authenticatorData, clientDataJSON])
.
Minimal example — convert and attach for submission:
Convert the DER signature to Flow raw r||s and build signatureExtension = 0x01 || RLP([authenticatorData, clientDataJSON])
per the FLIP, then compose the Flow signature object for inclusion in your transaction.
_27import { encode as rlpEncode } from 'rlp'_27import { bytesToHex } from '@noble/hashes/utils'_27_27// Inputs from previous steps_27declare const address: string // 0x-prefixed Flow address_27declare const keyId: number // Account key index used for signing_27declare const signature: Uint8Array // DER signature from WebAuthn assertion_27declare const clientDataJSON: Uint8Array_27declare const authenticatorData: Uint8Array_27_27// 1) DER -> raw r||s (64 bytes), implementation below or similar_27const rawSig = derToRawRS(signature)_27_27// 2) Build extension_data per FLIP: 0x01 || RLP([authenticatorData, clientDataJSON])_27const rlpPayload = rlpEncode([authenticatorData, clientDataJSON]) as Uint8Array | Buffer_27const rlpBytes = rlpPayload instanceof Uint8Array ? rlpPayload : new Uint8Array(rlpPayload)_27const extension_data = new Uint8Array(1 + rlpBytes.length)_27extension_data[0] = 0x01_27extension_data.set(rlpBytes, 1)_27_27// 3) Compose Flow signature object_27const flowSignature = {_27 addr: address, // e.g., '0x1cf0e2f2f715450'_27 keyId, // integer key index_27 signature: '0x' + bytesToHex(rawSig),_27 signatureExtension: extension_data,_27}
Submit the signature
Return the signature data to the application that initiated signing. The application should attach it to the user transaction for the signer (addr
, keyId
) and submit the transaction to the network.
See Transactions for how signatures are attached per signer role (payload vs envelope) and how submissions are finalized.
Helper: derToRawRS
_38// Minimal DER ECDSA (r,s) -> raw 64-byte r||s_38function derToRawRS(der: Uint8Array): Uint8Array {_38 let offset = 0_38 if (der[offset++] !== 0x30) throw new Error("Invalid DER sequence")_38 const seqLen = der[offset++] // assumes short form_38 if (seqLen + 2 !== der.length) throw new Error("Invalid DER length")_38_38 if (der[offset++] !== 0x02) throw new Error("Missing r INTEGER")_38 const rLen = der[offset++]_38 let r = der.slice(offset, offset + rLen)_38 offset += rLen_38 if (der[offset++] !== 0x02) throw new Error("Missing s INTEGER")_38 const sLen = der[offset++]_38 let s = der.slice(offset, offset + sLen)_38_38 // Strip leading zeros and left-pad to 32 bytes_38 r = stripLeadingZeros(r)_38 s = stripLeadingZeros(s)_38 const r32 = leftPad32(r)_38 const s32 = leftPad32(s)_38 const raw = new Uint8Array(64)_38 raw.set(r32, 0)_38 raw.set(s32, 32)_38 return raw_38}_38_38function stripLeadingZeros(bytes: Uint8Array): Uint8Array {_38 let i = 0_38 while (i < bytes.length - 1 && bytes[i] === 0x00) i++_38 return bytes.slice(i)_38}_38_38function leftPad32(bytes: Uint8Array): Uint8Array {_38 if (bytes.length > 32) throw new Error("Component too long")_38 const out = new Uint8Array(32)_38 out.set(bytes, 32 - bytes.length)_38 return out_38}
Notes from the PoC
- The PoC in
fcl-js/packages/passkey-wallet
demonstrates end‑to‑end flows for passkey creation and assertion, including:- Extracting and normalizing the P‑256 public key for Flow
- Generating secure nonces and verifying account‑proof
- Converting DER signatures to raw
r||s
- Packaging WebAuthn fields as signature extension data
Align your implementation with the FLIP to ensure your extension payloads and verification logic match network expectations.
Security and UX considerations
-
Use
ECDSA_P256
withSHA2_256
for Flow account keys derived from WebAuthn P‑256. -
Enforce nonce expiry, single‑use semantics, and strong server‑side randomness.
-
Clearly communicate platform prompts and recovery paths; passkeys UX can differ across OS/browsers.
-
Replay protection: Flow uses on‑chain proposal‑key sequence numbers; see Replay attacks.
-
Optional wallet backend: store short‑lived correlation data or rate‑limits as needed (not required).
Credential management (wallet responsibilities)
Wallet providers should persist credential metadata to support seamless signing, rotation, and recovery:
- Map
credentialId
↔ Flowaddr
(andkeyId
) for the active account - Store
rpId
, user handle, and (optionally)aaguid
/attestation info for risk decisions - Support multiple credentials per account and revocation/rotation workflows
- Enforce nonce/sequence semantics and rate limits server-side as needed
See WebAuthn Credential Support (FLIP) for rationale and wallet‑mode guidance.
Conclusion
In this tutorial, you integrated passkeys (WebAuthn) with Flow for both registration and signing.
Now that you have completed the tutorial, you should be able to:
- Register a WebAuthn credential and derive a Flow‑compatible public key
- Generate the correct challenge for signing transactions (wallet sets SHA2‑256(signable))
- Convert a WebAuthn ECDSA DER signature into Flow’s raw r||s format and attach the FLIP signature extension
Further reading
- Review signing flows and roles: Transactions
- Account keys: Signature and Hash Algorithms
- Web Authentication API (MDN): Web Authentication API
- Flow Client Library (FCL): Flow Client Library
- Wallet Provider Spec: Wallet Provider Spec
- Track updates: FLIP 264: WebAuthn Credential Support