Use a passkey
Create a passkey-protected wallet
useWalletPasskey(ceremony) drives the passkey ceremony and opens the wallet
through the engine, refreshing engine state on success so phase advances
automatically. The ceremony argument is injected from your transport:
supported is boolean | null, not a plain boolean: it starts null while
the platform capability probe is in flight and settles to true or false
once it resolves. Treat null as “still checking” and hold the UI on a
loading state rather than falling into the unsupported branch, or a
capable device will flash a password-only form before flipping to the
passkey option a moment later:
Pass webPasskeyCeremony from @lightninglabs/wavelength-web as the ceremony
implementation.
import { webPasskeyCeremony } from '@lightninglabs/wavelength-web';import { useWalletPasskey } from '@lightninglabs/wavelength-react';
function PasskeyOnboard() { const { create, supported, createPending } = useWalletPasskey(webPasskeyCeremony);
const handleCreate = async () => { const outcome = await create('My Wallet App'); // Persist outcome.credentialId to scope future unlocks. console.log('Wallet ready:', outcome.result.identityPubKey); };
if (supported === null) return <p>Checking device capabilities…</p>; if (!supported) return <p>Passkeys are not supported on this device.</p>;
return ( <button onClick={handleCreate} disabled={createPending}> Create wallet with passkey </button> );}Pass createNativePasskeyCeremony({ rpId }) from
@lightninglabs/wavelength-react-native as the ceremony implementation. The
rpId domain must be associated with your app first; see
Passkey setup.
import { Button, Text } from 'react-native';import { createNativePasskeyCeremony } from '@lightninglabs/wavelength-react-native';import { useWalletPasskey } from '@lightninglabs/wavelength-react';
const ceremony = createNativePasskeyCeremony({ rpId: 'wallet.example.com' });
function PasskeyOnboard() { const { create, supported, createPending } = useWalletPasskey(ceremony);
const handleCreate = async () => { const outcome = await create('My Wallet App'); // Persist outcome.credentialId to scope future unlocks. console.log('Wallet ready:', outcome.result.identityPubKey); };
if (supported === null) return <Text>Checking device capabilities…</Text>; if (!supported) return <Text>Passkeys are not supported on this device.</Text>;
return ( <Button title="Create wallet with passkey" onPress={handleCreate} disabled={createPending} /> );}create(appName) registers a device-bound credential (Face ID, Touch ID,
Windows Hello, or a hardware security key) and derives the wallet seed from
the PRF output. No password is stored or transmitted. Creation and opening
track separately (create/createPending/createError versus
open/openPending/openError) because apps typically render them on
different screens.
The underlying capability probe (ceremony.supportsPasskeyPrf()) is
memoized: only the first call per ceremony instance actually runs it, and
every useWalletPasskey call sharing that instance resolves from the same
promise. Because of this, it is worth warming the probe once at app boot,
before onboarding ever mounts (void webPasskeyCeremony.supportsPasskeyPrf();
alongside your engine setup). By the time the first screen reads supported,
the probe has usually already resolved, so the loading branch above rarely
paints in practice.
Unlock with a passkey
The samples below use the web ceremony for brevity; on React Native, substitute the ceremony you created in the tabbed section above.
When phase === 'locked', call open(credentialId?). The platform presents
the passkey dialog. On success, the engine refreshes and phase advances
through 'syncing' to 'ready' on its own. If the ceremony fails, open
rejects and openError is set; see
Handle phases & errors for how errors
and phases interact more broadly.
Pass the stored credentialId to scope the assertion to the same passkey.
Omit it for a discoverable credential flow, useful when the app has not
persisted a credentialId yet or the user is unlocking from a new device
where a synced passkey is already available:
// No credentialId: the platform prompts with any discoverable passkey// registered for this origin.await open();import { webPasskeyCeremony } from '@lightninglabs/wavelength-web';import { useWallet, useWalletPasskey } from '@lightninglabs/wavelength-react';
function PasskeyUnlock({ credentialId }: { credentialId: string }) { const { phase } = useWallet(); const { open, openPending, openError } = useWalletPasskey(webPasskeyCeremony);
const unlock = async () => { await open(credentialId); };
return ( <div> {phase === 'locked' && ( <button onClick={unlock} disabled={openPending}> Unlock with passkey </button> )} {openError && <p role="alert">{openError.message}</p>} </div> );}Handle a cancelled ceremony
If the user dismisses the OS passkey prompt, create/open reject with a
PasskeyCancelledError instead of a regular failure, and that rejection is
never recorded into createError/openError: a dismissed prompt is not a
failure to display. Check for it with instanceof and treat it as a no-op
rather than showing an error:
import { webPasskeyCeremony } from '@lightninglabs/wavelength-web';import { PasskeyCancelledError, useWalletPasskey } from '@lightninglabs/wavelength-react';
function PasskeyUnlock({ credentialId }: { credentialId: string }) { const { open } = useWalletPasskey(webPasskeyCeremony);
const unlock = async () => { try { await open(credentialId); } catch (err) { if (err instanceof PasskeyCancelledError) { // The user dismissed the prompt; nothing to show. return; } // A genuine failure: openError is already set for a declarative render, // or handle err here for imperative flow. } };
return <button onClick={unlock}>Unlock with passkey</button>;}Troubleshooting: a ceremony that always “cancels”
Browsers collapse most WebAuthn failures into the same NotAllowedError
signal used for a genuine user dismissal: a permissions-policy block, an
iframe restriction, or an rpId that does not match the page’s association
file all surface as PasskeyCancelledError here, indistinguishable from the
user tapping away. If a ceremony instantly “cancels” on every attempt, before
assuming users are backing out, check the configuration angle first: confirm
rpId matches the domain hosting the app, and that the
.well-known/assetlinks.json (Android) or apple-app-site-association (iOS)
association file for that rpId is reachable and lists the app.
Provide a recovery-phrase fallback
If the passkey credential is unavailable (for example, the user is on a new
device or has lost access to the registered authenticator), open rejects
with a genuine error (not a PasskeyCancelledError). Catch it and offer a
recovery-phrase fallback instead of leaving the user stuck.
Every successful passkey outcome includes outcome.result.imported, a
boolean that is true when the ceremony created a new local wallet from the
derived seed (a fresh device) and false when it unlocked an existing local
wallet. outcome.result.mnemonic is populated only when imported is
true, so check imported before reading mnemonic for backup display. To
restore on a new device without a passkey, call create({ password, mnemonic }) from useWalletCreate() with a new wallet password and the
saved recovery phrase; password is required by CreateWalletRequest. This
is why users should record their recovery phrase when they first create the
wallet.
import { useState } from 'react';import { webPasskeyCeremony } from '@lightninglabs/wavelength-web';import { PasskeyCancelledError, useWalletCreate, useWalletPasskey,} from '@lightninglabs/wavelength-react';
function UnlockWithFallback({ credentialId }: { credentialId: string }) { const { open } = useWalletPasskey(webPasskeyCeremony); const { create } = useWalletCreate(); const [recovering, setRecovering] = useState(false); const [password, setPassword] = useState(''); const [mnemonic, setMnemonic] = useState('');
const tryPasskey = async () => { try { await open(credentialId); } catch (err) { if (err instanceof PasskeyCancelledError) return; setRecovering(true); } };
if (recovering) { return ( <form onSubmit={e => { e.preventDefault(); create({ password, mnemonic: mnemonic.trim().split(/\s+/) }); }}> <input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Choose a wallet password" /> <textarea value={mnemonic} onChange={e => setMnemonic(e.target.value)} placeholder="Enter your recovery phrase" /> <button type="submit">Recover wallet</button> </form> ); }
return <button onClick={tryPasskey}>Unlock with passkey</button>;}