npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

better-auth-passkey-lifecycle

v1.0.1

Published

WebAuthn Signal API lifecycle sync for better-auth's passkey plugin. Deletes stale passkeys from iCloud Keychain / Google Password Manager / device credential stores when they're removed server-side.

Readme

better-auth-passkey-lifecycle

Three thin helpers that call the WebAuthn Signal API so passkeys @better-auth/passkey deletes server-side actually disappear from iCloud Keychain / Google Password Manager / device credential stores on the user's device.

Better-auth deletes the row. The device never hears about it. Users tap the passkey, get a cryptic "credential not found" error, and give up. This library fixes that on web.

  • 🧹 Ghost credential fix — Server-side deletions actually reach the device credential store.
  • 🎯 Better-auth first — Wraps authClient.passkey.listUserPasskeys() so syncAcceptedCredentials is a one-liner.
  • 🪶 ~1 KB gzipped — Three functions, one convenience helper, no runtime dependencies.
  • 🌐 Progressive — Feature-detects per method; silent no-op on browsers without Signal API support.
  • 🛡️ iOS 26 bug guard — Refuses empty accepted-lists by default to avoid the Apple Passwords cross-account deletion bug.

[!IMPORTANT]

  • Requires better-auth 1.6.26+ with the @better-auth/passkey server plugin — same as the RN companion library.
  • Requires a browser with WebAuthn Signal API support: Chromium 132+ (Chrome, Edge, Arc, Brave) or Safari 26+. Firefox is not supported at time of writing; this library silently no-ops there.
  • For React Native, use react-native-nitro-better-auth-passkey — same API surface, native ASAuthorizationPlatformPublicKeyCredentialProvider on iOS and CredentialManager.signalCredentialState on Android.

📦 Installation

npm install better-auth-passkey-lifecycle

No native install steps. Pure JS, works with any bundler.


🧠 Overview

| Feature | Description | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Three lifecycle helpers | signalUnknownCredential, signalAllAcceptedCredentials, signalCurrentUserDetails — thin wrappers over PublicKeyCredential.signal* with base64url normalization built in. | | syncAcceptedCredentials | Convenience: fetches the authoritative accepted set via authClient.passkey.listUserPasskeys() and signals it in one call. The recommended entry point for post-login / add / delete flows. | | No plugin wrapping | Doesn't touch passkeyClient(). You call these at your app's lifecycle join points — if better-auth ever ships this natively, your imports keep working and you delete the calls. | | Per-method feature detection | Safari has historically shipped these methods one at a time; each helper feature-detects independently and returns { signaled: false, reason: 'unsupported' } when the browser lacks it. | | iOS 26 bug guard | signalAllAcceptedCredentials refuses empty lists by default. Empty = "delete every passkey for this user" on the device, which triggers the Apple Passwords cross-account deletion bug. Opt in with allowEmpty: true when intended. | | Base64url normalization | Server user ids (cuid, ulid, uuid) get UTF-8 → base64url automatically; already-base64url values pass through untouched. | | Cross-platform API parity | Identical function signatures to the React Native companion, so cross-platform apps share lifecycle code verbatim. |


⚙️ Usage

This package doesn't wrap passkeyClient(). You call three functions at the right moments in your app.

After the server rejects a passkey at sign-in

import { signalUnknownCredential } from "better-auth-passkey-lifecycle";

const { data, error } = await authClient.signIn.passkey();
if (error && (error.status === 404 || error.code === "CREDENTIAL_NOT_FOUND")) {
  // The passkey the user just picked no longer exists server-side.
  // Tell the browser so it stops offering it.
  await signalUnknownCredential({
    rpId: "auth.example.com",
    credentialId, // the id from the failed assertion
  });
}

Safe to call when the user is not authenticated — it names exactly one credential id and reveals nothing else.

After a successful login / add / delete

import { syncAcceptedCredentials } from "better-auth-passkey-lifecycle";

// After login:
const { data } = await authClient.signIn.passkey();
if (data?.user) {
  await syncAcceptedCredentials(authClient, {
    rpId: "auth.example.com",
    userId: data.user.id,
  });
}

// After the user deletes a passkey:
await authClient.passkey.deletePasskey({ id });
await syncAcceptedCredentials(authClient, { rpId, userId });

// After the user adds a passkey:
await authClient.passkey.addPasskey({ name });
await syncAcceptedCredentials(authClient, { rpId, userId });

syncAcceptedCredentials calls authClient.passkey.listUserPasskeys(), then hands the resulting id list to PublicKeyCredential.signalAllAcceptedCredentials. Anything on the device outside that list gets hidden (Chrome / GPM) or deleted (iOS Passwords app).

After renaming a passkey

import { signalCurrentUserDetails } from "better-auth-passkey-lifecycle";

await authClient.passkey.updatePasskey({ id, name: "MacBook Pro" });
await signalCurrentUserDetails({
  rpId: "auth.example.com",
  userId: session.user.id,
  name: session.user.email,
  displayName: session.user.name,
});

🧩 API Reference

signalUnknownCredential(input: {
  rpId: string
  credentialId: string                 // base64url
}): Promise<SignalResult>

signalAllAcceptedCredentials(input: {
  rpId: string
  userId: string                       // server user id — auto-normalized to base64url
  allAcceptedCredentialIds: string[]   // base64url
  allowEmpty?: boolean                 // default false — refuses empty lists as a safety guard
}): Promise<SignalResult>

signalCurrentUserDetails(input: {
  rpId: string
  userId: string                       // server user id — auto-normalized to base64url
  name: string
  displayName: string
}): Promise<SignalResult>

syncAcceptedCredentials(
  authClient: AuthClientWithPasskey,
  options: {
    rpId: string
    userId: string
    allowEmpty?: boolean
  }
): Promise<SignalResult>

type SignalResult =
  | { signaled: true }
  | { signaled: false; reason: 'unsupported' }

syncAcceptedCredentials is the recommended entry point for post-login / add / delete. Best-effort: returns { signaled: false } on browser unsupport, on empty lists (unless allowEmpty: true), or when the listUserPasskeys request itself fails. Never throws for those reasons.

What each function does

| Function | Scoped by | Auth required | Effect | | ------------------------------ | ------------------------ | ------------- | ----------------------------------------- | | signalUnknownCredential | rpId + one credential id | no | Hides / removes that one credential | | signalAllAcceptedCredentials | rpId + user handle | yes | Prunes anything outside the accepted list | | signalCurrentUserDetails | rpId + user handle | yes | Updates the username / display label |

All three are best-effort. The browser API returns nothing about what the authenticator did — that's a spec decision, not a bug. On browsers without Signal API support, every function is a silent no-op that returns { signaled: false, reason: 'unsupported' }.

Utility exports

isBase64Url(s: string): boolean
b64urlEncodeUtf8(input: string): string
normalizeUserHandle(raw: string): string

hasWebAuthn(): boolean
supportsSignalUnknownCredential(): boolean
supportsSignalAllAcceptedCredentials(): boolean
supportsSignalCurrentUserDetails(): boolean

Use the supports* helpers if you want to conditionally render UI (e.g. show a "your device will remember this deletion" hint only when supported).


🍎 iOS 26 caveat — read this

Between iOS/macOS 26.0.1 and at least 26.6.1, the two credential-removing signal methods (signalUnknownCredential and signalAllAcceptedCredentials) have a bug: calling them for one account can also delete the passkey of an unrelated account on the same RP ID, when that other account is stored as a combined password + passkey entry in the Apple Passwords app (a state that only arises when the password was saved via the iOS system save prompt).

Full writeup by Corbado: https://www.corbado.com/blog/signal-api-ios-passkey-deletion-bug

Practical implications:

  • The bug is triggered by deletions, not by calls. If the id list you send matches what the device holds, nothing is removed and no other account is touched. Calling syncAcceptedCredentials after every login is safe as long as the list is correct.
  • An empty accepted list is a deletion and unsafe. syncAcceptedCredentials refuses to send an empty list by default; pass { allowEmpty: true } only when the user genuinely has zero passkeys left.
  • signalCurrentUserDetails is not affected. Use it freely.
  • Not doing the sync is worse than doing it. Stale passkeys that fail at login hurt more users, far more often, than the cross-account bug does.

🚦 Browser support

| Browser | Support | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | Chromium 132+ | ✅ Full support — Chrome, Edge, Arc, Brave. On by default since Jan 2025. | | Safari 26+ | ⚠️ Supported but subject to the iOS/macOS 26 removal bug above. signalCurrentUserDetails is safe. | | Firefox | ❌ Not supported at time of writing. All helpers no-op silently. | | Older browsers | ❌ Silent no-op. Feature detection is per-method — if Safari ships one method but not another, we handle each independently. |


🛠️ RP ID gotchas

  • Must exactly match your better-auth server's rpID config.
  • No scheme, no port, no trailing slash. Just example.com.
  • localhost works in dev, but the OS credential managers largely ignore signals there — test with a real domain.

🔤 Base64URL

  • credentialID from listUserPasskeys() is already base64url — pass it through.
  • userId is your server's opaque user id — this library base64url-encodes UTF-8 automatically. If your user.id is already base64url (or a hex string), pass it as-is; the encoding is idempotent.
  • If your server puts something weird in user.id (a JSON blob, a signed token), that's what gets encoded and signaled. Match your server's WebAuthn user handle exactly.

🏗️ Design

  • No plugin wrapping. Three imperative helpers you call at lifecycle join points. Keeps the surface small and lets you skip the signal on flows where it isn't wanted (e.g. an admin-side delete of another user's passkey).
  • SignalResult return type so callers can distinguish "browser didn't support it" from a thrown error. Errors from the underlying API (invalid base64url, security errors) still throw — those are real and shouldn't be swallowed.
  • Structural AuthClientWithPasskey type — no hard import of @better-auth/passkey, so consumers on older versions or custom clients still work.
  • Zero runtime dependencies.

🤝 Contributing

PRs welcome — especially browser compatibility fixes and better error-shape detection for server-side "credential not found" responses.


🪪 License

MIT © Gautham Vijayan


Made with ❤️ + WebAuthn Signal API + better-auth