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.
Signature
auth.sendOtp(input: {
email: string;
createUser?: boolean;
userMeta?: Record<string, unknown>;
}): Promise<void>sendOtp errors
| Situation | code / status | What to show the user |
|---|---|---|
| Email doesn’t look like an email | invalid_email / 400 | ”Check your email” |
| Too many sends (rate limit) | over_email_send_rate_limit / 429 | ”Try again in a minute” |
| Network is down | network_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
type | When 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
| Situation | code / status |
|---|---|
| Wrong code, expired code, or email without an OTP sent | invalid_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
sendOtpandverifyOtp— 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
- Password reset — reuses
verifyOtpwithtype: 'recovery'. - Session management — what to do after a successful
verifyOtp.