---
title: "Use a passkey"
description: "How to create and unlock a Wavelength wallet using platform passkeys instead of a password."
canonical: https://wavelength.lightning.engineering/guides/use-a-passkey/
---

> Docs index: https://wavelength.lightning.engineering/llms.txt

[Wavelength](/)›Guides›Use a passkey

# Use a passkey

4 min read

Guides

Web SDK

## Create a passkey-protected wallet

[`useWalletPasskey(ceremony)`](/reference/wavelength-react/#useWalletPasskey) 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`](/reference/wavelength-web/#webPasskeyCeremony) from `@lightninglabs/wavelength-web` as the ceremony implementation.

```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 })`](/reference/wavelength-react-native/#createNativePasskeyCeremony) from `@lightninglabs/wavelength-react-native` as the ceremony implementation. The `rpId` domain must be associated with your app first; see [Passkey setup](/react-native/get-started/passkey-setup/).

```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()`](/reference/wavelength-web/#supportsPasskeyPrf)) is memoized: only the first call per ceremony instance actually runs it, and every [`useWalletPasskey`](/reference/wavelength-react/#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](/guides/handle-phases-and-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:

```tsx
// No credentialId: the platform prompts with any discoverable passkey
// registered for this origin.
await open();
```

```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`](/reference/wavelength-core/#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:

```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`](/reference/wavelength-core/#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`](/reference/wavelength-core/#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()`](/reference/wavelength-react/#useWalletCreate) with a new wallet password and the saved recovery phrase; `password` is required by [`CreateWalletRequest`](/reference/wavelength-core/#createWallet). This is why users should record their recovery phrase when they first create the wallet.

```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>;
}
```
