Skip to content

feat: passkey (WebAuthn) auth + auth-config flags for local-mode routing#905

Merged
buremba merged 6 commits into
mainfrom
feat/local-passkey
May 19, 2026
Merged

feat: passkey (WebAuthn) auth + auth-config flags for local-mode routing#905
buremba merged 6 commits into
mainfrom
feat/local-passkey

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 19, 2026

Summary

Adds biometric sign-in (Touch ID / Face ID / security key) via @better-auth/passkey, and the auth-config flags the SPA needs to route + render correctly in single-user local mode.

Backend

  • Passkey plugin wired into auth/index.tsx. rpID derived from PUBLIC_WEB_URL env (stable per deployment); origin: null so the plugin reads the request Origin header at verification time — avoids the cache-poisoning issue where the first request that constructs the BA instance freezes the WebAuthn origin for everyone.
  • Migration db/migrations/20260519000000_passkey_table.sql creates the passkey table matching @better-auth/passkey's schema.
  • /api/auth-config extended with passkey, singleUserMode, hasUser so the SPA can route to /sign-up or /sign-in based on whether anyone has signed up yet.

SPA (owletto submodule)

Companion PR: lobu-ai/owletto#TBD (8c6c283).

  • authClient adds passkeyClient().
  • /sign-in page renders a "Sign in with passkey" button when authConfig.passkey is enabled. Inspects result.error since the BA client returns failure modes as a result object rather than throwing.
  • Single-user-mode routing: when singleUserMode is on, force authIntent to sign-up if !hasUser, sign-in if hasUser. Operator only sees the relevant tab.

Codex review

Caught three real bugs, all fixed before push:

  1. Request-derived origin froze for cached BA instance → would silently break for multi-host (localhost + Tailscale + prod) deployments.
  2. Same for rpID.
  3. signIn.passkey() returned failure as result.error not via throw → button cleared loading state with no message on cancel / failed auth.

Test plan

  • make typecheck clean.
  • bun test packages/server/src/__tests__/unit — 201 pass, 0 fail.
  • End-to-end on a local install: sign up with email+password → settings shows "Add a passkey" → enroll → sign out → sign back in with Touch ID.

Deferred

  • Post-signup passkey enrollment prompt (today: user has to navigate to /settings to add one).
  • Passkey-only signup (no password at all) — requires custom resolveUser hook; bigger change.

Summary by CodeRabbit

  • New Features

    • Added passkey (WebAuthn) authentication so users can sign in with biometric or hardware keys.
  • Chores

    • Database schema extended to store passkey credentials and metadata.
    • Server and CLI updated to enable passkey support and surface passkey availability, single-user mode, and whether any user exists in auth configuration.

Review Change Stack

…ode routing

Two additions on top of the local-first refactor (#902):

1. @better-auth/passkey plugin enabled in auth/index.tsx
-------------------------------------------------------
The operator signs up once with email+password, then can enroll a
passkey (Touch ID / Face ID / hardware key) for biometric sign-in.
The plugin's default `requireSession: true` for registration is kept —
this PR adds the sign-in side; post-signup enrollment UI is a small
follow-up.

Codex review caught two subtle bugs:
- `origin: resolveBaseUrl({ request })` would freeze the WebAuthn
  origin onto the first request that constructed the cached BA
  instance — request from localhost could poison passkey for a
  subsequent Tailscale request. Now `origin: null` (plugin reads the
  request Origin header at verification time).
- `rpID: new URL(resolveBaseUrl({ request })).hostname` had the same
  freezing problem. Now derived from `PUBLIC_WEB_URL` env (stable per
  deployment), defaults to "localhost" for local-mode.

Migration db/migrations/20260519000000_passkey_table.sql creates the
`passkey` table with the schema @better-auth/passkey's adapter expects:
publicKey, credentialID, counter, deviceType, backedUp, transports,
aaguid. FK userId → user(id) ON DELETE CASCADE.

2. /api/auth-config extended with passkey + singleUserMode + hasUser
--------------------------------------------------------------------
SPA currently reads auth-config to gate visible providers (magic-link,
social, email-password). After #902 it also needs to know:

- `passkey: true` — plugin is always wired, so the SPA can render the
  Sign-in-with-passkey button unconditionally.
- `singleUserMode: env.LOBU_SINGLE_USER === '1'` — drives the SPA's
  copy + routing for local-first installs.
- `hasUser`: SELECT EXISTS(SELECT 1 FROM "user" WHERE id <> 'bootstrap-user')
  — when singleUserMode + !hasUser, SPA forces /sign-up; when +hasUser,
  forces /sign-in. Filters out the legacy bootstrap-user row so an
  install that still has it pre-#902 isn't mis-routed.

Submodule bump
--------------
packages/owletto → feat/passkey-client (lobu-ai/owletto#8c6c283):
- authClient wires passkeyClient().
- login.tsx renders the Sign-in-with-passkey button + checks result.error
  (third codex finding: BA client returns failure as result.error, not
  via throw).
- login.tsx auto-toggles authIntent based on singleUserMode + hasUser.

Verified:
- `make typecheck` clean.
- `bun test packages/server/src/__tests__/unit` — 201 pass, 0 fail.
- Codex reviewed; 3 findings (origin freeze, rpID freeze, result.error
  not inspected) all fixed before push.

Not in this PR (deferred):
- Post-signup passkey enrollment prompt — operator needs to sign up
  with password first, then go to /settings to add a passkey. A
  one-click "enrol now?" after first sign-up is a follow-up.
- Passkey-only signup (no password at all) — requires the plugin's
  custom `resolveUser` callback + a user-create hook. Bigger lift.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds WebAuthn/passkey support: a DB migration and schema for passkeys, extended AuthConfig (passkey, singleUserMode, hasUser) with a DB-backed hasUser check, and wiring the Better Auth passkey plugin plus dependency updates for server and CLI.

Changes

Passkey WebAuthn Authentication Support

Layer / File(s) Summary
Database schema for passkey credentials
db/migrations/20260519000000_passkey_table.sql, db/schema.sql
Migration creates passkey table with WebAuthn credential fields, primary key, indexes on credentialID and userId, foreign key to user with ON DELETE CASCADE, and appends migration version to schema_migrations.
Authentication configuration and type updates
packages/server/src/auth/config.ts
AuthConfig extended with passkey, singleUserMode, and hasUser; getAuthConfig queries the DB (excluding bootstrap-user) to compute hasUser with a fail-closed fallback and returns the expanded config.
Passkey plugin initialization and configuration
packages/server/package.json, packages/cli/package.json, packages/server/src/auth/index.tsx
Added @better-auth/passkey dependency (v^1.6.9) to server and CLI; imported and configured the passkey plugin in createAuth() with rpID derived from PUBLIC_WEB_URL hostname (falls back to localhost), rpName set to "Lobu", and origin: null.

Sequence Diagram

sequenceDiagram
  participant getAuthConfig as getAuthConfig
  participant Database as Database
  participant createAuth as createAuth()
  participant PasskeyPlugin as BetterAuth Passkey Plugin
  getAuthConfig->>Database: query users (exclude 'bootstrap-user')
  Database-->>getAuthConfig: user existence boolean
  getAuthConfig->>createAuth: return AuthConfig (passkey, singleUserMode, hasUser)
  createAuth->>PasskeyPlugin: configure(passkey: { rpID, rpName: "Lobu", origin: null })
  PasskeyPlugin-->>createAuth: initialized
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • lobu-ai/lobu#830: Modifies createAuth() plugin wiring; overlaps at the auth initialization point where passkey() is added here.
  • lobu-ai/lobu#898: Related single-user mode work — both PRs touch LOBU_SINGLE_USER/hasUser logic in auth flow.

Poem

🐰 I hopped to add a key so neat,
stored passkeys where users meet,
rpID from hostname spun,
Better Auth plugin now begun,
secure hops, small and sweet.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main changes: adding passkey (WebAuthn) authentication and auth-config flags for local-mode routing, which are the core objectives of this PR.
Description check ✅ Passed The description covers the required template sections: Summary explains the changes, Test plan lists completed and pending tests with checkboxes, and Notes include linked companion PR and deferred items. All critical information is present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/local-passkey

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/server/src/auth/config.ts (1)

415-433: ⚡ Quick win

Skip the hasUser DB probe when single-user mode is off.

The query runs even when singleUserMode is false, which adds unnecessary DB load on a hot config path.

Refactor to gate the query
 const singleUserMode = env.LOBU_SINGLE_USER === '1';
 let hasUser = false;
-try {
-  const sql = getDb();
-  const rows = (await sql`
-    SELECT EXISTS(
-      SELECT 1 FROM "user" WHERE id <> 'bootstrap-user'
-    ) AS has_user
-  `) as unknown as Array<{ has_user: boolean }>;
-  hasUser = !!rows[0]?.has_user;
-} catch {
-  hasUser = false;
-}
+if (singleUserMode) {
+  try {
+    const sql = getDb();
+    const rows = (await sql`
+      SELECT EXISTS(
+        SELECT 1 FROM "user" WHERE id <> 'bootstrap-user'
+      ) AS has_user
+    `) as unknown as Array<{ has_user: boolean }>;
+    hasUser = !!rows[0]?.has_user;
+  } catch {
+    hasUser = false;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/auth/config.ts` around lines 415 - 433, The DB probe that
sets hasUser (using getDb() and the SQL EXISTS query) should be skipped when
singleUserMode is false to avoid unnecessary DB load; wrap the try/catch and the
SQL call that assigns hasUser in an if (singleUserMode) { ... } block (leaving
hasUser default false and the catch behavior unchanged) so the query only runs
when singleUserMode is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@db/migrations/20260519000000_passkey_table.sql`:
- Around line 18-29: The passkey table defines "credentialID" without a
uniqueness constraint, risking duplicate credentials; modify the migration to
enforce uniqueness by altering the table definition or adding a unique index on
"credentialID" (replace or convert the existing passkey_credential_id_idx to a
UNIQUE index) so that "credentialID" is unique across the "passkey" table and
migrations will fail if duplicates exist.

In `@packages/server/src/auth/index.tsx`:
- Around line 537-548: The rpID resolution IIFE currently falls back to
"localhost" when PUBLIC_WEB_URL is missing/invalid; change it to fail fast in
production by detecting production mode (e.g., process.env.NODE_ENV ===
"production" or an existing isProduction flag) and throw a clear error (or call
process.exit(1)) if PUBLIC_WEB_URL is unset or yields an invalid URL/hostname,
otherwise keep the existing parsing behavior for non-production; update the rpID
assignment code (the rpID: (() => { ... })() block) to perform this check and
surface a descriptive error mentioning PUBLIC_WEB_URL and rpID resolution.

---

Nitpick comments:
In `@packages/server/src/auth/config.ts`:
- Around line 415-433: The DB probe that sets hasUser (using getDb() and the SQL
EXISTS query) should be skipped when singleUserMode is false to avoid
unnecessary DB load; wrap the try/catch and the SQL call that assigns hasUser in
an if (singleUserMode) { ... } block (leaving hasUser default false and the
catch behavior unchanged) so the query only runs when singleUserMode is true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 02ed667c-d3c7-4ee3-be02-fe14004f4b9b

📥 Commits

Reviewing files that changed from the base of the PR and between f6522b3 and 560d133.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • db/migrations/20260519000000_passkey_table.sql
  • packages/owletto
  • packages/server/package.json
  • packages/server/src/auth/config.ts
  • packages/server/src/auth/index.tsx

Comment on lines +18 to +29
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);

CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce uniqueness for credentialID.

credentialID is currently only indexed, not constrained unique. Duplicate credential IDs can make passkey lookup ambiguous during authentication.

Suggested migration adjustment
-CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");
+CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_id_uq ON "passkey"("credentialID");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);
CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);
CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_id_uq ON "passkey"("credentialID");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/migrations/20260519000000_passkey_table.sql` around lines 18 - 29, The
passkey table defines "credentialID" without a uniqueness constraint, risking
duplicate credentials; modify the migration to enforce uniqueness by altering
the table definition or adding a unique index on "credentialID" (replace or
convert the existing passkey_credential_id_idx to a UNIQUE index) so that
"credentialID" is unique across the "passkey" table and migrations will fail if
duplicates exist.

Comment on lines +537 to +548
rpID: (() => {
const publicWebUrl = process.env.PUBLIC_WEB_URL?.trim();
if (publicWebUrl) {
try {
const host = new URL(publicWebUrl).hostname;
if (host) return host;
} catch {
/* fallthrough to default */
}
}
return "localhost";
})(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast in production when PUBLIC_WEB_URL is missing/invalid.

Falling back to "localhost" in production can silently misconfigure RP ID and break passkey ceremonies at runtime.

Safer rpID resolution
 rpID: (() => {
 	const publicWebUrl = process.env.PUBLIC_WEB_URL?.trim();
-	if (publicWebUrl) {
-		try {
-			const host = new URL(publicWebUrl).hostname;
-			if (host) return host;
-		} catch {
-			/* fallthrough to default */
-		}
-	}
-	return "localhost";
+	if (!publicWebUrl) {
+		if (runtimeNodeEnv === "production") {
+			throw new Error("PUBLIC_WEB_URL is required for passkey auth in production");
+		}
+		return "localhost";
+	}
+	try {
+		const host = new URL(publicWebUrl).hostname;
+		if (host) return host;
+	} catch {
+		if (runtimeNodeEnv === "production") {
+			throw new Error("PUBLIC_WEB_URL must be a valid absolute URL for passkey auth");
+		}
+	}
+	return "localhost";
 })(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/auth/index.tsx` around lines 537 - 548, The rpID
resolution IIFE currently falls back to "localhost" when PUBLIC_WEB_URL is
missing/invalid; change it to fail fast in production by detecting production
mode (e.g., process.env.NODE_ENV === "production" or an existing isProduction
flag) and throw a clear error (or call process.exit(1)) if PUBLIC_WEB_URL is
unset or yields an invalid URL/hostname, otherwise keep the existing parsing
behavior for non-production; update the rpID assignment code (the rpID: (() => {
... })() block) to perform this check and surface a descriptive error mentioning
PUBLIC_WEB_URL and rpID resolution.

CI catches:
1. db/schema.sql must match `dbmate up` output (Schema snapshot drift
   check). My branch added a migration that creates the `passkey` table
   but forgot to regenerate db/schema.sql.
2. CLI declares the runtime deps the embedded server bundle pulls at
   start (dev.test.ts 'CLI package declares runtime deps'). Adding
   @better-auth/passkey to packages/server/* needs the matching entry
   in packages/cli/package.json so `lobu run` can resolve it.

Hand-edited db/schema.sql to insert only the four passkey blocks
(CREATE TABLE, PK, two INDEXes, FK) at the alphabetically correct
positions. Avoided a full `make db-schema` regen because my local
Postgres has postgis enabled (not in CI's pg16 image) so a full regen
would also add postgis extension + geo_lookup function rows that
shouldn't be in the snapshot.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@db/schema.sql`:
- Around line 1524-1536: The schema snapshot adds the passkey table DDL but the
schema_migrations seed wasn’t updated, causing the new migration
(20260519000000_passkey_table.sql) to be considered pending; update the
schema_migrations seed to include the new migration ID so the snapshot and
migration history match. Locate the seed data insertion for schema_migrations
(which currently ends at 20260518070000) and append the 20260519000000 entry
(and similarly update the other missing spots noted around lines ~2747, ~4176,
~4974) so that the passkey table creation migration is recorded and will not be
replayed by dbmate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3199e6af-258f-4d2c-a9c9-1cbf3758fc7a

📥 Commits

Reviewing files that changed from the base of the PR and between 560d133 and f8f41d6.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • db/schema.sql
  • packages/cli/package.json

Comment thread db/schema.sql Outdated
buremba added 2 commits May 19, 2026 04:22
Previous patch put the passkey table after personal_access_tokens, but
'passkey' < 'pending_interactions' < 'personal_access_tokens'
alphabetically — dbmate's pg_dump emits them in alpha order, so CI's
schema-drift check flagged the mis-positioning. Re-anchored each block:

- TABLE: before pending_interactions
- PK CONSTRAINT: before pending_interactions_pkey
- INDEXes: before personal_access_tokens_active_idx (after oauth_tokens_*)
- FK CONSTRAINT: before pending_interactions_organization_id_fkey
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@db/schema.sql`:
- Around line 1456-1472: The schema contains duplicate DDL for the passkey
artifacts causing "already exists" errors; locate the duplicate CREATE TABLE
public.passkey block and the repeated definitions for passkey_pkey (PRIMARY
KEY), passkey_credential_id_idx, passkey_user_id_idx (INDEXes) and
passkey_userId_fkey (FK) and remove the second occurrences so each object
(CREATE TABLE, primary key, indexes, foreign key) is defined only once; keep the
first/canonical definitions and delete the later duplicated blocks, then run a
quick SQL parse to verify no remaining duplicate object names.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eb619d51-7f62-408a-9769-3d08c96d8374

📥 Commits

Reviewing files that changed from the base of the PR and between f8f41d6 and 07a0c5e.

📒 Files selected for processing (2)
  • db/schema.sql
  • packages/owletto
✅ Files skipped from review due to trivial changes (1)
  • packages/owletto

Comment thread db/schema.sql
buremba added 2 commits May 19, 2026 04:26
Previous attempts left duplicate passkey blocks: re-patching after
git checkout but before merge layered the new blocks on top of the
old wrong-position ones. Reset db/schema.sql to origin/main's clean
state, then spliced the 4 passkey blocks once at the correct positions
(alphabetical: passkey < pending_interactions < personal_access_tokens)
+ inserted the 20260519000000 row in INSERT INTO schema_migrations.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@db/schema.sql`:
- Line 1561: Change the transports column from a scalar TEXT to a Postgres
text[] array to match the `@better-auth/passkey` expectation: locate the column
definition named "transports" in the schema (currently "transports text,") and
update it to use the text[] type; also update any related DEFAULTs, INSERTs, or
CHECK constraints to use array syntax and add a small migration step to convert
existing scalar values into single-element arrays (or NULL) so existing data
remains valid.
- Around line 1552-1564: The passkey table lacks a uniqueness constraint on the
credentialID column which must be globally unique; add a UNIQUE constraint/index
for "credentialID" on the passkey table (referencing the passkey table and its
"credentialID" column) — either create a unique index on passkey("credentialID")
or run an ALTER TABLE passkey ADD CONSTRAINT ... UNIQUE("credentialID") after
the primary key is defined so duplicate credential IDs cannot be inserted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e0573c0-bbba-4697-bee5-82b1415ff5e4

📥 Commits

Reviewing files that changed from the base of the PR and between d32589f and d3c7466.

📒 Files selected for processing (1)
  • db/schema.sql

Comment thread db/schema.sql
Comment on lines +1552 to +1564
CREATE TABLE public.passkey (
id text NOT NULL,
name text,
"publicKey" text NOT NULL,
"userId" text NOT NULL,
"credentialID" text NOT NULL,
counter bigint DEFAULT 0 NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean DEFAULT false NOT NULL,
transports text,
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
aaguid text
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the @better-auth/passkey plugin require a unique constraint on the credentialID column in the passkey table schema?

💡 Result:

Yes, the @better-auth/passkey plugin requires the credentialID column to be unique [1]. In the official schema definition for the passkey table, the credentialID is identified as the unique identifier for the registered credential [2][1]. While the plugin's documentation and schema generation tools (such as the CLI migrate or generate commands) handle the creation of the necessary table structure [3][4][1], it is standard practice and a functional requirement for the credentialID to be unique to ensure that each passkey can be correctly identified and retrieved during the authentication ceremony [2][1]. If you are defining the schema manually, you should ensure that the credentialID column has a unique constraint applied to it [1].

Citations:


Add UNIQUE constraint on credentialID column.

The credentialID column stores the WebAuthn credential identifier, which must be globally unique per the WebAuthn specification and the @better-auth/passkey plugin requirements. During authentication, the server looks up credentials by this ID; without a UNIQUE constraint, duplicate credential IDs could be stored, breaking the authentication flow.

Proposed fix

Add a unique index on credentialID. After the existing non-unique index at line 4325:

 CREATE INDEX passkey_credential_id_idx ON public.passkey USING btree ("credentialID");
+CREATE UNIQUE INDEX passkey_credential_id_key ON public.passkey USING btree ("credentialID");

Alternatively, add the constraint via ALTER TABLE after the primary key constraint at line 2875:

 ALTER TABLE ONLY public.passkey
     ADD CONSTRAINT passkey_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY public.passkey
+    ADD CONSTRAINT passkey_credential_id_key UNIQUE ("credentialID");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/schema.sql` around lines 1552 - 1564, The passkey table lacks a uniqueness
constraint on the credentialID column which must be globally unique; add a
UNIQUE constraint/index for "credentialID" on the passkey table (referencing the
passkey table and its "credentialID" column) — either create a unique index on
passkey("credentialID") or run an ALTER TABLE passkey ADD CONSTRAINT ...
UNIQUE("credentialID") after the primary key is defined so duplicate credential
IDs cannot be inserted.

Comment thread db/schema.sql
counter bigint DEFAULT 0 NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean DEFAULT false NOT NULL,
transports text,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the expected data type for the transports column in the @better-auth/passkey schema - TEXT, text array, or jsonb?

💡 Result:

The expected data type for the transports column in the @better-auth/passkey schema is a text array (Postgres type: text[]). Evidence: Better Auth’s passkey plugin documentation describes transports as “The transports used to register the passkey” in the generated passkey table schema [1]. The underlying Better Auth schema type definition for the passkey plugin declares transports as an array of strings, which maps to a Postgres text[] column in the generated schema [2].

Citations:


Change transports column type from TEXT to text[].

The @better-auth/passkey schema expects transports as a Postgres array type (text[]) to store the list of WebAuthn transports (e.g., ["usb", "nfc", "ble"]), not a scalar TEXT column. This is required for the plugin to correctly handle transport data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/schema.sql` at line 1561, Change the transports column from a scalar TEXT
to a Postgres text[] array to match the `@better-auth/passkey` expectation: locate
the column definition named "transports" in the schema (currently "transports
text,") and update it to use the text[] type; also update any related DEFAULTs,
INSERTs, or CHECK constraints to use array syntax and add a small migration step
to convert existing scalar values into single-element arrays (or NULL) so
existing data remains valid.

@buremba buremba merged commit 54de2e0 into main May 19, 2026
27 checks passed
@buremba buremba deleted the feat/local-passkey branch May 19, 2026 03:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants