TL;DR
- Magic links on mobile need deep links and redirect URLs. A 6-digit code needs neither.
- In Supabase it's the same feature: put
{{ .Token }}in the Magic Link email template and users get a code. -
signInWithOtp({ email })sends it,verifyOtp({ email, token, type: 'email' })signs in. - The built-in email service only sends to your project's team. Set up custom SMTP before real users.
- Codes are 6 digits, expire after 1 hour by default, and can be requested once per 60 seconds.
1. Template
Dashboard → Authentication → Email Templates → Magic Link:
<p>Your sign-in code: <strong>{{ .Token }}</strong></p>
2. Send
await supabase.auth.signInWithOtp({
email: email.trim().toLowerCase(),
options: { shouldCreateUser: true }, // false = existing users only
});
3. Verify
const { data, error } = await supabase.auth.verifyOtp({
email: email.trim().toLowerCase(),
token: code,
type: 'email',
});
Normalize the email the same way in both calls, or the code will "never work".
4. The input that makes it feel native
<TextInput
value={code}
onChangeText={(t) => setCode(t.replace(/\D/g, '').slice(0, 6))}
keyboardType="number-pad"
autoComplete="one-time-code"
textContentType="oneTimeCode"
maxLength={6}
/>
oneTimeCode lets iOS suggest the code above the keyboard. Stripping non-digits makes pasting "123 456" work.
If you're building the screens around this from scratch, RapidNative generates an Expo Router + TypeScript project from a prompt that you can export and wire up like this. (I work on it.)
5. Resend cooldown
Supabase allows one request per 60 seconds, so show a countdown instead of an error:
const [cooldown, setCooldown] = useState(0);
useEffect(() => {
if (!cooldown) return;
const t = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(t);
}, [cooldown]);
Debug table
| Symptom | Cause |
|---|---|
| Email has a link, not a code | No {{ .Token }} in the template |
| "Email address not authorized" | Built-in email service + non-team address. Use custom SMTP. |
| Code always rejected | Email casing/whitespace differs between calls |
| "You can only request this after..." | 60s resend limit |
Codes or magic links in your app? What made you pick one?
Top comments (0)