# Passwordless Sign-In in Expo with Supabase Email OTP Codes

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](https://supabase.com/docs/guides/auth/auth-email-passwordless) 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:

```html
<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](https://supabase.com/docs/guides/auth/auth-smtp) 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

```tsx
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

```tsx
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.

```tsx
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 and **`autoComplete="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:

```tsx
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](https://rapidnative.com/?utm_source=hashnode&utm_medium=blog&utm_campaign=expo-email-otp-supabase) 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?

