BETTER-AUTH. UI
Plugins

Passkey

Add passwordless passkey sign-in and device management to your Solid/Zaidan authentication flow.

The passkey plugin adds passwordless authentication using WebAuthn. Users can sign in with their device authenticator (Touch ID, Face ID, Windows Hello) and manage registered passkeys from their security settings.

It contributes:

  • A "Continue with Passkey" button rendered on the sign-in and magic-link views (hidden on sign-up)
  • A <PasskeysSettings /> security card for listing, adding, renaming, and deleting registered passkeys
  • Solid query and mutation options such as signInPasskeyOptions, listPasskeysOptions, addPasskeyOptions, updatePasskeyOptions, and deletePasskeyOptions

Setup

Install the Better Auth plugin

Install the @better-auth/passkey package and add it to your Better Auth server config:

src/lib/auth.ts
import { betterAuth } from "better-auth"
import { passkey } from "@better-auth/passkey"

export const auth = betterAuth({
  // ...
  plugins: [
    passkey() 
  ]
})

Configure the WebAuthn origin and trusted origins before production so browser authenticators can complete ceremonies on your real domain.

Install the matching client plugin

Add passkeyClient() to your auth client so authClient.signIn.passkey and authClient.passkey.* are available:

src/lib/auth-client.ts
import { createAuthClient } from "better-auth/solid"
import { passkeyClient } from "@better-auth/passkey/client"

export const authClient = createAuthClient({
  plugins: [passkeyClient()] 
})

Install the UI plugin

Run the shadcn CLI to install the passkey button, passkey management card, and the local passkeyPlugin() factory into your project:

npx shadcn@latest add https://better-auth-ui.com/r/solid/passkey.json

This drops the following into your codebase:

  • src/lib/auth/passkey-plugin.ts: passkeyPlugin() factory
  • src/components/auth/passkey/passkey-localization.ts: local localization resolver for copied Passkey UI
  • src/components/auth/passkey/passkey-button.tsx: the "Continue with Passkey" sign-in button
  • src/components/auth/passkey/passkeys.tsx: the passkey management card
  • src/components/auth/passkey/passkey.tsx: individual passkey row
  • src/components/auth/passkey/passkey-skeleton.tsx: loading placeholder shown while passkeys load
  • src/components/auth/passkey/passkeys-empty.tsx: empty state shown when no passkeys exist
  • src/components/auth/passkey/add-passkey-dialog.tsx: dialog for registering a new passkey
  • src/components/auth/passkey/delete-passkey-dialog.tsx: confirmation dialog for deleting a passkey
  • src/components/ui/spinner.tsx: pending indicator reused by Passkey actions

Register the plugin

Pass passkeyPlugin() to <AuthProvider>. The local plugin registers the auth-flow button and the security settings card.

src/components/providers.tsx
import { AuthProvider } from "@/components/auth/auth-provider"
import { passkeyPlugin } from "@/lib/auth/passkey-plugin"
import { authClient } from "@/lib/auth-client"

export function Providers(props: { children?: JSX.Element }) {
  return (
    <AuthProvider
      authClient={authClient}
      navigate={navigate}
      plugins={[passkeyPlugin()]} 
    >
      {props.children}
    </AuthProvider>
  )
}

When the plugin is registered, <SignIn /> can render <PasskeyButton />, and <SecuritySettings /> can render <PasskeysSettings /> through plugin-contributed securityCards.

Runtime prerequisites

  • Server: passkey() from @better-auth/passkey.
  • Client: passkeyClient() from @better-auth/passkey/client.
  • Production: set the WebAuthn origin and trusted origins correctly.
  • Runtime API: passkey options and queries from Solid runtime APIs.

Copied files

  • src/lib/auth/passkey-plugin.ts
  • src/components/auth/passkey/passkey-localization.ts
  • src/components/auth/passkey/passkey-button.tsx
  • src/components/auth/passkey/passkeys.tsx
  • src/components/auth/passkey/passkey.tsx
  • src/components/auth/passkey/passkey-skeleton.tsx
  • src/components/auth/passkey/passkeys-empty.tsx
  • src/components/auth/passkey/add-passkey-dialog.tsx
  • src/components/auth/passkey/delete-passkey-dialog.tsx
  • src/components/ui/spinner.tsx

Components

These previews use seeded Storybook fixtures and mocked Passkey methods. They do not call navigator.credentials or start real WebAuthn ceremonies.

<SignIn />

A "Continue with Passkey" button is automatically rendered on the <SignIn /> and magic-link views when the plugin is registered. It is hidden on sign-up.

Usage

import { PasskeyButton } from "@/components/auth/passkey/passkey-button"

export function PasskeySignInDemo() {
  return <PasskeyButton />
}

Props

Prop

Type

<PasskeysSettings />

The <PasskeysSettings /> security card is rendered on the security settings page when your layout renders each plugin's securityCards and passkeyPlugin() is registered. It lists registered passkeys and provides add / delete dialogs.

Usage

import { PasskeysSettings } from "@/components/auth/passkey/passkeys"

export function PasskeysDemo() {
  return <PasskeysSettings />
}

Props

Prop

Type

Passkey registration policy

By default, the add-passkey dialog does not set authenticatorAttachment. The browser and operating system show the available passkey options.

Set a preference in the plugin when all registrations must use one authenticator type:

passkeyPlugin({ authenticatorAttachment: "platform" })
passkeyPlugin({ authenticatorAttachment: "cross-platform" })

"platform" prefers the current device. "cross-platform" prefers a security key or another device. The dialog does not show an attachment selector.

useAddPasskey accepts every parameter exposed by authClient.passkey.addPasskey. Use this hook for custom registration flows:

const addPasskey = useAddPasskey(auth.authClient)

addPasskey.mutate({
  name: "Work laptop",
  authenticatorAttachment: "platform",
  extensions: { credProps: true },
  returnWebAuthnResponse: true
})

residentKey and userVerification are server plugin policies. They are not parameters of authClient.passkey.addPasskey, so the client UI does not expose them.

Passkey autofill

With the plugin registered, the sign-in form asks the browser to offer saved passkeys straight from its autofill dropdown, so most people never press the passkey button at all. This is the WebAuthn conditional UI flow.

Two pieces make it work, and the built-in components already handle both:

  • The identifier and password fields carry webauthn as the last token of their autocomplete attribute, added by withPasskeyAutoFill.
  • <PasskeyButton /> calls usePasskeyAutoFill, which opens a conditionally mediated request once the browser reports that it supports one.

Browsers without conditional mediation ignore the extra token and never get the request, so the button stays as the fallback everywhere.

Turn the whole thing off with:

passkeyPlugin({ autoFill: false })

If you write your own sign-in form, add the token and start the conditional request:

import type { PasskeyAuthClient } from "@better-auth-ui/core/plugins/passkey"
import {
  isPasskeyAutoFillEnabled,
  withPasskeyAutoFill
} from "@better-auth-ui/core/plugins/passkey"
import { usePasskeyAutoFill } from "@better-auth-ui/solid/plugins/passkey"

const auth = useAuth<PasskeyAuthClient>()
const passkeyAutoFill = isPasskeyAutoFillEnabled(auth.plugins)

usePasskeyAutoFill(auth.authClient)

<Input autocomplete={withPasskeyAutoFill("email", passkeyAutoFill)} />

navigator.credentials.get() accepts an AbortSignal. The bundled hook calls the Better Auth passkey client, which does not expose that signal. Unmounting the form only stops a pending availability probe. It does not cancel a request that already started.

If your custom implementation calls navigator.credentials.get() directly, pass an AbortSignal and abort it during cleanup.

Options

passkeyPlugin({
  // Omit this option to let the browser show all available choices.
  authenticatorAttachment: "platform",
  // Override any of the plugin's localization strings.
  localization: {
    passkeys: "Security Keys"
  }
})

Prop

Type

Localization

Prop

Type

The copied Zaidan Passkey components resolve labels from passkeyPlugin({ localization }) and fall back to the default Passkey localization.

Solid runtime APIs

Use the low-level Solid runtime helpers when building custom Passkey UI:

Last updated on

On this page