HelloJohn / docs
MFA

Email OTP

Use one-time passwords sent by email as an MFA factor in HelloJohn.

Email OTP

Email OTP sends a 6-digit code to the user's email address during authentication. It requires no app or phone number — just access to the user's inbox.

When to Use Email OTP

Email OTP is well suited for:

  • Users who don't have authenticator apps or can't receive SMS
  • Low-friction MFA for lower-risk applications
  • Fallback factor when primary MFA (TOTP/SMS) is unavailable

For higher-security requirements, prefer TOTP or WebAuthn.


Enabling Email OTP

Enable in Tenant Settings → MFA → Allowed Factors, or via the Admin API:

curl -X PATCH "https://api.hellojohn.dev/v1/admin/config" \
  -H "Authorization: Bearer sk_live_abc123" \
  -H "X-Tenant-ID: tnt_01HABCDEF654321" \
  -H "Content-Type: application/json" \
  -d '{"mfa_allowed_factors": ["totp", "email_otp", "backup_codes"]}'

Enrolling Email OTP

Email OTP uses the user's primary email — no additional setup is needed.

curl -X POST "https://api.hellojohn.dev/v1/users/usr_01HABCDEF123456/mfa/factors" \
  -H "Authorization: Bearer sk_live_abc123" \
  -H "X-Tenant-ID: tnt_01HABCDEF654321" \
  -H "Content-Type: application/json" \
  -d '{"type": "email_otp", "name": "Email OTP"}'

HelloJohn sends a verification code to the user's email immediately.

Response:

{
  "factor_id": "fac_01HABCDEF555446",
  "type": "email_otp",
  "status": "pending",
  "email": "a***@example.com"
}

Confirm the factor:

curl -X POST "https://api.hellojohn.dev/v1/users/usr_01HABCDEF123456/mfa/factors/fac_01HABCDEF555446/confirm" \
  -H "Authorization: Bearer sk_live_abc123" \
  -H "X-Tenant-ID: tnt_01HABCDEF654321" \
  -H "Content-Type: application/json" \
  -d '{"code": "456789"}'

Verifying Email OTP During Sign-In

// Step 1: Sign in
const result = await hj.auth.signIn({ email, password });

if (result.error?.code === "mfa_required") {
  const { challenge_id, available_factors } = result.error;
  const emailFactor = available_factors.find((f) => f.type === "email_otp");

  // Step 2: Trigger email code delivery
  await hj.mfa.challenge({
    challenge_id,
    factor_id: emailFactor.id,
  });

  // Step 3: Prompt user to enter the code from their inbox
  const code = await promptUserForCode();

  // Step 4: Verify
  const { access_token } = await hj.mfa.verify({
    challenge_id,
    factor_id: emailFactor.id,
    code,
  });
}

Via SDK (React)

import { useSignIn } from "@hellojohn/react";

function EmailOTPStep({ challengeId, factorId }: { challengeId: string; factorId: string }) {
  const { verifyMFA } = useSignIn();
  const [sent, setSent] = useState(false);

  const sendCode = async () => {
    await hj.mfa.challenge({ challenge_id: challengeId, factor_id: factorId });
    setSent(true);
  };

  const verify = async (code: string) => {
    await verifyMFA({ challengeId, factorId, code });
  };

  return (
    <div>
      {!sent ? (
        <button onClick={sendCode}>Send code to my email</button>
      ) : (
        <>
          <p>We sent a 6-digit code to your email. It expires in 10 minutes.</p>
          <form onSubmit={(e) => {
            e.preventDefault();
            verify(new FormData(e.currentTarget).get("code") as string);
          }}>
            <input name="code" maxLength={6} placeholder="000000" autoFocus />
            <button type="submit">Verify</button>
          </form>
          <button onClick={sendCode}>Resend code</button>
        </>
      )}
    </div>
  );
}

Code Expiry

Email OTP codes expire after 10 minutes by default. Users can request a new code by calling the challenge endpoint again.

The expiry is configurable per tenant:

curl -X PATCH "https://api.hellojohn.dev/v1/admin/config" \
  -H "Authorization: Bearer sk_live_abc123" \
  -H "X-Tenant-ID: tnt_01HABCDEF654321" \
  -H "Content-Type: application/json" \
  -d '{"email_otp_expiry": "5m"}'

Both use email and a one-time code/link. The difference:

Email OTP (MFA)Magic Link (Auth)
PurposeSecond factor during sign-inPrimary authentication
Format6-digit codeClickable link
Requires passwordYes (first factor)No

On this page