Skip to contentVibraUI
Shared pagesinstalls at /passkey

Passkey sign-in

Sign in with a passkey, or make one first: both halves of the ceremony mocked behind server actions, with a refusal under the button that produced it and three sentences saying what a passkey actually is.

Open the live page

WebAuthn is a browser API plus a server-side attestation check, so the mock of it lives in this block's actions.ts rather than in AuthAdapter, which every other auth block implements — adding two methods to that contract to demonstrate one page would be a breaking change for anyone who has implemented it. registerPasskey mints a credential id deterministically from the device name (a random one would differ between the server and the browser) and remembers it in module state; signInWithPasskey refuses an id it has never seen, which is what makes "use a passkey before making one" a real state a reader can walk into rather than a hypothetical. A verified assertion and a verified password end in the same place — a session — so once the check passes the block goes through the adapter's own signIn exactly as the sign-in page does. The sign-in control is a button rather than a form, because there is nothing to type: it is an AsyncButton holding its own pending state, and its refusal renders directly under it, where the reader who just pressed it is looking. Nothing navigates on success; the way on is a link. The explainer is part of the page rather than a link off it: a page that offers to replace a password owes the reader that before they agree to it. Composes AuthFrame (block-local), Callout, AsyncButton, Input, Label and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-passkey

Needs the @vibra registry in your components.json — set it up once.

Source

app/passkey/page.tsx
import Link from "next/link"

import { Callout } from "@/components/ui/callout"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { PasskeyPanel } from "./components/passkey-panel"
import { BRAND, PASSKEY_FACTS, SIGN_IN_HREF } from "./data"

/**
 * Sign in with a passkey, or make one. A server component: the explainer and
 * the frame render on the server, and only the panel — which runs both halves
 * of the ceremony through server actions — is a client island. The page renders
 * outside AppShell, so the frame owns the single `main`.
 */
export default function PasskeyPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Sign in with a passkey"
      description="No password to remember and none to leak: the device you are reading this on proves who you are."
      brand={BRAND}
      layout={layout}
      footer={
        <>
          Rather use a password?{" "}
          <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
            Sign in
          </Link>
        </>
      }
    >
      <Callout className="text-xs">
        This demo mocks the ceremony: no browser credential is created, and{" "}
        <span className="font-mono">actions.ts</span> is where a real WebAuthn call goes.
      </Callout>

      <PasskeyPanel />

      {/* A page that offers to replace a password owes the reader an
          explanation before they agree to it — so the explainer is part of the
          page rather than a link off it. */}
      <section aria-label="What a passkey is" className="flex flex-col gap-2 border-t pt-4">
        <h2 className="text-sm font-medium">What a passkey is</h2>
        <ul className="flex list-disc flex-col gap-1.5 pl-4 text-sm text-pretty text-muted-foreground">
          {PASSKEY_FACTS.map((fact) => (
            <li key={fact}>{fact}</li>
          ))}
        </ul>
      </section>
    </AuthFrame>
  )
}
app/passkey/data.ts
/**
 * Everything this page reads. What it changes lives in `actions.ts` beside it,
 * where the adapter and the mocked ceremony run on the server; this file holds
 * only the vocabulary the page and its client island share.
 */

/** The workspace this card is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }

export const SIGN_IN_HREF = "/sign-in"
export const DASHBOARD_HREF = "/saas"

/** The account a passkey on this device would sign in as. */
export const ACCOUNT = "ada@northwind.example"

/** What the device-name field starts with, so the common case is one click. */
export const DEFAULT_DEVICE = "This laptop"

/**
 * What a passkey actually is, in the three sentences that matter to whoever is
 * being asked to make one. A page that offers to replace a password owes the
 * reader this much before they agree to it.
 */
export const PASSKEY_FACTS = [
  "It is a key pair your device makes. The private half never leaves the device, so there is nothing to phish and nothing on our side worth stealing.",
  "You unlock it the way you unlock the device — a fingerprint, a face, a PIN — and that unlock never reaches us.",
  "One passkey per device. Losing a device does not lock you out of the others, and you can revoke any of them from security settings.",
]
app/passkey/actions.ts
"use server"

import { isFormData, mockAuthAdapter, NOT_A_FORM, type AuthError, type AuthResult, type Session } from "@/lib/auth-adapter"

import { ACCOUNT } from "./data"

/** A passkey this browser has registered: what it is called, and its credential id. */
export type Passkey = { credentialId: string; label: string }

// The credentials this process has seen registered. `AuthAdapter` has no
// passkey ceremony of its own and this block does not invent one on it: WebAuthn
// is a browser API plus a server-side attestation check, and a mock of it
// belongs beside the page that demonstrates it rather than inside the contract
// every other auth block implements. A real implementation replaces this file's
// two functions with navigator.credentials plus its own verification, and the
// island above it does not change.
const REGISTERED = new Set<string>()

/** A device's name, as a list of passkeys prints it. */
const MAX_LABEL = 64

// Deterministic, so the same device name yields the same credential id on every
// run — a demo that minted a random id would show a different one to the server
// and the browser.
function credentialIdFor(label: string): string {
  let hash = 0
  for (const char of label) hash = (hash * 31 + char.codePointAt(0)!) >>> 0
  return `pk_${hash.toString(16).padStart(8, "0")}`
}

const unnamedDevice: AuthError = {
  code: "invalid_input",
  field: "name",
  message: "Name this device so you can tell its passkey from the others.",
}

const noPasskey: AuthError = {
  code: "invalid_credentials",
  message: "No passkey on this device yet. Create one first, then sign in with it.",
}

/**
 * The registration half of the ceremony, mocked: a real one would ask the
 * browser to create a credential and verify the attestation here.
 */
export async function registerPasskey(formData: FormData): Promise<AuthResult<Passkey>> {
  if (!isFormData(formData)) return { ok: false, error: NOT_A_FORM }
  const raw = formData.get("label")
  const label = typeof raw === "string" ? raw.trim() : ""

  if (label === "") return { ok: false, error: unnamedDevice }
  if (label.length > MAX_LABEL) {
    return { ok: false, error: { code: "invalid_input", field: "name", message: `Keep the device's name under ${MAX_LABEL} characters.` } }
  }

  const credentialId = credentialIdFor(label)
  REGISTERED.add(credentialId)
  return { ok: true, data: { credentialId, label } }
}

/**
 * The assertion half, mocked: a real one would verify a signature from the
 * device against the stored public key. A verified assertion and a verified
 * password end in the same place — a session — so what happens after the check
 * goes through the adapter exactly as sign-in does.
 */
export async function signInWithPasskey(credentialId: string): Promise<AuthResult<Session>> {
  if (!credentialId || !REGISTERED.has(credentialId)) return { ok: false, error: noPasskey }

  // The device has proved who it is; there is no password in this flow, and the
  // adapter is being told the check already passed.
  return mockAuthAdapter.signIn({ email: ACCOUNT, password: "" })
}
app/passkey/components/auth-frame.tsx
import * as React from "react"

export type AuthLayout = "centered" | "split"

export type AuthFrameProps = {
  /** The page's only h1. */
  title: string
  description: React.ReactNode
  brand: { name: string; initial: string }
  /** Sits under the card, outside its frame — the way off this page. */
  footer?: React.ReactNode
  /**
   * Goes in the split panel, under the brand. Decoration inside decoration:
   * the panel is `aria-hidden`, and a "centered" frame has no panel to put it
   * in, so nothing here may be the only place the page says something.
   */
  aside?: React.ReactNode
  /** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
  layout?: AuthLayout
  children: React.ReactNode
}

/**
 * The frame this page sits in. Auth pages render outside AppShell, so nothing
 * else on the route owns a landmark: the frame carries the page's single
 * `main` and its single `h1`.
 *
 * `layout` belongs to the product this page is installed in rather than to the
 * page — a template says which one it wants, and the page forwards it. The
 * card is the same either way; "split" only sets a brand panel beside it, and
 * only from `lg`, where there is room for one. Below that the two layouts are
 * the same screen: a phone gets the card, never a panel stacked above it.
 *
 * Copied into each auth block rather than shared between them. A block
 * installs as a self-contained route, so it brings its own frame with it.
 */
export function AuthFrame({
  title,
  description,
  brand,
  footer,
  aside,
  layout = "centered",
  children,
}: AuthFrameProps) {
  const card = (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <div className="flex items-center justify-center gap-2">
        <span
          aria-hidden="true"
          className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
        >
          {brand.initial}
        </span>
        <span className="text-sm font-medium tracking-tight">{brand.name}</span>
      </div>

      <section className="flex flex-col gap-5 panel p-6">
        <header className="flex flex-col gap-1.5">
          <h1 className="type-display text-3xl text-pretty">{title}</h1>
          <p className="text-sm text-pretty text-muted-foreground">{description}</p>
        </header>
        {children}
      </section>

      {footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
    </div>
  )

  if (layout === "split") {
    return (
      <main
        data-slot="auth-frame"
        data-layout={layout}
        className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
      >
        {/* The panel stays in the accessibility tree: it carries the brand and
            whatever the page hands it through `aside` — sign-up's headline and
            highlights, for one — so only the mark and the hairline are hidden
            as decoration. A surface token rather than the primary colour, so it
            reads as a panel in both themes instead of inverting in the dark one. */}
        <aside
          data-slot="auth-frame-panel"
          className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
        >
          <span
            aria-hidden="true"
            className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
          >
            {brand.initial}
          </span>
          <div className="flex flex-col gap-3">
            <span className="type-display text-3xl">{brand.name}</span>
            <span aria-hidden="true" className="h-px w-16 bg-border" />
          </div>

          {aside}
        </aside>

        <div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
      </main>
    )
  }

  return (
    <main
      data-slot="auth-frame"
      data-layout={layout}
      className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
    >
      {card}
    </main>
  )
}
app/passkey/components/passkey-panel.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { KeyRoundIcon } from "lucide-react"

import { type AuthResult, type Session } from "@/lib/auth-adapter"
import { AsyncButton } from "@/components/ui/async-button"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

import { registerPasskey, signInWithPasskey, type Passkey } from "../actions"
import { ACCOUNT, DASHBOARD_HREF, DEFAULT_DEVICE } from "../data"

const LABEL_ID = "passkey-device-label"
const LABEL_ERROR_ID = "passkey-device-error"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the panel,
 * and never on the panel's own mount. `tabIndex={-1}` makes it a focus target
 * without putting it in the tab order.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/**
 * Sign in with a passkey, or make one first.
 *
 * Both halves of the ceremony are server actions, so the browser never sees the
 * adapter and the block keeps the same boundary every other auth page has. The
 * sign-in button is not a form — there is nothing to type — so it is an
 * `AsyncButton` holding its own pending state, and the refusal it can produce
 * renders directly under it rather than at the top of the card: a reader who
 * has just pressed "Use a passkey" is looking at that button.
 */
export function PasskeyPanel() {
  const [passkey, setPasskey] = React.useState<Passkey | null>(null)
  const [session, setSession] = React.useState<Session | null>(null)
  const [signInError, setSignInError] = React.useState<string | null>(null)

  const [registration, registerAction, registering] = React.useActionState<
    AuthResult<Passkey> | null,
    FormData
  >(async (_previous, formData) => {
    const result = await registerPasskey(formData)
    if (result.ok) setPasskey(result.data)
    return result
  }, null)

  const registerError = registration && !registration.ok ? registration.error : null

  if (session) {
    return (
      <div className="flex flex-col gap-5">
        <div className="flex flex-col items-start gap-3">
          <span
            aria-hidden="true"
            className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success"
          >
            <KeyRoundIcon className="size-4" />
          </span>
          <div className="flex flex-col gap-1.5">
            <ConfirmationHeading>You are signed in</ConfirmationHeading>
            <p role="status" className="text-sm text-pretty text-muted-foreground">
              The device proved it holds the passkey for{" "}
              <span className="text-foreground">{ACCOUNT}</span>. No password was typed and none
              was sent.
            </p>
          </div>
        </div>

        <Link href={DASHBOARD_HREF} className={buttonVariants()}>
          Go to dashboard
        </Link>
      </div>
    )
  }

  return (
    <div className="flex flex-col gap-5">
      <div className="flex flex-col gap-2">
        <AsyncButton
          onClick={async () => {
            const result = await signInWithPasskey(passkey?.credentialId ?? "")
            if (!result.ok) {
              setSignInError(result.error.message)
              return
            }
            setSignInError(null)
            setSession(result.data)
          }}
        >
          Use a passkey
        </AsyncButton>
        {signInError ? (
          <p role="alert" className="text-sm text-danger">
            {signInError}
          </p>
        ) : null}
      </div>

      <div className="flex flex-col gap-3 border-t pt-4">
        {passkey ? (
          <p role="status" className="text-sm text-muted-foreground">
            <span className="text-foreground">{passkey.label}</span> now holds a passkey for this
            account. Press <span className="text-foreground">Use a passkey</span> to sign in with
            it.
          </p>
        ) : null}

        <form action={registerAction} noValidate className="flex flex-col gap-3">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor={LABEL_ID}>Name this device</Label>
            <Input
              id={LABEL_ID}
              name="label"
              defaultValue={DEFAULT_DEVICE}
              autoComplete="off"
              aria-invalid={registerError ? true : undefined}
              aria-describedby={registerError ? LABEL_ERROR_ID : undefined}
            />
            {registerError ? (
              <p id={LABEL_ERROR_ID} role="alert" className="text-sm text-danger">
                {registerError.message}
              </p>
            ) : null}
          </div>

          <Button type="submit" variant="outline" disabled={registering}>
            {registering ? "Creating…" : "Create a passkey"}
          </Button>
        </form>
      </div>
    </div>
  )
}