A complete flow for connecting a passkey to an existing authenticated user session.
User signs up with Google, wants to add a passkey:
- ❌ Standard registration tries to create session (fails)
- ❌ Can't verify it's the same user
- ❌ UX is broken
Dedicated connection endpoints that require existing session.
app/api/webauthn/connect/
├── options/route.ts (Generate registration options)
└── verify/route.ts (Verify & store passkey)
PASSKEY_CONNECTION_FLOW.md (Comprehensive guide)
PASSKEY_CONNECTION_STATUS.md (Implementation notes)
PASSKEY_CONNECTION_QUICKSTART.md (Quick reference)
Authenticated User (Google logged in)
↓
Click "Link Passkey"
↓
POST /api/webauthn/connect/options
├─ Check session exists ✓
└─ Generate challenge ✓
↓
User confirms on device (biometric)
↓
POST /api/webauthn/connect/verify
├─ Check session ✓
├─ Verify WebAuthn ✓
├─ Store passkey ✓
└─ NO new session ← Key point
↓
Success! Passkey linked.
User stays authenticated with existing session.
✅ Identity Verified: Session proves who user is ✅ No Session Conflict: Doesn't try to create new session ✅ Same Security: Uses all same WebAuthn checks as registration ✅ Rate Limited: Same limits as authentication ✅ No Account Hijacking: Session binding prevents attacks ✅ Seamless UX: User stays logged in throughout
Generate WebAuthn registration options for existing user.
Required:
- Authorization header with session token
emailin body
Returns: WebAuthn options + challenge token
Verify device ownership and store passkey.
Required:
- Authorization header with session token
- WebAuthn attestation response
- Challenge token
Returns: { success: true }
// User already logged in with Google
const sessionToken = getSessionToken();
const email = getCurrentUserEmail();
// 1. Get options
const options = await fetch('/api/webauthn/connect/options', {
method: 'POST',
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
}).then(r => r.json());
// 2. Create credential (user touches device)
const attestation = await navigator.credentials.create({
publicKey: options
});
// 3. Verify
const result = await fetch('/api/webauthn/connect/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
assertion: attestation,
challenge: options.challenge,
challengeToken: options.challengeToken
})
}).then(r => r.json());
// Done! Passkey connected, user stays logged inSame WebAuthn verification as registration, PLUS:
- Session requirement (identity proof)
- User binding via session (can't hijack)
- Rate limiting (same as auth)
- Challenge time-binding (120s expiry)
Result: Connection is MORE secure than registration.
| Feature | Registration | Connection | Auth |
|---|---|---|---|
| Endpoint | /register/ |
/connect/ |
/auth/ |
| Session Required | ❌ | ✅ | ❌ |
| Creates Session | ✅ | ❌ | ✅ |
| Purpose | New account | Link passkey | Sign in |
| Use When | Signup | Add to existing | Login |
- User signs up with Google OAuth
- User goes to account settings
- User clicks "Add Passkey"
- User confirms on device
- Passkey stored
- User can now sign in with passkey
- User can have multiple auth methods
- Google + Passkey = two ways to sign in
- Account recovery if one method fails
- Transition away from passwords
- Keep existing auth (Google, email, etc.)
- Add passkey as modern alternative
✅ Implementation: Complete ✅ Documentation: Complete ✅ Build: Passes ✅ TypeScript: Clean ✅ Security: Verified ✅ Backwards Compatible: 100%
- Read:
PASSKEY_CONNECTION_QUICKSTART.md(5 min) - Read:
PASSKEY_CONNECTION_FLOW.md(15 min) - Integrate: Add UI button for "Link Passkey"
- Test: Try with different devices
- Deploy: Roll out to users
- Uses existing
issueChallenge() - Uses existing
verifyChallengeToken() - Uses existing
registerPasskey() - Uses existing rate limiting
- No duplicated logic
- Session validation
- User verification
- Challenge binding
- WebAuthn verification
- Rate limiting
- Cryptographic validation
- ✅ Uses existing @simplewebauthn
- ✅ Uses existing crypto
- ✅ Uses existing Appwrite integration
Unauthenticated → New User → Passkey + Session
Authenticated → Existing User → Passkey + No Session ← Key difference!
The key difference: No session creation because user already has one.
app/api/webauthn/connect/options/route.ts(108 lines)app/api/webauthn/connect/verify/route.ts(243 lines)
PASSKEY_CONNECTION_FLOW.md- Full guidePASSKEY_CONNECTION_STATUS.md- Implementation notesPASSKEY_CONNECTION_QUICKSTART.md- Quick reference- This file - Overview
Status: ✅ Ready for integration and testing
For quick overview, read: PASSKEY_CONNECTION_QUICKSTART.md For full details, read: PASSKEY_CONNECTION_FLOW.md