Implementation · SealGate

How to add passkeys to an existing login without dropping passwords

6 min read

Key points

  • You can add passkeys without replacing your login, as an enrollment step after sign-in and as a second way in on the login page.
  • Decide three things first, namely the RP ID, the conditions for accepting a new passkey, and how recovery works.
  • Keep passwords for now and set a cut-off only once enough users have a passkey.

To add passkeys to an existing login, keep your password flow and work in this order: (1) ask signed-in users to register a passkey, (2) add a passkey entry point to the login page, and (3) give users a device list and a recovery path. If you decide the RP ID (the domain passkeys are bound to) and the rules for accepting a new passkey up front, nothing needs to be rebuilt. Retire passwords only after enough users have a passkey.

The overall plan

Stage What you do What users see
0. Prepare Choose the RP ID and origins; add feature detection Nothing
1. Enroll Offer passkey registration after sign-in "Sign in with your fingerprint next time"
2. Sign in Support passkeys from a button and from autofill "Sign in with a passkey" on the login page
3. Manage List, rename, and remove passkeys A "Passkeys" section in account settings
4. Recover Let users who lost a device register again "Can't sign in?"
5. Coexist Set a date to close password sign-in The password field goes away

0. Prepare: choose the RP ID and origins

The RP ID is the domain passkeys are bound to: your login page's domain or a parent of it. If your login page is login.example.com and you want the same passkeys to work on www.example.com, use example.com.

Treat the RP ID as permanent. A passkey works only with the RP ID it was registered under, so changing it breaks every existing passkey.

Add feature detection at the same time. If window.PublicKeyCredential is missing, hide the passkey UI and show the password login as before. For autofill (conditional mediation), check PublicKeyCredential.isConditionalMediationAvailable().

1. Enroll users after they sign in

Offer the first passkey right after a successful sign-in, or from account settings. The passkeys.dev bootstrapping guide likewise says to verify that the user is sufficiently strongly authenticated with existing methods, including MFA where you have it, before prompting them to create a passkey.

Three points matter here:

  • Guard the enrollment step. If any password-only session can add a passkey at any time, someone who stole the password can add their own. Limit enrollment to just after sign-in, to sessions that completed your existing MFA, or require re-authentication for older sessions.
  • Keep personal data out of the user ID. The WebAuthn spec says the user handle (user.id) must not contain personally identifying information such as a username or email address, and recommends 64 random bytes.
  • Prevent duplicates on one authenticator. Pass the user's existing passkeys in excludeCredentials, and the authenticator will refuse to create a second one for the same user.

2. Add a passkey entry point to the login page

There are two ways in:

  • A "Sign in with a passkey" button. Call navigator.credentials.get() and let the user pick from passkeys saved on the device. With an empty allowCredentials, users don't need to type a username (this relies on discoverable credentials).
  • Autofill on the username field. Add autocomplete="username webauthn" to the field and call navigator.credentials.get() with mediation: 'conditional' when the page loads. Passkeys then appear among the field's suggestions, and users who pick a password continue as usual.

Either way, keep the password field. Some users won't have a passkey yet, and some will be on a device without one.

3. Let users manage their passkeys

Add a passkey list to account settings. At a minimum you need:

  • A list with a name ("iPhone", "Work laptop"), the date it was added, and the date it was last used
  • Rename, so users can tell them apart
  • Remove, to disable a passkey on a lost device

If you also know whether each passkey is synced (the WebAuthn BE / BS flags), you can nudge users whose only passkey lives on one device to add a second one.

4. Decide recovery before you launch

Plan how users who lost their device, or who don't use sync, prove who they are without a passkey and register a new one. The identity check (a registered email plus another factor, a support desk check, and so on) varies by service, but settle it before launch. We cover this in designing passkey account recovery.

5. Run both, then set a cut-off for passwords

Start with passwords and passkeys side by side. Once enough users have a passkey, set a date and move in steps: stop showing the password field to users who have a passkey, then stop accepting their passwords. Tell users ahead of time, and confirm your recovery flow works before closing the door.

Implementing with SealGate

With SealGate, the stages above map to the following API and SDK. The API key lives only on your server; the browser SDK calls URLs on your server (optionsUrl / verifyUrl).

Setup

A new project in the dashboard starts with a Test environment (rp_id localhost). Once your login domain is settled, add the Live environment and set its rp_id and origins. Test keys start with sk_test_ and Live keys with sk_live_.

In the browser

js
import { SealGate } from 'https://sealgate.jp/sdk/v1.js';

const sg = new SealGate({
  optionsUrl: '/api/passkey/options',
  verifyUrl: '/api/passkey/verify',
});

if (SealGate.supported()) {
  // on a page where the user is signed in
  await sg.register({ name: 'This device' });
}

// the login page button
const result = await sg.login();

The same code is on npm as sealgate-browser, with types. Failures throw SealGateError with a code such as cancelled (the user closed the dialog or it timed out), already_registered (this authenticator already has a passkey for the user), or unsupported.

On your server

The SDK POSTs { kind: 'register' | 'login', ... }. Your server looks at kind, relays to SealGate's /v1/register/* or /v1/login/*, and creates its own session once login verification succeeds.

js
const BASE = 'https://api.sealgate.jp/v1';

export async function POST(req, { params }) {
  const { step } = await params;            // 'options' | 'verify'
  const body = await req.json();
  const user = await currentUser(req);      // your own login state

  const payload = step === 'options'
    ? body.kind === 'register'
      ? { external_id: user.id, display_name: user.email, signed_in: true }
      : { external_id: body.external_id }
    : { challenge_id: body.challenge_id, response: body.response };

  const res = await fetch(`${BASE}/${body.kind}/${step}`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.SEALGATE_API_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify(payload),
  });
  const json = await res.json();
  if (step === 'verify' && body.kind === 'login' && res.ok) {
    await createSession(json.external_id);
  }
  return Response.json(json, { status: res.status });
}

Things to keep in mind:

  • Use an opaque ID (an internal number or a random ID) as external_id, not an email address, because SealGate stores external_id and display_name.
  • Adding a passkey for a user who already has one requires signed_in: true or a recovery_token; otherwise you get 403 recovery_not_allowed. Set signed_in from your server session, never from a value the browser sends. Your enrollment rules (recent sign-in, re-authentication) also live in this server code.
  • Registration and login challenges expire after 5 minutes and can be used once (challenge_expired).

Management, recovery, and coexistence

  • GET /v1/users/{external_id}/credentials returns each passkey's name, aaguid, backup_eligible, backed_up, and last-used time. Rename with PATCH; revoke one with DELETE /v1/credentials/{credential_id}.
  • For recovery, verify the user's identity on your side, then call POST /v1/recovery/start and pass the recovery_token (valid for 15 minutes) to /v1/register/options. SealGate does not verify identity.
  • Set a "Password login end date" in the environment settings in the dashboard. After that date, login responses for users who registered a passkey return password_allowed: false, which you can use to decide whether to hide the password field.

See the docs for the full API and the pricing table for pricing.

FAQ

Do I need to migrate my password database?

No. A passkey is an additional credential tied to the user. Your password hashes stay in your existing system.

Should I require a passkey at sign-up?

For existing users, offering it after sign-in is the easiest start. If you ask at sign-up, keep another option for users on devices that can't create a passkey.

Do I need my production domain in development?

WebAuthn only allows registration when the RP ID is the page's domain or a parent of it. Use an environment with localhost as the RP ID for local work and keep it separate from production.

Sources

Related posts

Back to the blog