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"}'Email OTP vs. Magic Link
Both use email and a one-time code/link. The difference:
| Email OTP (MFA) | Magic Link (Auth) | |
|---|---|---|
| Purpose | Second factor during sign-in | Primary authentication |
| Format | 6-digit code | Clickable link |
| Requires password | Yes (first factor) | No |