Skip to main content

Email OTP login

Passwordless sign-in via 6-digit codes mailed to the user. Good for consumer-app flows where you don't want password management at all, or as a recovery factor for password-managed accounts.

Flow

POST /v1/auth/email-otp/request { email }
→ 204 No Content (always)
→ if email is registered: send "Your sign-in code is 123456"

POST /v1/auth/email-otp/verify { email, code }
→ 200 { status: "success", access_token, ... } // or
→ 200 { status: "mfa_required", mfa_token, ... } // if MFA enrolled

Properties

  • Code format: 6 digits, zero-padded ("042857").
  • TTL: 10 minutes.
  • Max attempts per challenge: 5. After 5 misses, the challenge is locked even if the right code arrives later.
  • Storage: SHA-256 hash; plaintext exists only in the outbound email.
  • Single-use: marked consumed on success.

Enumeration defense

/request ALWAYS returns 204 — for registered emails, unknown emails, disabled accounts, and even malformed input. Account existence can't be inferred from the response.

/verify returns the same authentication_required error for all failure modes (unknown email, expired code, wrong code, attempts exhausted).

When the user has MFA enrolled

The verify endpoint returns status: "mfa_required" with an mfa_token, not a session. The caller must POST to /v1/auth/mfa/challenge to complete the flow.

What about password-reset?

Password reset uses a separate flow — see /v1/auth/password/forgot. The mechanics overlap but the schema and TTLs differ.

Limitations

  • Email-only. No SMS-OTP variant.
  • No "magic link" alternative; the user always types the 6 digits.
  • Resending a code wipes any prior outstanding code for the same user.
  • Codes are NOT a step-up factor. Use /v1/auth/mfa/verify for step-up.

Combining with passwords

A common pattern is to offer email OTP as ONE of the sign-in options:

<LoginForm>
<option>Email + password</option>
<option>Email me a code</option>
<option>Sign in with passkey</option>
</LoginForm>

Each option calls a different SDK endpoint; the end state (a session cookie) is identical.