Skip to content

feat(auth): native WebAuthn passkeys for panel login#4544

Open
iyobo wants to merge 14 commits into
Dokploy:canaryfrom
iyobo:feat/native-passkeys
Open

feat(auth): native WebAuthn passkeys for panel login#4544
iyobo wants to merge 14 commits into
Dokploy:canaryfrom
iyobo:feat/native-passkeys

Conversation

@iyobo

@iyobo iyobo commented Jun 3, 2026

Copy link
Copy Markdown

Summary

This PR adds WebAuthn passkeys (Touch ID, Face ID, Windows Hello, hardware security keys) to Dokploy panel login and account settings. It uses Better Auth's @better-auth/passkey plugin pinned to 1.5.4, matching the existing better-auth / @better-auth/* versions so no 1.6 upgrade is pulled in.

Passkeys give users passwordless, phishing-resistant sign-in that runs alongside the existing email/password, SSO, and TOTP 2FA factors rather than replacing them. This implements the request in #1296.

What changed (high level)

  • Server auth (auth.ts): the passkey() plugin is wired in and the Relying Party (rpID / origin) is resolved at startup from a single priority chain. Better Auth's baseURL is left unset (matching canary); the handler infers it per request, and passkey origin validation uses the request Origin header plus trustedOrigins. No global auth behavior changes.
  • Schema generation (auth-cli.ts): a minimal, static-RP Better Auth config exists solely so @better-auth/cli generate can regenerate auth-schema2.ts — it is never used at runtime.
  • Database: a new passkey table is added via Drizzle migration 0172_huge_next_avengers.sql, which also runs automatically on container start.
  • Login (pages/index.tsx): a "Sign in with passkey" button and conditional autofill are added. A user-verifying passkey is treated as complete MFA, so passkey sign-in goes straight to the dashboard without a TOTP step (see Behavioral notes).
  • Profile (manage-passkeys.tsx): users can list, register, rename, and delete passkeys, with a separate "Use security key instead" path for cross-platform authenticators.
  • Hardening: a client-side ceremony mutex prevents overlapping WebAuthn prompts, conditional-UI sessions use a generation counter so they survive React StrictMode double-mounts, and there are hooks for 2FA and email verification plus an origin preflight check.
  • Audit: passkey create and delete are recorded under a new passkey audit resource type.
  • Tests: unit suites cover the ceremony state machine, error-message mapping, and RP resolution.
  • Build/infra: the Dockerfile server build switches from tsc to an esbuild bundle (see Build & infra changes).

Architecture / key files

Area File Purpose
RP resolution packages/server/src/lib/passkey-rp.ts Resolves rpID / rpName / origin via a priority chain; pure, unit-tested helpers
Auth wiring packages/server/src/lib/auth.ts passkey() plugin, trustedOrigins, and before/after hooks (email verify, audit)
Schema CLI packages/server/src/lib/auth-cli.ts Minimal static-RP config used only by @better-auth/cli generate
DB schema packages/server/src/db/schema/passkey.ts passkey table, relation, and indexes
Migration apps/dokploy/drizzle/0172_huge_next_avengers.sql Creates the passkey table
Login UI apps/dokploy/pages/index.tsx Passkey sign-in button and 2FA redirect handling
Conditional UI apps/dokploy/utils/hooks/use-passkey-conditional-ui.ts Passive autofill with stale-session guarding
Ceremony helpers apps/dokploy/lib/passkey-ceremony.ts Mutex, abort/timeout/security detection, error-copy mapping, origin preflight
Profile UI apps/dokploy/components/dashboard/settings/profile/manage-passkeys.tsx Manage passkeys
Audit packages/server/src/db/schema/audit-log.ts, components/proprietary/audit-logs/data-table.tsx passkey resource type and its rendering

RP (Relying Party) resolution — single source of truth

resolvePasskeyRpConfig() chooses the WebAuthn rpID / origin once when auth.ts initializes, because the plugin only accepts static values (this is why a restart is required after URL changes). It resolves in priority order:

  1. Cloud (IS_CLOUD) uses a hardcoded https://app.dokploy.com.
  2. Env override uses BETTER_AUTH_URL or NEXT_PUBLIC_APP_URL if set (trimmed, trailing slash stripped). This is optional.
  3. Development (NODE_ENV=development, no env) falls back to http://localhost:${PORT}.
  4. Settings → Server (the default for self-hosted) builds the URL from Host + HTTPS, or from serverIp:PORT if Host is unset.
  5. Last resort is the localhost fallback.

rpID is the hostname (localhost stays localhost). No static origin is passed to the plugin, so the verify step validates against the request Origin header instead, and the resolved origin is appended to trustedOrigins.

Behavioral notes

No global auth changes. baseURL is deliberately left unset (as on canary), so email-verification and password-reset links keep using their request-inferred URL. Verified locally: with the bundle rebuilt, get-session and the passkey option endpoints return 200 without baseURL, so it is not needed for routing or for passkeys.

Passkey + TOTP 2FA. A passkey that performed user verification (Touch ID / Face ID / Windows Hello / PIN) is already phishing-resistant multi-factor auth — possession of the authenticator plus a biometric/PIN — so it satisfies 2FA on its own. Passkey sign-in therefore completes the session directly and does not prompt for a TOTP code, matching how Google, Microsoft, GitHub, and Apple treat passkey sign-in. TOTP remains required for the email/password flow.

Hooks — scoped to passkey paths. The hooks below are guarded by ctx.path and only fire on the new /passkey/* endpoints, so existing email-login, SSO, and reset flows are unchanged.

  • Cloud email verification is enforced. When IS_CLOUD and requireEmailVerification are set, an unverified user who signs in via passkey is rejected (session deleted) and a fresh verification email is sent — matching the existing email-login enforcement.
  • Audit logging is wired through hooks. An after hook records verify-registration (create) and delete-passkey (delete), and a before hook snapshots the passkey's name prior to deletion because the row no longer exists by the time the after hook runs.

Database schema

The migration adds one table, passkey (packages/server/src/db/schema/passkey.ts). Each row is a single registered credential bound to a user.

Column Type Null Notes
id text no Primary key
name text yes User-supplied label (e.g. "MacBook Touch ID")
public_key text no Credential public key used to verify assertions
user_id text no FK → user(id), ON DELETE CASCADE; indexed (passkey_userId_idx)
credential_id text no WebAuthn credential identifier; indexed (passkey_credentialID_idx)
counter integer no Signature counter for clone/replay detection
device_type text no Authenticator type (e.g. platform vs cross-platform)
backed_up boolean no Whether the credential is synced/backed up (e.g. iCloud Keychain)
transports text yes Comma-separated transport hints (usb, nfc, ble, internal)
created_at timestamp yes Registration time
aaguid text yes Authenticator model identifier

Because user_id cascades, deleting a user automatically removes all of their passkeys.

Dependencies

  • @better-auth/passkey@1.5.4, pinned to the existing 1.5.x line.

Added to packages/server and apps/dokploy, and pnpm-lock.yaml is updated.

User flows

You can walk through each flow end-to-end on a self-hosted instance, provided HTTPS is enabled and Settings → Server (Host + HTTPS) matches the URL you open in the browser.

1. First-time passkey setup (requires an existing account)

  1. Open the login page and sign in with email + password (or SSO, if configured and not enforced-only).
  2. Go to Settings → Profile (/dashboard/settings/profile).
  3. Click Manage passkeys in the Account card.
  4. Optionally name the passkey (e.g. "MacBook Touch ID").
  5. Click Add passkey; the dialog closes and you complete the platform prompt (Touch ID / Windows Hello / security key).
  6. Confirm the new passkey appears in the list.
  7. Sign out.
  8. On the login page, click Sign in with passkey, select the passkey, and land on the dashboard.

Expected: step 8 succeeds. A user who clicks passkey sign-in before completing steps 1–6 sees "No passkey is set up for this site yet…" rather than an error overlay.

2. Passkey sign-in with 2FA enabled

  1. Enable TOTP 2FA on the account (existing flow).
  2. Sign out.
  3. Click Sign in with passkey and complete the device prompt.
  4. Expected: you land on the dashboard directly — no TOTP prompt. The passkey already provided user-verified, phishing-resistant MFA, so it satisfies 2FA on its own.
  5. Confirm that the email/password flow still shows the TOTP screen for the same account (the second factor is only skipped for passkeys).

3. Conditional passkey autofill (supported browsers)

  1. Sign out with at least one passkey registered.
  2. Open the login page and focus the Email field.
  3. Expected: the browser may offer a passkey autofill suggestion. This is silent — no toast appears on failure or when none exist.
  4. Click Sign in with passkey manually; the device prompt still works and is not blocked by autofill.

Autofill is gated behind isConditionalMediationAvailable, deferred until the email field is focused, and disabled while Enforce SSO is on or the 2FA screen is showing. Conditional sessions use a generation counter so StrictMode double-mounts and manual-button preemption never leak ceremonies.

4. Delete a passkey

  1. Sign in, then go to Settings → Profile → Manage passkeys.
  2. Delete a passkey from the list.
  3. Sign out.
  4. Try Sign in with passkey using the deleted credential, which may still appear in the OS passkey picker.

Expected: the error reads "This passkey is no longer registered with Dokploy. Remove it from your device (Passwords or iCloud Keychain)…", which is deliberately distinct from the first-time "No passkey is set up for this site yet" message.

  1. Remove the stale entry from System Settings → Passwords (or iCloud Keychain) so it stops appearing in the picker.
  2. Sign in with email/password and register a fresh passkey if needed.

5. Security key registration

  1. Go to Settings → Profile → Manage passkeys.
  2. Click Use security key instead (cross-platform authenticator).
  3. Click Add passkey and complete the YubiKey / FIDO2 prompt.
  4. Sign out and sign back in with the security key.

6. SSO enforce mode (Enterprise)

  1. Enable SSO and turn on Enforce SSO (Settings → SSO).
  2. Open the login page.

Expected: only SSO sign-in is shown; the email/password and passkey buttons are hidden. Registration still requires an authenticated session via Profile, so operators must temporarily disable enforce mode if users need to register passkeys through password login first.


Self-hosted operators

Passkeys need no environment variables in most installs. The passkey Relying Party (rpID and origin) is derived from your public Dokploy URL using the existing getDokployUrl() logic, so configuring that URL correctly is the only requirement.

Standard path — Settings → Server. Set Host to the exact hostname users type in their browser (e.g. dokploy.example.com) and enable HTTPS for any non-localhost host (WebAuthn requires a secure context; only localhost may use plain HTTP). That's enough for registration and sign-in to work.

Optional env override. BETTER_AUTH_URL or NEXT_PUBLIC_APP_URL pin the URL in container env instead of the UI — use only for local dev (http://localhost:3000), GitOps/manifest-driven URLs, or fixing a stale Host. When set, they override Settings → Server, so a wrong value silently breaks passkeys.

Cloud (IS_CLOUD) is automatic: the RP is fixed to https://app.dokploy.com.

Restart after URL changes. The RP is resolved once at startup, so any change to Host/HTTPS or an env override requires a Dokploy restart. Passkeys are origin-bound — if the public URL changes, existing passkeys must be re-registered.

Build & infra changes

Dockerfile. The @dokploy/server build step changes from pnpm --filter=@dokploy/server build (tsc) to run switch:prod && exec tsx esbuild.config.ts. The reason is that tsc hits a stack overflow on this tree, whereas esbuild bundles the server dist reliably. This is the only non-auth change that affects the runtime, so reviewers and CI should confirm the produced image still works.

.gitignore. Ignores .env.production and a local plans/ notes folder.

Env examples. .env.example and .env.production.example now document the optional BETTER_AUTH_URL override, left commented out by default.

Automated tests

apps/dokploy/__test__/lib/passkey-ceremony.test.ts covers concurrent-ceremony rejection (the busy message), sequential reuse, detection of abort / NotAllowedError / SecurityError, and the full getPasskeyErrorMessage mapping (stale vs first-time vs cancelled vs origin-mismatch, per flow).

apps/dokploy/__test__/lib/passkey-rp.test.ts covers RP resolution across cloud, env-override precedence, dev localhost, Settings → Server, the serverIp fallback, and the error fallback, plus originMatchesRpConfig and passkeyRpFromOrigin.

Test plan

  • Run the migration on dev/staging (0172_huge_next_avengers.sql; it also runs automatically on container start).
  • Confirm the passkey unit suites pass (pnpm test).
  • Build the Docker image and smoke-test it, which validates the esbuild build switch.
  • Confirm Settings → Server Host + HTTPS match the browser URL (the typical path, with no env override).
  • Flow 1: email login → Profile → add passkey → sign out → passkey sign-in.
  • Flow 2: with 2FA enabled, passkey sign-in lands on the dashboard with no TOTP prompt; email/password sign-in still requires TOTP.
  • Flow 3: focus the Email field → conditional autofill suggestion (Chrome/Safari); the manual button still works.
  • Flow 4: delete passkey → sign out → attempt the deleted passkey → stale error copy (not first-time copy) plus an audit log entry.
  • Flow 5: security key registration path.
  • Flow 6: Enforce SSO hides the passkey login button.
  • Origin preflight (dev): 127.0.0.1 vs localhost shows a clear error when the optional dev override expects localhost.

Security notes

  • Passkeys are origin-bound, which makes them phishing-resistant compared to copy-pasted TOTP codes; verification uses the request Origin header.
  • Registration requires an authenticated session, which is Better Auth's default behavior.
  • TOTP 2FA stays available and is still enforced for the email/password flow. A user-verifying passkey is itself MFA (possession + biometric/PIN), so passkey sign-in does not additionally require TOTP — consistent with major providers and FIDO guidance.
  • On Cloud, accounts with unverified email cannot complete passkey sign-in — the session is torn down and verification is re-sent.
  • Audit logs record passkey create and delete under resourceType: passkey.
  • passkey rows cascade-delete with the owning user.

Related

Co-authored-by: Cursor <cursoragent@cursor.com>
@iyobo iyobo requested a review from Siumauricio as a code owner June 3, 2026 23:35
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. enhancement New feature or request labels Jun 3, 2026
@iyobo iyobo marked this pull request as draft June 3, 2026 23:46
iyobo and others added 8 commits June 3, 2026 19:22
Resolve WebAuthn RP/origin from env or server settings, document self-hosted setup, add audit logs for passkey lifecycle, and polish login/profile UX with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remove login autofill race, improve Abort/NotAllowed handling, close register dialog before WebAuthn, align trustedOrigins/baseURL, and add passkey-hardening plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
…l UI

Enforce TOTP on passkey sign-in, centralize WebAuthn ceremony handling, restore safe conditional autofill, and align origin/trustedOrigins config.

Co-authored-by: Cursor <cursoragent@cursor.com>
…n-in

Keep passive email-field mediation outside the explicit-action mutex so the sign-in button can open the device prompt instead of showing a false busy error.

Co-authored-by: Cursor <cursoragent@cursor.com>
Start email-field autofill only after focus, preempt it before manual sign-in, and swallow abort/not-allowed errors so the login page never shows a Next.js overlay.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fix a React batching race where pendingAdd was still false when onOpenChange fired, so Add passkey closed the modal without ever opening the WebAuthn prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
Separate server PASSKEY_NOT_FOUND from browser NotAllowedError so deleted passkeys show removal guidance instead of first-login copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
Merge upstream/canary and renumber the passkey table migration to 0172 after upstream landed 0170 (forward auth) and 0171 (SSO FK).

Co-authored-by: Cursor <cursoragent@cursor.com>
@iyobo iyobo marked this pull request as ready for review June 7, 2026 17:06
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jun 7, 2026
@iyobo

iyobo commented Jun 7, 2026

Copy link
Copy Markdown
Author

@Siumauricio let's give the people what they want 👍🏽
This implements browser Passkey authentication, so folks no longer need to struggle with authenticator apps.

The magic begins at Settings -> Profile after you login.
See "Manage Passkeys" at the top right.
image

image image

iyobo and others added 5 commits June 7, 2026 12:36
Document Settings → Server as the default passkey origin path, soften error copy, and add fleet deploy notes without requiring env URL pins.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use esbuild for @dokploy/server in the Dockerfile, add @better-auth/utils for Next bundling, and point server package exports at dist so production builds resolve reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop fleet-specific planning docs from the upstream-facing branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep personal planning notes and root .env.production local; remove
links to untracked plans from env examples and passkey copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
Treat a user-verifying passkey as complete MFA so passkey sign-in
completes the session directly instead of forcing a TOTP step; TOTP
stays enforced for the email/password flow. Remove the global
baseURL override (unnecessary for routing and passkeys; matches
canary) along with the now-dead trust-device/HMAC code and the
@better-auth/utils dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant