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 emptyallowCredentials, 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 callnavigator.credentials.get()withmediation: '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
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.
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 storesexternal_idanddisplay_name. - Adding a passkey for a user who already has one requires
signed_in: trueor arecovery_token; otherwise you get 403recovery_not_allowed. Setsigned_infrom 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}/credentialsreturns each passkey's name,aaguid,backup_eligible,backed_up, and last-used time. Rename withPATCH; revoke one withDELETE /v1/credentials/{credential_id}.- For recovery, verify the user's identity on your side, then call
POST /v1/recovery/startand pass therecovery_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.