DEV Community

Cover image for Passwordless Sign-In in Expo: Email OTP Codes with Supabase (No Deep Links)
Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

Passwordless Sign-In in Expo: Email OTP Codes with Supabase (No Deep Links)

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>
Enter fullscreen mode Exit fullscreen mode

2. Send

await supabase.auth.signInWithOtp({
  email: email.trim().toLowerCase(),
  options: { shouldCreateUser: true }, // false = existing users only
});
Enter fullscreen mode Exit fullscreen mode

3. Verify

const { data, error } = await supabase.auth.verifyOtp({
  email: email.trim().toLowerCase(),
  token: code,
  type: 'email',
});
Enter fullscreen mode Exit fullscreen mode

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}
/>
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)