Skip to content

Add passkeys to the account page#685

Merged
markusahlstrand merged 2 commits intomainfrom
account-passkeys
Apr 5, 2026
Merged

Add passkeys to the account page#685
markusahlstrand merged 2 commits intomainfrom
account-passkeys

Conversation

@markusahlstrand
Copy link
Copy Markdown
Owner

@markusahlstrand markusahlstrand commented Apr 3, 2026

Summary by CodeRabbit

  • New Features
    • Passkeys management added to the account page.
    • Add new passkeys via browser-based registration.
    • Remove existing passkeys from an account.
    • Rename passkeys for easier identification.
    • Passkey enrollment status and counts shown in Security settings.
    • New dedicated Passkeys screen and direct link from account settings.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
authero-docs Ready Ready Preview, Comment Apr 5, 2026 7:12am
authhero-react-admin Ready Ready Preview, Comment Apr 5, 2026 7:12am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 3, 2026

📝 Walkthrough

Walkthrough

A passkey management screen was added for account pages (GET/POST) to list, rename, add, and remove passkeys. Shared WebAuthn utilities were extracted, account/security UIs were updated to separate passkeys from MFA, and new u2 routes and registry entries expose the screen.

Changes

Cohort / File(s) Summary
Changeset
/.changeset/tricky-dodos-double.md
Minor version bump for authhero with release note "Add passkeys to the account page".
Passkey Management Screen
packages/authhero/src/routes/universal-login/screens/account-passkeys.ts
New screen definition with GET/POST handlers for listing, renaming, removing, and adding passkeys; includes WebAuthn registration flow, session state handling, validation, and error/success rendering.
Shared WebAuthn Utilities
packages/authhero/src/routes/universal-login/screens/passkey-utils.ts
New utility module exporting PASSKEY_TYPES, getRpId(), getExpectedOrigin(), and buildWebAuthnRegistrationScript() to centralize WebAuthn origin/rp logic and client script generation.
Passkey Enrollment Refactor
packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts
Replaced inline WebAuthn script and host/origin helpers with imports from passkey-utils; switched to buildWebAuthnRegistrationScript() and PASSKEY_TYPES filtering.
Account UI Integration
packages/authhero/src/routes/universal-login/screens/account.ts, packages/authhero/src/routes/universal-login/screens/account-security.ts
Separated passkey and MFA counts, added conditional passkeys link when enabled, and excluded passkey types from MFA enrollment listings.
Route & Registry Registration
packages/authhero/src/routes/universal-login/screens/registry.ts, packages/authhero/src/routes/universal-login/u2-routes.tsx
Registered account-passkeys in screen registry and added GET/POST U2 routes (/u2/account/passkeys) and mapping to the "common" prompt.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Page as Account Passkeys Screen
    participant Server as Server (GET/POST handlers)
    participant WebAuthn as Browser WebAuthn API
    participant Session as Session Store
    participant DB as AuthenticationMethods DB

    User->>Page: GET /u2/account/passkeys
    Page->>Server: load user & enrollments
    Server->>DB: fetch confirmed passkey enrollments
    DB-->>Server: enrollments
    Server-->>Page: render list

    alt Remove Passkey
        User->>Page: POST action=remove_passkey
        Page->>Server: validate & delete enrollment
        Server->>DB: delete enrollment
        DB-->>Server: deleted
        Server-->>Page: render success
    end

    alt Rename Passkey
        User->>Page: POST action=rename_passkey
        Page->>Server: validate & update friendly_name
        Server->>DB: update enrollment
        DB-->>Server: updated
        Server-->>Page: render success
    end

    alt Add Passkey (Start)
        User->>Page: POST action=start_add_passkey
        Page->>Server: generate registration options
        Server->>Session: store challenge
        Server-->>Page: inject registration script
        Page->>WebAuthn: navigator.credentials.create()
        WebAuthn-->>Page: credential
        Page->>Server: POST action=complete_add_passkey (credential)
        Server->>Session: retrieve challenge
        Server->>WebAuthn: verify registration response
        WebAuthn-->>Server: verification result
        Server->>DB: create confirmed passkey enrollment
        DB-->>Server: created
        Server-->>Page: render success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I found a key that hops with me,

On accounts now it lives with glee.
Add, rename, remove — a gentle dance,
WebAuthn magic gives users a chance.
Hooray for safer logins, one-two-three!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add passkeys to the account page' directly and clearly summarizes the primary change—adding passkey management functionality to the account interface.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch account-passkeys

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts (1)

246-248: 🛠️ Refactor suggestion | 🟠 Major

Inconsistent: local passkeyTypes array should use shared PASSKEY_TYPES.

The GET handler still defines a local passkeyTypes array at line 246, while generateFreshOptionsJSON at lines 158-160 correctly uses the imported PASSKEY_TYPES. This creates an inconsistency—if PASSKEY_TYPES is updated, this local array would be out of sync.

Use shared PASSKEY_TYPES constant
       // Get existing passkey credentials to exclude
       const enrollments = await ctx.env.data.authenticationMethods.list(
         client.tenant.id,
         user.user_id,
       );
-      const passkeyTypes = ["passkey", "webauthn-roaming", "webauthn-platform"];
       const excludeCredentials = enrollments
-        .filter((e) => passkeyTypes.includes(e.type) && e.credential_id)
+        .filter(
+          (e) =>
+            PASSKEY_TYPES.includes(e.type as (typeof PASSKEY_TYPES)[number]) &&
+            e.credential_id,
+        )
         .map((e) => ({
           id: e.credential_id!,
           transports: (e.transports || []) as AuthenticatorTransport[],
         }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts`
around lines 246 - 248, Replace the locally defined passkeyTypes array with the
shared imported PASSKEY_TYPES constant to avoid drift: remove the const
passkeyTypes = ["passkey", "webauthn-roaming", "webauthn-platform"] and use
PASSKEY_TYPES in the excludeCredentials computation (the same constant already
used by generateFreshOptionsJSON) so both places reference the single source of
truth.
🧹 Nitpick comments (3)
packages/authhero/src/routes/universal-login/screens/account-passkeys.ts (2)

308-308: Clarify: empty action triggers passkey registration.

The condition action === "start_add_passkey" || action === "" means clicking the "Add Passkey" button (which doesn't set an action value) will start registration. This is likely intentional since the button at line 155-164 is a NEXT_BUTTON without explicit action, but a brief comment would improve readability.

Add clarifying comment
+  // --- Start add passkey (generate WebAuthn options) ---
+  // Empty action comes from the "Add Passkey" NEXT_BUTTON which doesn't set a specific action
   if (action === "start_add_passkey" || action === "") {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts` at
line 308, The check `if (action === "start_add_passkey" || action === "")` in
the passkey registration flow is relying on the NEXT_BUTTON (which doesn't set
an explicit action) to start registration when `action` is empty; add a concise
comment immediately above this condition explaining that an empty `action` is
intentional and originates from the NEXT_BUTTON click so readers understand why
`""` is included (reference the `action` variable and the NEXT_BUTTON control
used to start registration in this screen).

346-359: Potential race condition when storing WebAuthn challenge.

The read-modify-write pattern for state_data (read existing → merge → write) can lose concurrent updates if the user has multiple tabs open or rapid interactions. This is a known pattern across the codebase (per context snippets), but worth noting for future hardening.

Since this follows the existing codebase pattern and the risk is low for typical single-tab usage, this is informational rather than a blocker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`
around lines 346 - 359, The current read-modify-write using
loginSessions.get(...) then loginSessions.update(...) can drop concurrent
changes to state_data (race when multiple tabs); change to an
atomic/transactional update or use a server-side merge API so you only set
webauthn_challenge without clobbering other fields—e.g., replace the get+update
flow around loginSessions.get / loginSessions.update and state_data with a
single atomic update/merge that adds webauthn_challenge to the existing
state_data JSON (or implement optimistic concurrency via a version field) so
concurrent updates to state_data are preserved.
packages/authhero/src/routes/universal-login/screens/passkey-utils.ts (1)

26-30: Minor: localhost substring match could affect edge-case hostnames.

The host.includes("localhost") check at line 28 would also match hostnames like myapp.localhost.example.com or notlocalhost.dev, forcing HTTP protocol unexpectedly. Consider using a more precise check.

Proposed stricter localhost check
 export function getExpectedOrigin(ctx: any): string {
   const host = ctx.var.host || "localhost";
-  const protocol = host.includes("localhost") ? "http" : "https";
+  const hostWithoutPort = host.split(":")[0];
+  const protocol = hostWithoutPort === "localhost" ? "http" : "https";
   return `${protocol}://${host}`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/passkey-utils.ts` around
lines 26 - 30, The getExpectedOrigin function currently uses
host.includes("localhost") which can mis-detect hosts like
"myapp.localhost.example.com"; change the check in getExpectedOrigin to perform
a precise localhost match against ctx.var.host (e.g., exact hostname or hostname
with port such as "localhost" or "localhost:3000") or use a regex that matches
^localhost(:\d+)?$ and also consider explicit loopback addresses like 127.0.0.1
and ::1 so the protocol decision (http vs https) is only triggered for true
loopback hosts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`:
- Around line 98-99: The inline onclick handler for the Rename button constructs
a prompt default using manual single-quote escaping (name.replace(/'/g, "\\'")),
which is unsafe; update the handler in the Rename button's onclick to use
JSON.stringify(name) for proper JS string escaping and remove the surrounding
single quotes around the prompt default so the injected value is a valid quoted
JS string; keep the existing escapeHtml(passkey.id) usage for the passkey_id
assignment. Reference the Rename button's onclick handler and the injected
variables passkey.id and name.

---

Outside diff comments:
In `@packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts`:
- Around line 246-248: Replace the locally defined passkeyTypes array with the
shared imported PASSKEY_TYPES constant to avoid drift: remove the const
passkeyTypes = ["passkey", "webauthn-roaming", "webauthn-platform"] and use
PASSKEY_TYPES in the excludeCredentials computation (the same constant already
used by generateFreshOptionsJSON) so both places reference the single source of
truth.

---

Nitpick comments:
In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`:
- Line 308: The check `if (action === "start_add_passkey" || action === "")` in
the passkey registration flow is relying on the NEXT_BUTTON (which doesn't set
an explicit action) to start registration when `action` is empty; add a concise
comment immediately above this condition explaining that an empty `action` is
intentional and originates from the NEXT_BUTTON click so readers understand why
`""` is included (reference the `action` variable and the NEXT_BUTTON control
used to start registration in this screen).
- Around line 346-359: The current read-modify-write using
loginSessions.get(...) then loginSessions.update(...) can drop concurrent
changes to state_data (race when multiple tabs); change to an
atomic/transactional update or use a server-side merge API so you only set
webauthn_challenge without clobbering other fields—e.g., replace the get+update
flow around loginSessions.get / loginSessions.update and state_data with a
single atomic update/merge that adds webauthn_challenge to the existing
state_data JSON (or implement optimistic concurrency via a version field) so
concurrent updates to state_data are preserved.

In `@packages/authhero/src/routes/universal-login/screens/passkey-utils.ts`:
- Around line 26-30: The getExpectedOrigin function currently uses
host.includes("localhost") which can mis-detect hosts like
"myapp.localhost.example.com"; change the check in getExpectedOrigin to perform
a precise localhost match against ctx.var.host (e.g., exact hostname or hostname
with port such as "localhost" or "localhost:3000") or use a regex that matches
^localhost(:\d+)?$ and also consider explicit loopback addresses like 127.0.0.1
and ::1 so the protocol decision (http vs https) is only triggered for true
loopback hosts.
🪄 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

Run ID: a518de45-0040-4cb9-951b-8a0f5b888c4d

📥 Commits

Reviewing files that changed from the base of the PR and between 844f072 and e8336c3.

📒 Files selected for processing (8)
  • .changeset/tricky-dodos-double.md
  • packages/authhero/src/routes/universal-login/screens/account-passkeys.ts
  • packages/authhero/src/routes/universal-login/screens/account-security.ts
  • packages/authhero/src/routes/universal-login/screens/account.ts
  • packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts
  • packages/authhero/src/routes/universal-login/screens/passkey-utils.ts
  • packages/authhero/src/routes/universal-login/screens/registry.ts
  • packages/authhero/src/routes/universal-login/u2-routes.tsx

Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (3)
packages/authhero/src/routes/universal-login/screens/account-passkeys.ts (3)

62-64: Silent catch may mask legitimate errors.

Swallowing all exceptions means network failures, authorization errors, or data corruption would silently show "no passkeys" rather than an error. Consider logging the error or distinguishing between "adapter not available" and other failures.

🔧 Suggested improvement
-  } catch {
-    // adapter may not exist
+  } catch (err) {
+    // Log but don't fail the page - graceful degradation
+    console.warn("Failed to list passkeys:", err);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`
around lines 62 - 64, The current bare catch in the passkeys loading block
swallows all exceptions; change it to catch the error (e.g., catch (err)) and
either log it via the app logger/console or set an error state, and only
suppress the exception when it's the specific "adapter not available" case;
update the catch in the account-passkeys loading logic (where "adapter" is
referenced) to detect adapter absence vs other failures, log err (or
rethrow/propagate a visible error) for non-adapter issues so network/auth/data
errors are not silently ignored.

346-359: Read-modify-write on state_data could lose concurrent challenges.

If a user has multiple passkey registration flows open in different tabs (e.g., account-passkeys and passkey-enrollment), the last write to webauthn_challenge wins and invalidates the other. This could cause intermittent "Challenge expired" errors.

Consider namespacing the challenge (e.g., webauthn_challenge_account vs webauthn_challenge_enrollment) to isolate concurrent flows.

🔧 Suggested approach
       await ctx.env.data.loginSessions.update(tenant.id, state, {
         state_data: JSON.stringify({
           ...stateData,
-          webauthn_challenge: options.challenge,
+          webauthn_challenge_account: options.challenge,
         }),
       });

And correspondingly in the verification step:

-      const expectedChallenge = stateData.webauthn_challenge as string;
+      const expectedChallenge = stateData.webauthn_challenge_account as string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`
around lines 346 - 359, The current read-modify-write of login session
state_data (using ctx.env.data.loginSessions.get/update and the state_data JSON)
stores a single webauthn_challenge key which can be clobbered by concurrent
flows; update the logic to namespace the challenge per flow (e.g., use keys like
webauthn_challenge_account and webauthn_challenge_enrollment) when writing via
loginSessions.update and when reading/validating in the corresponding
verification code paths so each flow reads/writes its own namespaced challenge
rather than the shared webauthn_challenge field.

154-164: Consider explicit action value for "Add Passkey" button.

The NEXT_BUTTON submits with an empty action, handled by action === "". For clarity and to avoid unintended triggers, consider using an explicit action value.

🔧 Suggested change

Add onclick to set action field, or use a submit button with explicit name/value:

   components.push({
     id: "add-passkey",
     type: "NEXT_BUTTON",
     category: "BLOCK",
     visible: true,
     config: {
       text: "Add Passkey",
+      onclick: "(function(btn){var f=btn.closest('form');if(!f){var w=document.querySelector('authhero-widget');if(w&&w.shadowRoot)f=w.shadowRoot.querySelector('form')}if(f){var a=f.querySelector('[name=\"action-field\"]');if(a)a.value='start_add_passkey'}})(this)",
     },
     order: 5,
   });

And simplify the handler:

-  if (action === "start_add_passkey" || action === "") {
+  if (action === "start_add_passkey") {

Also applies to: 308-308

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`
around lines 154 - 164, The Add Passkey NEXT_BUTTON currently submits with an
empty action which relies on checking action === ""; update the component with
an explicit action/value so form handlers don't rely on empty strings: modify
the component object with id "add-passkey" (and the other similar component at
the other location) to include an explicit action name (e.g., action:
"add-passkey" or add an onclick that sets the form action field) and then
simplify the submit handler to check for that explicit action value instead of
"" in the function handling the NEXT_BUTTON submission.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/authhero/src/routes/universal-login/screens/account-passkeys.ts`:
- Around line 62-64: The current bare catch in the passkeys loading block
swallows all exceptions; change it to catch the error (e.g., catch (err)) and
either log it via the app logger/console or set an error state, and only
suppress the exception when it's the specific "adapter not available" case;
update the catch in the account-passkeys loading logic (where "adapter" is
referenced) to detect adapter absence vs other failures, log err (or
rethrow/propagate a visible error) for non-adapter issues so network/auth/data
errors are not silently ignored.
- Around line 346-359: The current read-modify-write of login session state_data
(using ctx.env.data.loginSessions.get/update and the state_data JSON) stores a
single webauthn_challenge key which can be clobbered by concurrent flows; update
the logic to namespace the challenge per flow (e.g., use keys like
webauthn_challenge_account and webauthn_challenge_enrollment) when writing via
loginSessions.update and when reading/validating in the corresponding
verification code paths so each flow reads/writes its own namespaced challenge
rather than the shared webauthn_challenge field.
- Around line 154-164: The Add Passkey NEXT_BUTTON currently submits with an
empty action which relies on checking action === ""; update the component with
an explicit action/value so form handlers don't rely on empty strings: modify
the component object with id "add-passkey" (and the other similar component at
the other location) to include an explicit action name (e.g., action:
"add-passkey" or add an onclick that sets the form action field) and then
simplify the submit handler to check for that explicit action value instead of
"" in the function handling the NEXT_BUTTON submission.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 85835870-feda-4d0f-988c-2cb8bc35c088

📥 Commits

Reviewing files that changed from the base of the PR and between e8336c3 and 846b050.

📒 Files selected for processing (2)
  • packages/authhero/src/routes/universal-login/screens/account-passkeys.ts
  • packages/authhero/src/routes/universal-login/screens/passkey-enrollment.ts

@markusahlstrand markusahlstrand merged commit 6a560ca into main Apr 5, 2026
5 checks passed
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.

1 participant