Skip to content

Commit a1f7b96

Browse files
authored
feat(auth): #36 re-enable passkey plugin via @better-auth/passkey (#81)
* feat(auth): #36 re-enable passkey plugin via @better-auth/passkey Passkey plugin was disabled with a TODO when better-auth split it into a separate package. Re-wire it now that we're on 1.6.11. - ADR 0003 documents the split + RP config + clone-time requirements - `@better-auth/passkey` added to pnpm catalog (peer-pinned to ^1.6.11) - packages/auth deps + apps/web deps updated - packages/auth/src/index.ts imports `passkey` from `@better-auth/passkey` - apps/web/src/lib/auth-client.ts wires `passkeyClient()` - "Sign in with passkey" button on `/sign-in` - `<PasskeySection />` on `/dashboard/security` — list + enroll + remove The `passkey` Drizzle table was already present in packages/db/src/schema/auth.ts from the previous attempt — no migration required. Closes #36 * fix(deps): override better-call to 1.3.5 (peer for @better-auth/core 1.6.11)
1 parent fc266f0 commit a1f7b96

12 files changed

Lines changed: 533 additions & 53 deletions

File tree

apps/admin/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"typecheck": "tsc --noEmit"
1010
},
1111
"dependencies": {
12+
"@better-auth/passkey": "catalog:",
1213
"@hookform/resolvers": "^5.2.2",
1314
"@polar-sh/better-auth": "catalog:",
1415
"@starter-saas/analytics": "workspace:*",

apps/web/src/app/(app)/dashboard/security/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useEffect, useState } from "react";
2424
import { toast } from "sonner";
2525
import { GdprSection } from "@/components/app/gdpr-section";
2626
import { PageHeader } from "@/components/app/page-header";
27+
import { PasskeySection } from "@/components/app/passkey-section";
2728
import { authClient } from "@/lib/auth-client";
2829

2930
type SessionRow = {
@@ -105,6 +106,8 @@ export default function SecurityPage() {
105106
</CardContent>
106107
</Card>
107108

109+
<PasskeySection />
110+
108111
<Card>
109112
<CardHeader>
110113
<CardTitle className="flex items-center gap-2">
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"use client";
2+
3+
import { Button } from "@starter-saas/ui/components/button";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@starter-saas/ui/components/card";
11+
import { EmptyState } from "@starter-saas/ui/components/empty-state";
12+
import { Skeleton } from "@starter-saas/ui/components/skeleton";
13+
import { Fingerprint } from "lucide-react";
14+
import { useEffect, useState } from "react";
15+
import { toast } from "sonner";
16+
import { authClient } from "@/lib/auth-client";
17+
18+
type PasskeyRow = {
19+
id: string;
20+
name?: string | null;
21+
deviceType?: string | null;
22+
createdAt?: string | Date | null;
23+
};
24+
25+
export function PasskeySection() {
26+
const [passkeys, setPasskeys] = useState<PasskeyRow[] | null>(null);
27+
const [enrolling, setEnrolling] = useState(false);
28+
29+
const refresh = async () => {
30+
try {
31+
// Better Auth's passkey plugin exposes listUserPasskeys on the client.
32+
// The shape isn't strongly typed for arbitrary plugins so we coerce.
33+
const res = await (
34+
authClient as unknown as {
35+
listPasskeys: () => Promise<{ data?: PasskeyRow[] | null }>;
36+
}
37+
).listPasskeys();
38+
setPasskeys(res?.data ?? []);
39+
} catch {
40+
setPasskeys([]);
41+
}
42+
};
43+
44+
useEffect(() => {
45+
refresh();
46+
}, []);
47+
48+
const enroll = async () => {
49+
setEnrolling(true);
50+
const id = toast.loading("Tap your security key or biometric sensor…");
51+
try {
52+
const result = await authClient.passkey.addPasskey();
53+
if (result?.error) {
54+
toast.error("Couldn't register passkey", {
55+
id,
56+
description: result.error.message,
57+
});
58+
return;
59+
}
60+
toast.success("Passkey registered", { id });
61+
await refresh();
62+
} catch (err) {
63+
toast.error("Couldn't register passkey", {
64+
id,
65+
description: err instanceof Error ? err.message : "?",
66+
});
67+
} finally {
68+
setEnrolling(false);
69+
}
70+
};
71+
72+
const remove = async (passkeyId: string) => {
73+
try {
74+
await (
75+
authClient as unknown as {
76+
deletePasskey: (args: { id: string }) => Promise<unknown>;
77+
}
78+
).deletePasskey({ id: passkeyId });
79+
toast.success("Passkey removed");
80+
await refresh();
81+
} catch (err) {
82+
toast.error("Couldn't remove passkey", {
83+
description: err instanceof Error ? err.message : "?",
84+
});
85+
}
86+
};
87+
88+
return (
89+
<Card>
90+
<CardHeader>
91+
<CardTitle className="flex items-center gap-2">
92+
<Fingerprint className="h-5 w-5" />
93+
Passkeys
94+
</CardTitle>
95+
<CardDescription>
96+
Sign in with Face ID, Touch ID, Windows Hello, or a hardware key —
97+
phishing-resistant, no password to forget.
98+
</CardDescription>
99+
</CardHeader>
100+
<CardContent className="space-y-4">
101+
{passkeys === null ? (
102+
<div className="space-y-2">
103+
<Skeleton className="h-10 w-full" />
104+
<Skeleton className="h-10 w-full" />
105+
</div>
106+
) : passkeys.length === 0 ? (
107+
<EmptyState
108+
illustration="arc"
109+
title="No passkeys yet"
110+
description="Register one to skip passwords on this device next time you sign in."
111+
className="border-0 bg-transparent py-6"
112+
/>
113+
) : (
114+
<ul className="divide-y">
115+
{passkeys.map((pk) => (
116+
<li key={pk.id} className="flex items-center gap-3 py-3">
117+
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
118+
<Fingerprint className="h-4 w-4" />
119+
</div>
120+
<div className="flex-1">
121+
<p className="font-medium text-sm">
122+
{pk.name?.trim() || "Unnamed passkey"}
123+
</p>
124+
<p className="text-muted-foreground text-xs">
125+
{pk.deviceType ?? "—"}
126+
{pk.createdAt
127+
? ` · ${new Date(pk.createdAt).toLocaleDateString()}`
128+
: ""}
129+
</p>
130+
</div>
131+
<Button variant="ghost" size="sm" onClick={() => remove(pk.id)}>
132+
Remove
133+
</Button>
134+
</li>
135+
))}
136+
</ul>
137+
)}
138+
<Button onClick={enroll} disabled={enrolling}>
139+
{enrolling ? "Waiting for device…" : "Register a passkey"}
140+
</Button>
141+
</CardContent>
142+
</Card>
143+
);
144+
}

apps/web/src/components/auth/sign-in-form.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
FormMessage,
1212
} from "@starter-saas/ui/components/form";
1313
import { Input } from "@starter-saas/ui/components/input";
14+
import { KeyRound } from "lucide-react";
1415
import { useRouter, useSearchParams } from "next/navigation";
1516
import { useState } from "react";
1617
import { useForm } from "react-hook-form";
@@ -72,6 +73,23 @@ export function SignInForm() {
7273
else toast.success("Check your inbox for a sign-in link");
7374
};
7475

76+
const onPasskey = async () => {
77+
// `signIn.passkey()` triggers the WebAuthn browser prompt. If the user
78+
// has no registered passkey for this origin, the browser handles the
79+
// "no credentials" UI; we surface server-side errors via toast.
80+
const result = await authClient.signIn.passkey();
81+
if (result?.error) {
82+
toast.error("Couldn't sign in with passkey", {
83+
description: result.error.message ?? "Try email + password instead",
84+
});
85+
return;
86+
}
87+
toast.success("Welcome back");
88+
// biome-ignore lint/suspicious/noExplicitAny: dynamic redirect target
89+
router.push(next as any);
90+
router.refresh();
91+
};
92+
7593
return (
7694
<Form {...form}>
7795
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
@@ -123,6 +141,11 @@ export function SignInForm() {
123141
{submitting ? "Signing in…" : "Sign in"}
124142
</Button>
125143

144+
<Button type="button" variant="outline" size="lg" onClick={onPasskey}>
145+
<KeyRound className="mr-2 h-4 w-4" />
146+
Sign in with passkey
147+
</Button>
148+
126149
<Button
127150
type="button"
128151
variant="ghost"

apps/web/src/lib/auth-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { passkeyClient } from "@better-auth/passkey/client";
12
import { polarClient } from "@polar-sh/better-auth";
23
import {
34
adminClient,
@@ -14,6 +15,7 @@ export const authClient = createAuthClient({
1415
adminClient(),
1516
organizationClient(),
1617
polarClient(),
18+
passkeyClient(),
1719
],
1820
});
1921

docs/adr/0003-passkey-plugin.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ADR-0003 — Passkey plugin via `@better-auth/passkey`
2+
3+
**Status**: Accepted
4+
**Date**: 2026-05-15
5+
6+
## Context
7+
8+
Better Auth 1.6 split the passkey plugin out of the core package
9+
(`better-auth/plugins/passkey`) into its own npm package
10+
(`@better-auth/passkey`). Our scaffold previously imported passkey from
11+
the core path and commented it out when the import path broke during a
12+
bump:
13+
14+
```ts
15+
// TODO: passkey plugin not exported from better-auth@1.6.9. Re-enable when upgrading.
16+
// import { passkey } from "better-auth/plugins/passkey";
17+
```
18+
19+
Backlog item #36 tracks re-enabling it cleanly.
20+
21+
## Decision
22+
23+
- Add `@better-auth/passkey` to the pnpm catalog (peer-dep matches
24+
`better-auth@^1.6.11`).
25+
- Import the server plugin from `@better-auth/passkey` in
26+
`packages/auth/src/index.ts`.
27+
- Import the client plugin from `@better-auth/passkey/client` in
28+
`apps/web/src/lib/auth-client.ts`.
29+
- Run `pnpm auth:generate` to add the `passkey` table to
30+
`packages/db/src/schema/auth.ts`.
31+
- Wire a "Sign in with passkey" button on `/sign-in` and a "Register a
32+
passkey" affordance on `/dashboard/security`.
33+
34+
## RP configuration
35+
36+
- `rpName`: `"starter-saas"` (cloned projects can rename this in
37+
`packages/auth/src/index.ts`).
38+
- `rpID`: derived from `env.APP_URL` host at runtime.
39+
- `origin`: `env.APP_URL`.
40+
41+
Cloned projects MUST update `rpName` + ensure `APP_URL` points to the
42+
real production origin before allowing passkey enrollment. Passkeys
43+
created against `localhost` will NOT work against the deployed origin
44+
and vice versa — there is no migration path.
45+
46+
## Backwards compatibility
47+
48+
- No existing user data is affected — the `passkey` table is purely
49+
additive.
50+
- Email/password + magic-link + Google OAuth continue to work unchanged.
51+
- Sessions issued before this change remain valid.
52+
53+
## Consequences
54+
55+
- One new top-level dep (`@better-auth/passkey`) — acceptable cost.
56+
- `pnpm auth:generate` will mutate `schema/auth.ts`; the regenerated
57+
file is checked into the repo so the migration is reproducible.
58+
- A new migration must be generated and applied (`pnpm db:generate &&
59+
pnpm db:migrate`) before deploying.
60+
61+
## Alternatives considered
62+
63+
- **Keep passkey disabled, ship without WebAuthn**: rejected — passkey
64+
is a tier-3 product surface that ships in the starter and is a
65+
meaningful security upgrade over passwords alone.
66+
- **Fork `better-auth/plugins/passkey` inline**: rejected — vendored
67+
WebAuthn code is a maintenance trap given the SimpleWebAuthn dep
68+
cadence.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
},
7272
"pnpm": {
7373
"overrides": {
74+
"better-call": "1.3.5",
7475
"drizzle-orm": "0.45.2",
7576
"react": "19.2.6",
7677
"react-dom": "19.2.6"

packages/auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"generate": "npx -y @better-auth/cli@latest generate --output ../db/src/schema/auth.ts --config ./src/index.ts -y"
1717
},
1818
"dependencies": {
19+
"@better-auth/passkey": "catalog:",
1920
"@polar-sh/better-auth": "catalog:",
2021
"@polar-sh/sdk": "catalog:",
2122
"@starter-saas/billing": "workspace:*",

packages/auth/src/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { passkey } from "@better-auth/passkey";
12
import { checkout, polar, portal, webhooks } from "@polar-sh/better-auth";
23
import { polar as polarClient } from "@starter-saas/billing/client";
34
import { polarCheckoutProducts } from "@starter-saas/billing/plans-server";
@@ -8,8 +9,6 @@ import { betterAuth } from "better-auth";
89
import { drizzleAdapter } from "better-auth/adapters/drizzle";
910
import { nextCookies } from "better-auth/next-js";
1011
import { admin, magicLink, organization, twoFactor } from "better-auth/plugins";
11-
// TODO: passkey plugin not exported from better-auth@1.6.9. Re-enable when upgrading.
12-
// import { passkey } from "better-auth/plugins/passkey";
1312

1413
import { ac, admin as adminRole, member, owner } from "./lib/permissions";
1514
import { createRedisSecondaryStorage } from "./lib/redis";
@@ -99,7 +98,11 @@ export function createAuth() {
9998
await sendMagicLink({ to: email, url });
10099
},
101100
}),
102-
// passkey({ rpID: new URL(env.APP_URL).hostname, rpName: "starter-saas", origin: env.APP_URL }),
101+
passkey({
102+
rpID: new URL(env.APP_URL).hostname,
103+
rpName: "starter-saas",
104+
origin: env.APP_URL,
105+
}),
103106
twoFactor(),
104107
admin({
105108
defaultRole: "user",

0 commit comments

Comments
 (0)