Use a passkey

4 min read
Guides
Web SDK

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.

passkey.tsx
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.

passkey.tsx
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();
passkey.tsx
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:

passkey.tsx
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.

passkey.tsx
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>;
}