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

@stablechain/passkey-web-login

v0.4.0

Published

StablePay passkeys (passkey.stablepay.to) for any Stable web app on Dynamic: sign-in, step-up, add, list, remove, rename.

Readme

@stablechain/passkey-web-login

StablePay's passkeys for any Stable web app that signs in with Dynamic: sign in, step-up, and add, list, rename and remove passkeys. Every Stable app uses the same StablePay passkeys. A passkey reaches the StablePay mobile app only if it was saved somewhere the phone can use (iCloud Keychain, Google Password Manager, or the phone itself), so the SDK suggests saving new ones to the phone.

Sign-in and step-up prompts for StablePay's passkeys run straight in the app's page where the browser supports related origins (Chrome and Edge 128+, Safari 18+, Firefox 152+), and in a frame on passkey.stablepay.to, the domain those passkeys belong to, everywhere else. Creating a passkey always goes through the frame. The app's own Dynamic client does everything else, and the session stays in the app.

It requires @dynamic-labs-sdk/client 1.38 or later, on the same Dynamic environment as StablePay. The app's origin must be on the frame's allowed list; ask the passkey-web-login maintainers to add it.

Install

The package is public on npm, under the @stablechain organisation.

npm install @stablechain/passkey-web-login

Usage

import { createDynamicClient, TokenScope } from '@dynamic-labs-sdk/client'
import { createStablePasskeys, isPasskeyCancelled, StepUpRequiredError } from '@stablechain/passkey-web-login'

const stable = createStablePasskeys()

export const dynamicClient = createDynamicClient({
  environmentId: import.meta.env.VITE_DYNAMIC_ENV_ID,
  coreConfig: { passkey: stable.passkeyService }
})
stable.connect(dynamicClient)

| Call | Does | Needs first | |---|---|---| | stable.signIn(choice?) | Signs in with a passkey. | Passkey sign-in enabled on the Dynamic environment | | stable.stepUp([TokenScope.Credentiallink], choice?) | Verifies with a passkey. The elevated token lands in the Dynamic client, where the gated call picks it up. | Signed in | | stable.register({ hints? }) | Adds a StablePay passkey and refreshes the Dynamic user. By default the browser suggests saving it to the phone (hints: ['hybrid']), so it also reaches the StablePay app. See "Adding passkeys" below before turning it on. | A credential:link token, else StepUpRequiredError | | stable.list() | The account's passkeys, each with rpId, isShared (StablePay's, usable by every Stable app) and isThisSite (made for this site's own domain). | Signed in | | stable.rename(id, alias) | Renames a passkey. | Possibly a credential:update token | | stable.remove(id) | Removes a passkey. Check first that it isn't the account's last way to sign in. | A credential:unlink token | | stable.promptsInPage() | Resolves true when sign-in and step-up for StablePay's passkeys start in the page here (related origins supported, prompt not 'frame'). Use it to decide whether to offer a frame prompt as a second choice. | — | | stable.cancel() | Gives up on the open passkey request, at any stage: while Dynamic prepares it, while the frame loads, at the dialog, or at a prompt running in the page. The frame closes its dialog and the pending call rejects as cancelled. Call it when the user closes the screen that started it. | — |

Which passkeys (choice). A prompt can only offer passkeys of one domain. { passkeys: 'shared' } (the default) asks for StablePay's, in the page or the frame (see prompt below). { passkeys: 'this-site' } asks for ones made for the app's own domain, prompted in the page, for accounts that created passkeys before they were shared. When list() shows isThisSite passkeys, offer both.

Where the prompt runs (prompt). By default ('auto') the SDK prompts straight in the page when the browser supports related origins: one tap, no frame dialog. If the page's prompt is refused at once (the app's origin isn't in https://passkey.stablepay.to/.well-known/webauthn, or the browser wants a fresher click), the frame takes over. 'frame' always uses the frame.

To pick for one prompt, pass it in the choice: stable.stepUp(scopes, { prompt: 'frame' }) (or signIn({ prompt: 'frame' })). Password-manager extensions that don't support related origins yet, such as 1Password, only answer a prompt on passkey.stablepay.to itself. In the page, the browser shows its own dialog (often a phone QR code) instead, and closing it is a cancel, so the frame is never reached. Where promptsInPage() is true, offer a second button, e.g. "Use a passkey from a password manager", that passes { prompt: 'frame' }:

await stable.stepUp([TokenScope.Credentiallink], { prompt: 'frame' })

When the frame can't help. If the frame doesn't load, stops answering, or the browser blocks it (some wallets' in-app browsers do), sign-in and step-up retry the same prompt in the page through related origins, unless that was already tried. That needs the app's origin listed in https://passkey.stablepay.to/.well-known/webauthn and a browser that supports it. If that fails too, the call throws PasskeyUnavailableError: tell the user, and offer another browser or support, because an account with a passkey may have no other way through step-up.

Adding passkeys. register() hands the frame the user's session and a credential:link token, because Dynamic only creates a passkey for the domain of the page calling it and has no related-origin option for registration. Anyone who can change what the frame serves could then attach their own passkey to that account. Treat the frame's hosting and deploy access as being as sensitive as the app itself, or keep adding passkeys off until Dynamic supports relatedOriginRpId on registration.

Once an account has a passkey, Dynamic only issues step-up tokens through a passkey and refuses email codes. So the app's step-up must offer stable.stepUp() for those accounts.

The prompt: by default the frame opens as an overlay dialog when a prompt starts, and the user taps "Continue with passkey" or "Create passkey" in it. Browsers only allow a passkey prompt in a cross-origin frame after a tap inside that frame. To place the button in your own UI instead, pass createStablePasskeys({ container: element }).

Safari. Safari and every iOS browser refuse to create a passkey in a frame from another site, so on those register() opens the frame page in a pop-up window, where the user taps "Create passkey". Any browser that refuses creation in the frame once (PasskeyUnavailableError, reason 'blocked') also gets the pop-up on its next try. Browsers only allow a pop-up opened by a click, so call register() directly from the click handler, before any await. Adding a passkey needs a step-up first, which breaks that link, so when createsInPopup() is true, run the step-up, then show a "Create passkey" button whose click calls register(). Sign-in and step-up stay in the frame on Safari. The pop-up talks back through window.opener, so a page served with Cross-Origin-Opener-Policy: same-origin can't use it; use same-origin-allow-popups.

The app must be served over https (or http://localhost with the frame also on localhost). Browsers refuse passkey prompts in an https frame under a plain-http page.

How it fits with Dynamic sign-in

The SDK doesn't add a sign-in method. It changes where the passkey prompt runs: in the frame on passkey.stablepay.to instead of the app's page. Dynamic still issues the challenge, checks the response, and owns the session and the user.

A Dynamic user is one account with several verified credentials. Any of them opens a session, and they all lead to the same user, wallets and JWT:

| Credential | Signs in with | Uses the SDK | |---|---|---| | Email or SMS code | sendEmailOTP / sendSmsOTP, then verifyOTP | No | | Google, Apple, Facebook | signInWithSocialRedirect | No | | External wallet (EOA), e.g. MetaMask or WalletConnect | connectAndVerifyWithWalletProvider and the wallet-connect flows | No | | Passkey | stable.signIn(), which calls signInWithPasskey | Yes: the prompt runs in the frame | | Embedded wallet | Comes with the account; not a sign-in method | No |

Passkeys also have a second role: they're an MFA method. stable.stepUp() calls authenticatePasskeyMFA, which issues the elevated tokens that sensitive actions need (credential:link, credential:unlink, wallet:export, …).

App's Dynamic client
 ├─ email / SMS / social / EOA sign-in ──── unchanged: the page talks to Dynamic directly
 ├─ signInWithPasskey / authenticatePasskeyMFA
 │    Dynamic issues a challenge for passkey.stablepay.to
 │    coreConfig.passkey.authenticate() ──▶ frame runs the prompt ──▶ response
 │    Dynamic verifies it and issues the session or elevated token
 └─ stable.register() ──▶ frame calls Dynamic's register endpoints with the app's session

What this means for users:

  • One account, several ways in. A user who signs in with an EOA can add a passkey after step-up. It becomes another credential on the same Dynamic user, and either one signs in from then on.
  • A passkey isn't a wallet key. Transactions are still signed by the EOA or the embedded wallet. A passkey sign-in only proves who the user is. Afterwards the app finds the user's wallets exactly as after an email sign-in.
  • Step-up changes once an account has a passkey. Dynamic then only issues step-up tokens through the passkey, and refuses email codes (and, most likely, wallet signatures). Offer the passkey first in step-up.
  • Passkey sign-in counts as MFA once it's enabled on the environment, so a step-up right after it may not prompt again.

Integrating it into an app

Keep Dynamic, and this SDK, behind one folder of the app (the hub uses src/lib/dynamic/). Components import only from that folder, never from either package. The SDK plugs in at three places.

1. The client: one Dynamic client for every method

// client.ts
import { createDynamicClient } from '@dynamic-labs-sdk/client'
import { addEvmExtension } from '@dynamic-labs-sdk/evm'
import { addMetaMaskEvmExtension } from '@dynamic-labs-sdk/evm/metamask'
import { addEvmWindowInjectedExtension } from '@dynamic-labs-sdk/evm/window-injected'
import { createStablePasskeys } from '@stablechain/passkey-web-login'

/** StablePay passkeys: prompts run in the frame on passkey.stablepay.to. */
export const stablePasskeys = createStablePasskeys()

export const dynamicClient = createDynamicClient({
  environmentId: import.meta.env.VITE_DYNAMIC_ENV_ID,
  autoInitialize: false,
  metadata: { name: 'Stable Hub' },
  coreConfig: { passkey: stablePasskeys.passkeyService }
})
stablePasskeys.connect(dynamicClient)

// Wallet sign-in, as before.
addEvmExtension(dynamicClient)
addEvmWindowInjectedExtension(dynamicClient)
addMetaMaskEvmExtension(dynamicClient)

Initialise the client as usual (initializeClient). Embedded-wallet creation, social redirects and WalletConnect setup don't change, and apply to passkey sign-ins too.

2. Sign-in: a hook next to the other methods

// auth.ts, alongside useEmailOtp, useSmsOtp, useSocialSignIn, useWalletLogin
import { isPasskeyCancelled } from '@stablechain/passkey-web-login'
import { stablePasskeys } from './client'

export function usePasskeySignIn() {
  const [state, setState] = useState<'idle' | 'waiting' | 'cancelled' | 'unsupported'>('idle')
  const [error, setError] = useState<Error>()

  const signIn = useCallback(async () => {
    if (!stablePasskeys.isSupported()) return setState('unsupported')
    setState('waiting')
    setError(undefined)
    try {
      await stablePasskeys.signIn()
      setState('idle')
    } catch (e) {
      if (isPasskeyCancelled(e)) return setState('cancelled')
      setError(e instanceof Error ? e : new Error(String(e)))
      setState('idle')
    }
  }, [])

  return { signIn, state, error }
}

3. Step-up and passkey management

// stepUp.ts: offered first in the step-up drawer for accounts with a passkey
export async function verifyStepUpWithPasskey(scope: TokenScope): Promise<'authorized' | 'cancelled'> {
  try {
    await stablePasskeys.stepUp([scope])
    return 'authorized'
  } catch (error) {
    if (isPasskeyCancelled(error)) return 'cancelled'
    throw error
  }
}

// passkeys.ts
// Adding needs a step-up first. Where register() opens a pop-up (Safari,
// iOS), the pop-up must come from a click, so it takes two: verify, then a
// "Create passkey" button that calls createPasskey() directly.
export async function verifyToAdd() {
  return ensureStepUp(TokenScope.Credentiallink)
}

export async function createPasskey() {
  await stablePasskeys.register() // first thing in the click handler, before any await
  return 'added'
}

// Elsewhere one click does both: the frame's own dialog asks for the tap.
export async function addPasskey() {
  if ((await verifyToAdd()) !== 'authorized') return 'cancelled'
  return createPasskey()
}
// UI: stablePasskeys.createsInPopup() ? (verifyToAdd, then a button for
// createPasskey) : addPasskey

export async function removePasskey(passkeyId: string) {
  // Refuse first if it's the account's last way to sign in.
  if ((await ensureStepUp(TokenScope.Credentialunlink)) !== 'authorized') return 'cancelled'
  await stablePasskeys.remove(passkeyId)
  return 'removed'
}

ensureStepUp is the app's own step-up gate, which opens its verification drawer when Dynamic requires one.

After any of these, the app's session hooks see the same user and wallets, whichever method was used.

On screen this gives:

| Screen | Options | |---|---| | Sign-in | Email, phone, Google / Apple / Facebook, wallets, and Use a passkey (when passkey sign-in is enabled and isSupported()) | | Step-up drawer | Use your passkey first, then email code, SMS code, social, wallet signature | | Account → Security | A Passkeys row, and a drawer to add, rename and remove passkeys |

Errors

| Error | Meaning | |---|---| | isPasskeyCancelled(error) | The user dismissed the prompt, it timed out, or the app called cancel(). Nothing changed; say so and offer to try again. | | PasskeyUnavailableError | The frame didn't load (reason: 'load'), stopped answering ('timeout'), the browser blocked passkeys in it ('blocked'), or blocked the registration pop-up ('popup-blocked'), and the related-origins fallback didn't help. Say so and offer a way out: another browser, or support. Two cases differ: 'blocked' from register() means the next try opens a pop-up (createsInPopup() turns true), so offer to try again; 'timeout' with afterPrompt: true means the device prompt was open and nobody answered it, so treat it like a cancel. | | PasskeyUnsupportedError | This browser can't use passkeys. Hide the passkey options. | | PasskeyExistsError | The password manager or device the user picked already holds a passkey for this account. An account can have several passkeys, but only one per place they're stored, because a second one there would replace the first. Ask the user to pick another, such as their phone or another passkey manager. | | StepUpRequiredError | Run the app's step-up for error.scope, then retry. | | PasskeyFailedError | Dynamic or the frame refused; see step and httpStatus. |

Options

| Option | Default | |---|---| | frameUrl | https://passkey.stablepay.to/frame/. Its hostname decides which passkeys are used. | | container | None, meaning the overlay. An element turns on inline mode. | | readyTimeoutMs | 10000, how long to wait for the frame to load. A frame that fails to load or stops answering is reloaded on the next call. | | prepareTimeoutMs | 30000, how long the frame may take to get ready to prompt; longer than the frame's own 20 s limit per call to Dynamic, so a slow Dynamic is reported as such. Waiting for the user's tap has no limit. | | processTimeoutMs | 120000, how long the frame may take from the user's tap to its answer (device prompt, then the save to Dynamic). | | relatedOriginsFallback | true. When the frame can't serve a sign-in or step-up, retry in the page through related origins. | | prompt | 'auto'. Where sign-in and step-up prompts run: in the page where related origins work, else the frame. 'frame' always uses the frame. A call's choice.prompt overrides it for that call. | | appearance | None. A function returning { theme, lang, copy }, read on every request, so the frame follows the app's theme and language. copy is the dialog's text from the app's translations (register and authenticate titles, hints and buttons, plus cancel, confirming, saving); anything left out stays English. | | createIn | 'auto'. Where register() creates the passkey: the frame, or a pop-up on Safari and iOS and after the frame refused once. 'frame' or 'popup' forces one. | | theme | 'dark'. The Stable palette the frame draws in: 'dark' or 'light'. Fixed at creation; use appearance to follow a theme that changes. | | onLog | None. Receives the frame's diagnostics, which never include a token. |