Skip to main content
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 page. For the backend setup, see the Passkey integration guide.

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).
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.
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
  }
}
FieldRequiredDescription
usernameYesWebAuthn user.name shown by the authenticator. Typically the user’s primary email or phone.
displayNameNoWebAuthn user.displayName shown alongside the name. Falls back to username.
nicknameNoServer-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.
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.
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:
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

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

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.
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.
Replace src/App.jsx with:
src/App.jsx
import "@picocss/pico";
import { useState, useEffect, useCallback } 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 [passkeys, setPasskeys] = useState([]);
  const [error, setError] = useState(null);

  const reload = useCallback(async () => {
    try {
      setPasskeys(await client.listPasskeys());
    } catch (err) {
      setError(err.message);
    }
  }, []);

  useEffect(() => { reload(); }, [reload]);

  const handleRegister = async () => {
    setError(null);
    try {
      await client.registerPasskey({ username: "user@example.com" });
      await reload();
    } catch (err) {
      if (err instanceof PrldErrors.InsufficientScope) {
        setError("Step up to grant prld:passkey:write first.");
      } else {
        setError(err.message);
      }
    }
  };

  const handleDelete = async (id) => {
    setError(null);
    try {
      await client.deletePasskey(id);
      await reload();
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <main style={{ maxWidth: 600, margin: "2rem auto" }}>
      <h1>My passkeys</h1>
      {!isPasskeySupported() && <p>WebAuthn not supported in this browser.</p>}
      {error && <p role="alert">{error}</p>}
      <button onClick={handleRegister} disabled={!isPasskeySupported()}>
        Register a new passkey
      </button>
      <ul>
        {passkeys.map((p) => (
          <li key={p.credential_id}>
            <strong>{p.nickname || p.credential_id}</strong>
            <small> · last used {new Date(p.last_used_at * 1000).toLocaleString()}</small>
            <button onClick={() => handleDelete(p.credential_id)}>Delete</button>
          </li>
        ))}
      </ul>
    </main>
  );
}

Error catalogue

ClassTriggered byTypical recovery
PrldErrors.PasskeyNotSupportedBrowser has no WebAuthn implementationFall back to a different sign-in method
PrldErrors.PasskeyNotConfiguredApp has no PasskeyConfig, or login_enabled: false on the login endpointsConfigure the Relying Party / flip the flag
PrldErrors.PasskeyRegistrationFailedThe attestation failed verificationRetry, or route to a different authenticator
PrldErrors.PasskeyStepUnavailableNo credentials, signature mismatch, or sign-count regression on a step-up assertionFall back to an OTP step
PrldErrors.UnauthorizedloginWithPasskey — unknown user or bad assertion (single 401 by design)Prompt the user to try again or pick a different method
PrldErrors.InsufficientScoperegisterPasskey / renamePasskey / deletePasskey without prld:passkey:writeDrive a step-up to grant the scope, then retry
PrldErrors.RateLimitedToo many begin ceremoniesHonor Retry-After and back off
PrldErrors.NotFoundrenamePasskey / deletePasskey with an unknown credential idReload the list

What’s next?

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