Skip to main content

Passkeys (WebAuthn)

This is a wallet‑centric guide (per FLIP 264: WebAuthn Credential Support) that covers end‑to‑end WebAuthn integration for Flow:

  1. Register a passkey and add a Flow account key
  2. 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:

  1. On the server, generate PublicKeyCredentialCreationOptions and send to the client.
  2. On the client, call navigator.credentials.create() and return the credential to the server.
  3. 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.
  4. Submit a transaction to add the key to the Flow account with weight and algorithms:
    • Signature algorithm: ECDSA_P256
    • Hash algorithm: SHA2_256
tip

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
_28
const rp = { name: "Passkey Wallet", id: window.location.hostname } as const
_28
const user = {
_28
id: getStableUserIdBytes(), // Uint8Array (16–64 bytes) stable per user
_28
name: "flow-user",
_28
displayName: "Flow User",
_28
} as const
_28
_28
const 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
_28
const 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
_44
import * as CBOR from 'cbor'
_44
_44
function toHex(bytes: Uint8Array): string {
_44
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
_44
}
_44
_44
function 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
_44
function 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
_44
const cred = (await navigator.credentials.create({ publicKey: creationOptions })) as PublicKeyCredential
_44
const att = cred.response as AuthenticatorAttestationResponse
_44
const attObj = new Uint8Array(att.attestationObject as ArrayBuffer)
_44
const cosePubKey = extractCosePublicKeyFromAttestation(attObj)
_44
const 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:


_22
const ACCOUNT_API = "https://wallet.example.com/api/accounts/provision"
_22
_22
export 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
}

note

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
_23
import { encodeMessageFromSignable, encodeTransactionPayload } from '@onflow/fcl'
_23
// Hash/encoding utilities (example libs)
_23
import { sha256 } from '@noble/hashes/sha256'
_23
import { 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
_23
declare const signable: any
_23
declare const address: string
_23
_23
// 1) Encode the signable message for this signer (payload vs envelope)
_23
const msgHex = encodeMessageFromSignable(signable, address)
_23
const payloadMsgHex = encodeTransactionPayload(signable.voucher)
_23
const role = msgHex === payloadMsgHex ? "payload" : "envelope"
_23
_23
// 2) Compute SHA2-256(msgHex) -> 32-byte challenge (Flow keys commonly use SHA2_256)
_23
const signableHash: Uint8Array = sha256(hexToBytes(msgHex))
_23
_23
// 3) Call navigator.credentials.get with challenge = signableHash
_23
// (see next subsection for a full getAssertion example)

note

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)
_23
declare const signableHash: Uint8Array
_23
declare const credentialId: Uint8Array // Credential ID for the active account (from prior auth)
_23
_23
const 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
_23
const assertion = (await navigator.credentials.get({
_23
publicKey: requestOptions,
_23
})) as PublicKeyCredential
_23
_23
const { authenticatorData, clientDataJSON, signature } =
_23
assertion.response as AuthenticatorAssertionResponse

note

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 raw r||s (64 bytes) and attach with addr and keyId.
  • 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.


_27
import { encode as rlpEncode } from 'rlp'
_27
import { bytesToHex } from '@noble/hashes/utils'
_27
_27
// Inputs from previous steps
_27
declare const address: string // 0x-prefixed Flow address
_27
declare const keyId: number // Account key index used for signing
_27
declare const signature: Uint8Array // DER signature from WebAuthn assertion
_27
declare const clientDataJSON: Uint8Array
_27
declare const authenticatorData: Uint8Array
_27
_27
// 1) DER -> raw r||s (64 bytes), implementation below or similar
_27
const rawSig = derToRawRS(signature)
_27
_27
// 2) Build extension_data per FLIP: 0x01 || RLP([authenticatorData, clientDataJSON])
_27
const rlpPayload = rlpEncode([authenticatorData, clientDataJSON]) as Uint8Array | Buffer
_27
const rlpBytes = rlpPayload instanceof Uint8Array ? rlpPayload : new Uint8Array(rlpPayload)
_27
const extension_data = new Uint8Array(1 + rlpBytes.length)
_27
extension_data[0] = 0x01
_27
extension_data.set(rlpBytes, 1)
_27
_27
// 3) Compose Flow signature object
_27
const 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
_38
function 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
_38
function 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
_38
function 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 with SHA2_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 ↔ Flow addr (and keyId) 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