> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Passkey

> Implement passkey (WebAuthn) authentication with the Prelude JavaScript SDK.

Passkeys serve two roles on the SDK:

* **MFA step-up factor** — the user already has an authenticated session and proves a passkey to acquire a sensitive scope. Driven by `continueWithPasskey`.
* **Primary-factor sign-in** — the user signs in with just a passkey, no email/phone OTP, no password. Driven by `loginWithPasskey`. Requires `PasskeyConfig.login_enabled` server-side.

For the full conceptual reference — ceremony shape, security model, error catalogue — see the [Passkey](/auth/documentation/passkey) page. For the backend setup, see the [Passkey integration guide](/auth/documentation/integration-guide/passkey).

## Detect WebAuthn support

Gate the UI on the `isPasskeySupported()` helper. Returns `false` on browsers without a usable WebAuthn implementation (older Safari, headless contexts, some embedded webviews).

```javascript theme={null}
import { isPasskeySupported } from "@prelude.so/js-sdk";

if (!isPasskeySupported()) {
  // Skip passkey entirely; offer SMS / email / password instead.
}
```

## Register a passkey

Registration runs inside an authenticated session and consumes the `prld:passkey:write` scope, typically obtained via a step-up challenge just before enrolment so adding an authenticator always requires an additional ownership proof.

```javascript theme={null}
import { PrldErrors } from "@prelude.so/js-sdk";

try {
  const { credential, alreadyRegistered } = await client.registerPasskey({
    username: "user@example.com",   // shown by the authenticator
    displayName: "User",            // optional, defaults to username
    nickname: "MacBook",            // optional, server-side label
  });

  if (alreadyRegistered) {
    // The same authenticator was already registered for this user — no-op success.
  }
} catch (error) {
  if (error instanceof PrldErrors.PasskeyNotSupported) {
    // Browser does not expose WebAuthn
  } else if (error instanceof PrldErrors.PasskeyNotConfigured) {
    // The app has no PasskeyConfig
  } else if (error instanceof PrldErrors.InsufficientScope) {
    // Session does not hold prld:passkey:write — drive a step-up first.
  } else if (error instanceof PrldErrors.PasskeyRegistrationFailed) {
    // Bad challenge, mismatched origin, excluded credential, or an
    // authenticator that refused to create a discoverable credential
    // when login_enabled is on.
  } else if (error instanceof PrldErrors.RateLimited) {
    // Too many begin ceremonies in the window
  }
}
```

| Field         | Required | Description                                                                                   |
| ------------- | -------- | --------------------------------------------------------------------------------------------- |
| `username`    | Yes      | WebAuthn `user.name` shown by the authenticator. Typically the user's primary email or phone. |
| `displayName` | No       | WebAuthn `user.displayName` shown alongside the name. Falls back to `username`.               |
| `nickname`    | No       | Server-side-only label for the "Manage your passkeys" UI ("MacBook", "YubiKey 5C").           |

A user may register multiple credentials. The registration ceremony pre-populates `excludeCredentials` so the same authenticator can't be registered twice — duplicate registration is the idempotent `alreadyRegistered: true` path.

The SDK invalidates its cached session and refreshes after a successful enrolment, so the next access token reflects the consumed scope and the (optionally mapped) `has_passkey` claim.

## Sign in with a passkey (primary factor)

`loginWithPasskey` opens a WebAuthn ceremony with empty `allowCredentials` so the browser surfaces every discoverable credential it holds for the configured RPID. The server resolves the user from the assertion's `userHandle` — no identifier is sent from the client.

Requires `PasskeyConfig.login_enabled` server-side. While the flag is on, registration also requests `residentKey: required` so the credential is discoverable.

```javascript theme={null}
import { PrldErrors } from "@prelude.so/js-sdk";

try {
  await client.loginWithPasskey();
  // User is now authenticated; the session was minted with login_method: "passkey".
} catch (error) {
  if (error instanceof PrldErrors.PasskeyNotSupported) {
    // No WebAuthn in this browser
  } else if (error instanceof PrldErrors.PasskeyNotConfigured) {
    // login_enabled is false server-side
  } else if (error instanceof PrldErrors.Unauthorized) {
    // No matching passkey, or the assertion failed verification.
    // The server deliberately collapses unknown user / bad signature
    // into a single 401 so attackers cannot probe valid users.
  } else if (error instanceof PrldErrors.RateLimited) {
    // Too many begin ceremonies on the app
  }
}
```

### Conditional UI (autofill)

Pass `mediation: "conditional"` to surface matching credentials in the username-field autofill chip instead of a modal authenticator picker. Pair with an `AbortSignal` so the SDK call cancels cleanly when the user picks a different sign-in method.

```javascript theme={null}
const controller = new AbortController();

// Wire the controller to your "Sign in with password" button click handler
// so it aborts the conditional request if the user picks a different method.

try {
  await client.loginWithPasskey({
    mediation: "conditional",
    signal: controller.signal,
  });
} catch (error) {
  if (error.name === "AbortError") {
    // User picked a different method — expected
  }
}
```

## Complete a verify\_passkey step-up step

When `requestStepUp` returns a challenge whose first step is `verify_passkey`, complete it with `continueWithPasskey`. The SDK caches the assertion options under the challenge id, so the call is parameter-light:

```javascript theme={null}
import { PrldErrors } from "@prelude.so/js-sdk";

let challengeId;

await client.requestStepUp({
  scope: "transfer:write",
  onChallenge: (info) => {
    challengeId = info.challengeId;
    if (info.currentStep !== "verify_passkey") {
      // Fall back to OTP / custom step UI.
    }
  },
});

if (challengeId) {
  try {
    await client.continueWithPasskey({ challengeId });
    // Session is refreshed; the access token now carries "transfer:write".
  } catch (error) {
    if (error instanceof PrldErrors.PasskeyStepUnavailable) {
      // No registered credentials, assertion failed, or the cached
      // challenge doesn't have a verify_passkey step. Route to a fallback.
    }
  }
}
```

`continueWithPasskey` runs `navigator.credentials.get()` against the cached options, posts the assertion to `/stepup/continue`, and the SDK refreshes the session automatically.

## Manage registered passkeys

A "Manage your passkeys" UI uses three endpoints under `/me/passkeys`.

### List

```javascript theme={null}
const passkeys = await client.listPasskeys();
// [
//   {
//     credential_id: "XKv4eJk7mGmJYI4r-hZxxBg",
//     nickname: "MacBook",
//     transports: ["internal", "hybrid"],
//     backup_state: true,
//     created_at: 1717689600,
//     last_used_at: 1718901234,
//   },
//   ...
// ]
```

Returns an empty array when the user has none — not a 404 — so a settings page can render without special-casing.

### Rename

```javascript theme={null}
await client.renamePasskey(credential.credential_id, "iPad");
```

Renaming the label requires `prld:passkey:write`, the same scope as registration — drive a step-up first if the session doesn't hold it. Pass an empty string to clear the label.

### Delete

Deletion requires `prld:passkey:write` — removing an authenticator is a sensitive operation, so it needs the same fresh step-up as registration rather than relying on the ambient session.

```javascript theme={null}
import { PrldErrors } from "@prelude.so/js-sdk";

try {
  await client.deletePasskey(credential.credential_id);
} catch (error) {
  if (error instanceof PrldErrors.InsufficientScope) {
    // Session does not hold prld:passkey:write — drive a step-up first.
  } else if (error instanceof PrldErrors.NotFound) {
    // Credential id doesn't match anything for this user
  }
}
```

The SDK invalidates the cached session and refreshes after a successful delete since removing a credential can flip the `has_passkey` custom claim.

<Accordion title="Try it" icon="flask">
  A passkey is **never the first thing a user sets up** — registration runs inside an authenticated session and consumes the `prld:passkey:write` scope. So a working demo needs two things the snippets above leave out:

  * **A primary login method** to establish the session before any passkey exists. Here we use email OTP. The same first screen also offers `loginWithPasskey`, so a returning user can sign in with a passkey directly instead of email — on a fresh account that button has nothing to match yet, which is exactly why the email path is needed to bootstrap the first credential.
  * **A step-up to obtain `prld:passkey:write` before enrolment.** After login the enrollment screen has two explicit buttons: **Grant scope** runs `requestStepUp({ scope: "prld:passkey:write" })`, and **Register a passkey** then calls `registerPasskey`. To keep the demo short we configure the scope in `direct` mode with `status: "continue"` and `grant_mode: "session-bound"`, so the grant is immediate, requires no extra OTP challenge, and stays on the session — grant once, then register as many times as you like. In production, gate it behind a real `verify_email` / `verify_sms` / `verify_passkey` step so adding an authenticator always requires a fresh ownership proof.

  **1. Register the scope**

  ```bash theme={null}
  curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/scopes \
    -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{ "scope": "prld:passkey:write" }'
  ```

  **2. Configure a direct step-up for `prld:passkey:write`**

  The step-up resolves inline (`mode: "direct"`), so no delegation hook is needed and `jwks_url` can stay empty. `status: "continue"` grants the scope immediately with no steps. (For a real ownership proof, switch to `status: "review"` and add a `steps` entry — see [Change Password](/auth/documentation/frontend-sdks/web/change-password) for the OTP-gated shape.)

  ```bash theme={null}
  curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/config/stepup \
    -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{
      "jwks_url": "",
      "step_keys": [],
      "allowed_scopes": [
        {
          "scope": "prld:passkey:write",
          "mode": "direct",
          "direct": {
            "identifier_types": ["email_address", "phone_number"],
            "status": "continue",
            "grant_mode": "session-bound",
            "granted_for": 600
          }
        }
      ]
    }'
  ```

  **3. Configure the PasskeyConfig**

  Point the Relying Party at your dev origin and set `login_enabled: true` so the "Sign in with a passkey" button works. `allowed_origins` must list **your localhost** (scheme + host + port) — here the Vite dev server on `http://localhost:5173`; with `rp_id: "localhost"`, adjust the port to match wherever `npm run dev` serves.

  ```bash theme={null}
  curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/config/passkey \
    -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{
      "rp_id": "localhost",
      "rp_name": "Prelude Playground (local)",
      "allowed_origins": ["http://localhost:5173"],
      "user_verification": "required",
      "attestation_preference": "none",
      "login_enabled": true
    }'
  ```

  Without `login_enabled: true`, `loginWithPasskey` returns `PasskeyNotConfigured` and only the email path works.

  **4. Replace `src/App.jsx`**

  ```jsx src/App.jsx theme={null}
  import "@picocss/pico";
  import { useState } from "react";
  import { PrldSessionClient, PrldErrors, isPasskeySupported } from "@prelude.so/js-sdk";

  const client = new PrldSessionClient({ domain: `${import.meta.env.VITE_APP_ID}.session.prelude.dev` });

  export default function App() {
    const [view, setView] = useState("login"); // login | code | enroll
    const [email, setEmail] = useState("");
    const [code, setCode] = useState("");
    const [scoped, setScoped] = useState(false);
    const [registered, setRegistered] = useState(false);
    const [error, setError] = useState(null);

    // ── Login: email OTP ────────────────────────────────────────────
    const handleSendOTP = async (e) => {
      e.preventDefault();
      setError(null);
      try {
        await client.startOTP({ identifier: { type: "email_address", value: email } });
        setView("code");
      } catch (err) {
        setError(err.message);
      }
    };

    const handleCheckLogin = async (e) => {
      e.preventDefault();
      setError(null);
      try {
        await client.checkOTP({ code });
        setView("enroll");
      } catch (err) {
        if (err instanceof PrldErrors.BadCheckCode) setError("Wrong code.");
        else setError(err.message);
      }
    };

    // ── Login: passkey as a primary factor ──────────────────────────
    const handleLoginPasskey = async () => {
      setError(null);
      try {
        await client.loginWithPasskey();
        setView("enroll");
      } catch (err) {
        if (err instanceof PrldErrors.Unauthorized) {
          setError("No passkey matched — sign in with email to enroll your first one.");
        } else {
          setError(err.message);
        }
      }
    };

    // ── Step 1: grant the scope (direct step-up, immediate) ─────────
    const handleGrantScope = async () => {
      setError(null);
      try {
        const { status } = await client.requestStepUp({ scope: "prld:passkey:write" });
        if (status === "continue") setScoped(true); // session refreshed with the scope
        else if (status === "block") setError("Scope denied by configuration.");
      } catch (err) {
        setError(err.message);
      }
    };

    // ── Step 2: register, now that the session holds the scope ──────
    const handleRegister = async () => {
      setError(null);
      try {
        await client.registerPasskey({ username: email || "user@example.com" });
        setRegistered(true); // session-bound scope persists — can register again
      } catch (err) {
        if (err instanceof PrldErrors.InsufficientScope) {
          setError("Grant prld:passkey:write first.");
        } else {
          setError(err.message);
        }
      }
    };

    // ── Render ──────────────────────────────────────────────────────
    return (
      <>
        <style>{`body, #root { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 0; }`}</style>
        <main style={{ textAlign: "center", maxWidth: "600px", width: "100%" }}>
          <h1>Passkeys</h1>
          {!isPasskeySupported() && <p>WebAuthn not supported in this browser.</p>}
          {error && <p role="alert" style={{ color: "var(--pico-color-red-500)" }}>{error}</p>}

          {view === "login" && (
            <>
              <form onSubmit={handleSendOTP}>
                <input type="email" placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
                <button type="submit">Send email code</button>
              </form>
              <button className="secondary" onClick={handleLoginPasskey} disabled={!isPasskeySupported()}>
                Sign in with a passkey
              </button>
            </>
          )}

          {view === "code" && (
            <form onSubmit={handleCheckLogin}>
              <input type="text" placeholder="Enter email code" value={code} onChange={(e) => setCode(e.target.value)} required />
              <button type="submit">Verify</button>
            </form>
          )}

          {view === "enroll" && (
            <article>
              <p style={{ display: "flex", gap: "0.5rem", justifyContent: "center" }}>
                <button onClick={handleGrantScope}>
                  1. Grant prld:passkey:write
                </button>
                <button onClick={handleRegister} disabled={!isPasskeySupported() || !scoped}>
                  2. Register a passkey
                </button>
              </p>
              {scoped && !registered && <p><small>Scope granted — you can register now.</small></p>}
              {registered && <p><small>Passkey registered — refresh to log in with passkey.</small></p>}
            </article>
          )}
        </main>
      </>
    );
  }
  ```

  Run `npm run dev`, sign in with your email, then click **Grant prld:passkey:write** (the `continue` config grants it instantly) followed by **Register a passkey** to enroll the credential. Once you have a passkey, reload and use **Sign in with a passkey** to log in without email.
</Accordion>

## Error catalogue

| Class                                  | Triggered by                                                                        | Typical recovery                                        |
| -------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `PrldErrors.PasskeyNotSupported`       | Browser has no WebAuthn implementation                                              | Fall back to a different sign-in method                 |
| `PrldErrors.PasskeyNotConfigured`      | App has no `PasskeyConfig`, or `login_enabled: false` on the login endpoints        | Configure the Relying Party / flip the flag             |
| `PrldErrors.PasskeyRegistrationFailed` | The attestation failed verification                                                 | Retry, or route to a different authenticator            |
| `PrldErrors.PasskeyStepUnavailable`    | No credentials, signature mismatch, or sign-count regression on a step-up assertion | Fall back to an OTP step                                |
| `PrldErrors.Unauthorized`              | `loginWithPasskey` — unknown user or bad assertion (single 401 by design)           | Prompt the user to try again or pick a different method |
| `PrldErrors.InsufficientScope`         | `registerPasskey` / `renamePasskey` / `deletePasskey` without `prld:passkey:write`  | Drive a step-up to grant the scope, then retry          |
| `PrldErrors.RateLimited`               | Too many begin ceremonies                                                           | Honor `Retry-After` and back off                        |
| `PrldErrors.NotFound`                  | `renamePasskey` / `deletePasskey` with an unknown credential id                     | Reload the list                                         |

## What's next?

* [Step-Up Authentication](/auth/documentation/frontend-sdks/web/step-up) — the SDK surface for `requestStepUp` and how `continueWithPasskey` plugs into it.
* [Passkey reference](/auth/documentation/passkey) — full ceremony walk-through, security model, AAGUID policy, webhook events.
* [Passkey integration guide](/auth/documentation/integration-guide/passkey) — backend curl flow to configure the Relying Party identity, step-up, and (optional) passwordless / enterprise policy.
