Account recovery
If passkeys are your primary sign-in method, you still need a recovery path for users who lose access to them. Common examples include a new device that was never synced, a lost security key, or a wiped password manager.
Mailbox challenges give you an email-based recovery step: prove control of the mailbox on the account, then issue a short-lived recovery session that lets the user re-enrol passkeys or complete other recovery-only actions.
Start the recovery challenge
Section titled “Start the recovery challenge”import { createMailboxChallenge } from "@passlock/server";
const user = await findUserByEmail("jdoe@example.com");if (!user) { return showUnknownAccount();}
const result = await createMailboxChallenge({ tenancyId: "myTenancyId", apiKey: "myApiKey", email: user.email, purpose: "account-recovery", userId: String(user.id), invalidateOthers: true,});
// message contains rendered HTML and plain-text email content.// The raw code is also available if you prefer to render your own email.const { challengeId, secret, message, email } = result.challenge;
await savePendingRecoveryChallenge({ challengeId, secret, userId: user.id, email,});
await sendCodeEmail({ email, message });import { createMailboxChallenge, isChallengeRateLimitedError,} from "@passlock/server/safe";
const user = await findUserByEmail("jdoe@example.com");if (!user) { return showUnknownAccount();}
const result = await createMailboxChallenge({ tenancyId: "myTenancyId", apiKey: "myApiKey", email: user.email, purpose: "account-recovery", userId: String(user.id), invalidateOthers: true,});
if (result.success) { // message contains rendered HTML and plain-text email content. // The raw code is also available if you prefer to render your own email. const { challengeId, secret, message, email } = result.value.challenge;
await savePendingRecoveryChallenge({ challengeId, secret, userId: user.id, email, });
await sendCodeEmail({ email, message });} else if (isChallengeRateLimitedError(result.error)) { return showRateLimit(result.error.retryAfterSeconds);} else { throw new Error(result.error.message);}Verify the code and start recovery
Section titled “Verify the code and start recovery”import { verifyMailboxChallenge } from "@passlock/server";
const pending = await loadPendingRecoveryChallenge();
const verified = await verifyMailboxChallenge({ tenancyId: "myTenancyId", apiKey: "myApiKey", challengeId: pending.challengeId, secret: pending.secret, code: form.code,});
if (verified.challenge.purpose !== "account-recovery") { throw new Error("Unexpected challenge purpose");}
if (verified.challenge.userId !== String(pending.userId)) { throw new Error("Unexpected user");}
if (verified.challenge.email !== pending.email) { throw new Error("Email mismatch");}
await clearPendingRecoveryChallenge();await createRecoverySession({ userId: pending.userId, expiresInMinutes: 10,});
return redirectTo("/settings/passkeys/recover");import { verifyMailboxChallenge } from "@passlock/server/safe";
const pending = await loadPendingRecoveryChallenge();
const result = await verifyMailboxChallenge({ tenancyId: "myTenancyId", apiKey: "myApiKey", challengeId: pending.challengeId, secret: pending.secret, code: form.code,});
if (result.success) { const { challenge } = result.value;
if (challenge.purpose !== "account-recovery") { throw new Error("Unexpected challenge purpose"); }
if (challenge.userId !== String(pending.userId)) { throw new Error("Unexpected user"); }
if (challenge.email !== pending.email) { throw new Error("Email mismatch"); }
await clearPendingRecoveryChallenge(); await createRecoverySession({ userId: pending.userId, expiresInMinutes: 10, });
return redirectTo("/settings/passkeys/recover");} else { return showVerificationError(result.error);}After recovery succeeds, prompt the user to register a new passkey as soon as possible and clear any temporary recovery state. The same mailbox challenge behavior is also available over raw HTTP in the REST API mailbox challenge reference.