Skip to Content

Email OTP

Passwordless sign-in: the user enters an email, receives a 6-digit code, types it in — AuthSession is ready. The same flow covers signin and signup: if the user doesn’t exist yet they’re created automatically.

await auth.sendOtp({ email: 'user@example.com' }); // ... show the code input UI const session = await auth.verifyOtp({ email: 'user@example.com', token: '123456' });

When to pick OTP over password. In Chrome extensions, password managers often misbehave (autofill breaks because of isolation). OTP minimises friction: one email and the user is in. Downside — every sign-in requires opening the inbox; on the web, where password managers work well, a classic password may be more convenient.

sendOtp

Sends a code to the email. The backend always returns ok (anti-enumeration: you can’t tell from the response whether the user exists). A real error (“invalid email”) surfaces only on the next step — verifyOtp.

auth.sendOtp(input: { email: string; createUser?: boolean; userMeta?: Record<string, unknown>; }): Promise<void>

sendOtp errors

Situationcode / statusWhat to show the user
Email doesn’t look like an emailinvalid_email / 400”Check your email”
Too many sends (rate limit)over_email_send_rate_limit / 429”Try again in a minute”
Network is downnetwork_error”Send again” button

verifyOtp

Verifies the code, sets the session on success and emits onAuthChange. Event-wise: SIGNED_IN for type='email'|'signup'|'magiclink'|'invite'; PASSWORD_RECOVERY for type='recovery' — so listeners can tell “user signed in” from “user opened the reset link, show the new-password form”.

auth.verifyOtp(input: { email: string; token: string; type?: 'email' | 'recovery' | 'signup' | 'magiclink' | 'invite'; userMeta?: Record<string, string>; }): Promise<AuthSession>

The type parameter

typeWhen to use
'email' (default)Standard OTP flow: signin/signup via sendOtp.
'recovery'After requestPasswordReset — yields a recovery session so you can call updatePassword.
'signup'After signUp with email confirmation enabled on the platform. Use when you want to flag “registration confirmation” separately.
'magiclink'If you send a magic link instead of an OTP code — extract the token from the link URL.
'invite'Accepting an invite — rarely used, kept for compatibility.

verifyOtp errors

Situationcode / status
Wrong code, expired code, or email without an OTP sentinvalid_otp / 401
Nonexistent type (typo)invalid_type / 400

OTP TTL. 1 hour by default (configured platform-side). If your UI mentions the code lifetime (“code valid for X minutes”), use a generic phrasing rather than hard-coding the value.

Full flow

Step 1: request a code

await auth.sendOtp({ email }); showCodeInputScreen({ email });

Step 2: show the code input

The UI should:

  • store the email from step 1 (the user shouldn’t have to re-enter it),
  • offer a “Resend” button with a 30-60 second delay (anti-spam),
  • auto-focus the input.

Step 3: verify the code

try { const session = await auth.verifyOtp({ email, token: code }); // ✅ user signed in onLoggedIn(session); } catch (e) { if (e instanceof PaywallError && e.code === 'invalid_otp') { showError('Wrong or expired code'); return; } throw e; }

Step 4: business as usual

AuthClient now holds the session and emits onAuthChange; BillingClient picks up the token.

Resend timer: example

function OtpScreen({ email, onLoggedIn }) { const [code, setCode] = useState(''); const [resendIn, setResendIn] = useState(45); useEffect(() => { if (resendIn === 0) return; const t = setTimeout(() => setResendIn((s) => s - 1), 1000); return () => clearTimeout(t); }, [resendIn]); const submit = async () => { try { const session = await auth.verifyOtp({ email, token: code }); onLoggedIn(session); } catch (e) { // show error } }; const resend = async () => { await auth.sendOtp({ email }); setResendIn(45); }; return ( <div> <input value={code} onChange={(e) => setCode(e.target.value)} /> <button onClick={submit}>Verify</button> <button onClick={resend} disabled={resendIn > 0}> {resendIn > 0 ? `Resend in ${resendIn}s` : 'Resend'} </button> </div> ); }

Anti-enumeration: things to watch for

The backend always returns ok for sendOtp — even when the user doesn’t exist. This is by design: you can’t tell from the response whether the email is registered. Consequence for UI:

  • Don’t show “no such user” between sendOtp and verifyOtp — you don’t have that info until the code is entered.
  • Error messages should be neutral. “Wrong code” works both for “no code because user doesn’t exist” and for “code expired / mistyped”.

Next steps