Passwordless Sign-In in Expo with Supabase Email OTP Codes
Six-digit codes instead of magic links: no deep links, iOS autofill, resend cooldown and the SMTP setting that breaks it for real users

Passwords are friction on a phone. Typing one on a small keyboard is slow, people forget them, and password reset is its own flow to build. Magic links remove the password, but on mobile they bring a new problem: the link opens in an email app or browser and has to find its way back into your app through deep links and redirect URLs.
A one-time code avoids both. The user types their email, gets a six-digit code, types it in, and they are signed in. No password, no redirect configuration, and it works the same in Expo Go, a development build and production.
This guide builds that flow in an Expo app with Supabase Auth.
Why choose an email code over a magic link on mobile?
| Magic link | Email OTP code | |
|---|---|---|
| User action | Tap a link in the email | Type a 6-digit code |
| Needs deep link / redirect setup | Yes | No |
| Works if email is opened on another device | Not easily | Yes, just read the code |
| Autofill support on iOS | No | Yes, with oneTimeCode |
In Supabase these are the same feature underneath. As the passwordless email docs explain, the difference is the email template: if it contains the {{ .Token }} variable, the user receives a code.
Step 1: Change the email template to send a code
In the Supabase dashboard, open Authentication → Email Templates → Magic Link and include the token:
<h2>Your sign-in code</h2>
<p>Enter this code in the app: <strong>{{ .Token }}</strong></p>
<p>It expires in 1 hour. If you didn't request it, you can ignore this email.</p>
According to Supabase's docs, the code is six digits and expires after one hour by default, and a user can request a new one once every 60 seconds. The expiry setting is shared with other email links such as password recovery, so change it with that in mind.
Step 2: Set up custom SMTP before you test with real users
This step is easy to miss. Supabase's SMTP guide says the built-in email service only delivers to addresses that are part of your project's team, is limited to a few messages per hour, and is best-effort only. Anyone outside your team gets "Email address not authorized".
So for anything beyond testing with your own team, configure a custom SMTP provider under Authentication → Emails → SMTP Settings.
Step 3: Send the code
import { supabase } from '@/lib/supabase';
export async function sendCode(email: string) {
const { error } = await supabase.auth.signInWithOtp({
email: email.trim().toLowerCase(),
options: { shouldCreateUser: true },
});
if (error) throw error;
}
shouldCreateUser decides what happens for a new email address. Set it to true if sign-in and sign-up are the same screen, or false if only existing users may sign in.
Step 4: Verify the code
export async function verifyCode(email: string, code: string) {
const { data, error } = await supabase.auth.verifyOtp({
email: email.trim().toLowerCase(),
token: code,
type: 'email',
});
if (error) throw error;
return data.session;
}
On success Supabase returns a session, and the client stores it like any other sign-in.
Step 5: The screen
A two-step screen keeps it simple: email first, then code.
import { useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
export default function SignIn() {
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [step, setStep] = useState<'email' | 'code'>('email');
const [error, setError] = useState<string | null>(null);
async function onSend() {
setError(null);
try {
await sendCode(email);
setStep('code');
} catch (e: any) {
setError(e.message);
}
}
async function onVerify() {
setError(null);
try {
await verifyCode(email, code);
} catch (e: any) {
setError('That code is wrong or has expired.');
}
}
return (
<View style={{ padding: 24, gap: 12 }}>
{step === 'email' ? (
<>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
/>
<Button title="Send code" onPress={onSend} />
</>
) : (
<>
<Text>Enter the 6-digit code sent to {email}</Text>
<TextInput
value={code}
onChangeText={(t) => setCode(t.replace(/\D/g, '').slice(0, 6))}
keyboardType="number-pad"
autoComplete="one-time-code"
textContentType="oneTimeCode"
maxLength={6}
/>
<Button title="Sign in" onPress={onVerify} disabled={code.length !== 6} />
<Button title="Use a different email" onPress={() => setStep('email')} />
</>
)}
{error && <Text style={{ color: 'red' }}>{error}</Text>}
</View>
);
}
Small details that make the flow feel finished:
textContentType="oneTimeCode"on iOS andautoComplete="one-time-code"let the keyboard suggest the code when it is available.- Strip non-digits so a pasted "123 456" still works.
- Disable the button until six digits are entered, so users don't trigger an error by tapping early.
Step 6: Handle resend and errors honestly
Because users can only request a new code every 60 seconds, show a countdown on a "Resend code" button instead of letting them tap it and get an error:
const [cooldown, setCooldown] = useState(0);
useEffect(() => {
if (cooldown === 0) return;
const t = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(t);
}, [cooldown]);
// after a successful send:
setCooldown(60);
Also keep error messages generic. "That code is wrong or has expired" is enough. Saying whether an email address has an account helps attackers find valid users.
Common problems
| Symptom | Likely cause |
|---|---|
| Email contains a link, not a code | Template doesn't include {{ .Token }} |
| "Email address not authorized" | Built-in email service used for a non-team address. Set up custom SMTP. |
| Code always rejected | Different email casing or spaces between send and verify. Normalize both. |
| "For security purposes, you can only request this after..." | The 60-second resend limit. Add a cooldown. |
| New users can't sign in | shouldCreateUser is false |
Where this fits
Auth is usually one of the first features an app needs and one of the least interesting to rebuild. If you are prototyping the screens around it, RapidNative generates a standard Expo project (Expo Router, TypeScript, NativeWind) from a plain-English description, which you can export and wire to Supabase exactly as above. Disclosure: I work on RapidNative.
Wrapping up
Email OTP gives you passwordless sign-in on mobile without deep link setup. Change the Magic Link template to include {{ .Token }}, configure custom SMTP, call signInWithOtp and verifyOtp, and add autofill, digit filtering and a resend cooldown so the screen feels finished.
Have you switched from magic links to codes in a mobile app, and did your users notice?
Comments
Join the discussionNo comments yet. Be the first to comment.
Shipping Expo Apps to Production
Practical, production-focused guides for React Native and Expo developers: OTA updates, EAS builds, push notifications, auth and payments. What to set up, what breaks, and how to ship with confidence.

