Passkeys
This is a wallet‑centric guide (per FLIP 264: WebAuthn Credential Support) that covers end‑to‑end passkeys integration for Flow:
- Create 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.
This tutorial focuses on the Web Authentication API (WebAuthn) for browser-based applications. Other platforms such as iOS, Android, and desktop applications will require platform-specific APIs (e.g., Apple's Authentication Services, Android's Credential Manager), but the underlying concepts—credential creation, challenge-response signing, and signature formatting—remain the same across all platforms.
What you'll learn
After completing this guide, you'll be able to:
- Create a passkey 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 transaction signature extension
Benefits of using passkeys
Sign transactions securely
Users can sign Flow transactions using passkeys while the private key stays securely stored within the authenticator. This reduces the risk of key extraction attacks and phishing attempts.
Authenticate across devices
Users can scan a QR code displayed on a desktop browser with a mobile device to approve transactions. Cloud-synchronized passkeys (such as those stored in Apple iCloud or Google Password Manager) enable authentication across multiple devices without manual key transfers.
Use hardware security keys
Users can sign transactions with external security keys, such as YubiKeys, to add another layer of protection against phishing and unauthorized access.
Authenticate with platform-based security
Users can sign transactions directly on devices with built-in authenticators, such as Face ID on iPhones or Windows Hello on Windows PCs. This approach enables native transaction signing without needing an external security key.
Recover access with cloud-synced passkeys
Cloud-synced passkeys help users recover access if they lose a device, though this introduces trade-offs between convenience and self-custody (see Limitations of passkeys).
Work with multi-key accounts
Combine passkeys with other authentication types using Flow's native multi-key account support to build secure recovery options and shared access patterns with weighted keys.
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
- Flow accounts and keys: Signature and Hash Algorithms
Registration
When a user generates 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 if the algorithm of the requested passkey is either ES256
or ES256k
. This guide demonstrates an ES256
passkey which translates to an ECDSA_P256
Flow key paired with SHA2_256
hashing. Alternatively, an ES256k
passkey translates to an ECDSA_secp256k1
Flow key paired with SHA2_256
hashing.
High‑level steps:
- On the client, generate
PublicKeyCredentialCreationOptions
with:
pubKeyCredParams
'salg
equal toES256
(-7
)- the RP id is derived from to the web origin
- the challenge equal to an arbitrary constant
- On the client, call
navigator.credentials.create()
. - Verify attestation if necessary and extract the public key (P‑256 in this guide). Convert it to raw uncompressed 64‑byte
X||Y
hex string as 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:
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 (ECDSA on P-256 with SHA-256)_28 // Optionally ES256K (ECDSA on secp256k1 with SHA-256) if the device supports secp256k1 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 ECDSA 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)
For web applications, rpId
is set to window.location.hostname
. For native mobile and desktop applications, use your app's identifier instead:
- iOS: Use your app's bundle identifier (e.g.,
com.example.wallet
) or an associated domain - Android: Use your app's package name (e.g.,
com.example.wallet
) or an associated domain - Desktop: Use your application identifier or registered domain
The rpId must remain consistent across credential creation and assertion for the same user account.
Extract and normalize public key
Client-side example — extract COSE ECDSA 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, 32 - x.length)_44 xy.set(y, 64 - y.length)_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 (or adding key to an existing account) 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 like 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 or random 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_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, prepended by the transaction domain tag), 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
- Credential selection: 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, omittingallowCredentials
is also valid and lets the authenticator surface available credentials. See WebAuthn specifications for guidance. - RP ID consistency: The
rpId
used here must match exactly what was used during credential creation. For non-browser platforms, use the same app identifier (bundle ID, package name, etc.) as in registration.
Convert and attach signature
WebAuthn assertion signatures in this guide 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 transaction 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 transaction 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 ECDSA P‑256 public key for Flow
- Building the correct challenge
- 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
ES256
orES256k
as algorithms to create Flow account compatible keys. - 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).
Limitations of passkeys
Functionality varies by authenticator
Some security keys do not support biometric authentication, requiring users to enter a PIN instead. Because WebAuthn does not provide access to private keys, users must either store their passkey securely or enable cloud synchronization for recovery.
Cloud synchronization introduces risks
Cloud-synced passkeys improve accessibility but also create risks if a cloud provider is compromised or if a user loses access to their cloud account. Users who prefer full self-custody can use hardware-based passkeys that do not rely on cloud synchronization.
Passkeys cannot be exported
Users cannot transfer a passkey between different authenticators. For example, a passkey created on a security key cannot move to another device unless it syncs through a cloud provider. To avoid losing access, users should set up authentication on multiple devices or combine passkeys with multi-key account configurations for additional recovery options.
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:
- Create 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 transaction 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