Skip to content

Introducing mailbox challenges

New feature Passlock now supports mailbox challenges, a server-side feature for building email one-time code flows without rebuilding the awkward security and lifecycle pieces yourself.

This is aimed at the cases teams keep needing even when passkeys are the long-term goal:

  • verifying email ownership during signup
  • passwordless login when passkeys are not available
  • confirming a new email address before updating an account

A mailbox challenge is an email verification flow with a little more structure than “generate a six-digit code and hope for the best”.

When you create a challenge, Passlock returns:

  • challengeId to identify the challenge
  • secret for your app to keep server-side
  • code for you to deliver by email
  • message.html and message.text if you want ready-made email content

The user receives the code, enters it into your app, and your backend verifies the attempt using all three parts: challengeId, secret, and code.

That detail matters. The emailed code alone is not enough.

Email one-time codes look simple until you try to ship them properly.

You need challenge expiry, retry handling, invalidation of older outstanding codes, rate limiting, consistent verification, and a way to reduce the risk of treating an intercepted code as sufficient proof on its own.

Mailbox challenges package those moving parts into a small API so you can focus on your product flow instead of rebuilding verification plumbing.

The flow is straightforward:

  1. Your backend creates a mailbox challenge for a purpose like signup, login, or email-change.
  2. Your app stores challengeId and secret in a server-side session or HTTP-only cookie.
  3. Passlock sends the email, or your mailer sends the code using Passlock’s rendered message or your own template.
  4. The user submits the code.
  5. Your backend verifies the challenge and checks the returned purpose, email, and any expected local user context before completing the action.

If you need to restore an in-progress flow, you can also read a challenge without exposing the secret or code.

If you are already using the Passlock server library, the basic shape looks like this:

import {
createMailboxChallenge,
verifyMailboxChallenge,
} from "@passlock/server/unsafe";
const created = await createMailboxChallenge({
email: "jdoe@example.com",
name: "Jane Doe",
purpose: "login",
invalidateOthers: true,
}, { tenancyId: "myTenancyId", apiKey: "myApiKey" });
await savePendingLoginSession({
challengeId: created.challenge.challengeId,
secret: created.challenge.secret,
});
const pending = await readPendingLoginSession();
const verified = await verifyMailboxChallenge({
challengeId: pending.challengeId,
secret: pending.secret,
code: form.code,
}, { tenancyId: "myTenancyId", apiKey: "myApiKey" });
if (verified.challenge.purpose !== "login") {
throw new Error("Unexpected challenge purpose");
}
await completeLoginForEmail(verified.challenge.email);

The same feature is also available over raw HTTP if you are not using the JS/TS server library.

Passlock will send the rendered email by default, but you can instead send via your own infrastructure.

When you pass sendEmail: false, Passlock creates the challenge and returns the same raw code and rendered message.html / message.text content without sending anything. You can send that rendered content through your own infrastructure, or generate your own template from the raw code.

Mailbox challenges are not a replacement for passkeys. They cover a different part of the authentication surface.

Passkeys remain the stronger default for ongoing authentication. Mailbox challenges are useful when you need to prove mailbox ownership, offer a passwordless fallback, or verify account changes that are naturally email-centric.

In practice, many applications will use both:

  • passkeys for primary authentication
  • mailbox challenges for onboarding, recovery, or account-change verification