# Magic links and email OTP

> Passwordless sign-in on SupaHost with signInWithOtp: every email carries both a magic link and a one-time code, so you can use either flow, on the web or in a mobile app.

Source: https://supahost.app/docs/auth/magic-links-otp · Updated 2026-11-11

Passwordless sign-in sends the user an email, and proving they can read it signs them in. Supabase Auth supports two forms that share one API call:

- **Magic link**: the user clicks a link in the email and lands in your app signed in.
- **Email OTP**: the user types a short one-time code from the email into your app.

On SupaHost you don't have to choose. Every Auth email includes the button link and, under it, the one-time code, so the same email serves both flows. Use the link on the web and the code where links are awkward, such as a mobile app or a desktop app that opened the email on another device.

## Before you start

- "Email sign-in" must be on in *Dashboard → your project → **Auth** tab → **Sign-in & security*** (it is by default).
- Set the **Site URL** and add the page users should land on to **Redirect URLs** on the **Auth** card. See [Site URL and redirect URLs](/docs/auth/redirect-urls).
- Optionally reword the **Magic link** template in the **Auth emails** card. See [Auth emails and templates](/docs/auth/email-templates).

## Send the email

```ts
const { error } = await supabase.auth.signInWithOtp({
  email: 'jo@example.com',
  options: {
    emailRedirectTo: 'https://bookings.example.com/auth/callback',
    shouldCreateUser: true,
  },
})
```

`shouldCreateUser` decides what happens for an address with no account:

- `true` (the default): Auth creates the user and the email doubles as their sign-up. If "Disable new sign-ups" is on, this fails for new addresses.
- `false`: only existing users get an email. Use this on a "sign in" screen when sign-up happens elsewhere.

## Option 1: the magic link

The link points at your project's `/auth/v1/verify` endpoint. Auth checks the token, then redirects to `emailRedirectTo` (or the Site URL if that address isn't allowed).

How the session reaches your app depends on the client's flow:

- **Browser-only apps** (the default implicit flow): the tokens arrive in the URL fragment and supabase-js picks them up when the page loads. Listen for the sign-in:

```ts
supabase.auth.onAuthStateChange((event, session) => {
  if (event === 'SIGNED_IN') showBookings(session!.user)
})
```

- **Server-rendered apps** (PKCE flow, the default with `@supabase/ssr`): the redirect carries `?code=…`, and your callback route swaps it for a session with `exchangeCodeForSession`. The code can only be exchanged in the same browser that requested the link, because the matching code verifier was stored there. See [Server-side auth](/docs/auth/server-side).

## Option 2: the one-time code

Ask the user for the code from the email and verify it:

```ts
const { data, error } = await supabase.auth.verifyOtp({
  email: 'jo@example.com',
  token: codeFromInput.trim(),
  type: 'email',
})

if (error?.code === 'otp_expired') {
  // the code is used or too old; offer to send a new email
}
```

On success, `data.session` is set and the user is signed in on this device, regardless of where they opened the email. This is the simplest passwordless flow for React Native, Flutter or Swift apps, because you don't need deep links at all.

A small sign-in form for a bookings app:

```ts
async function sendCode(email: string) {
  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: { shouldCreateUser: false },
  })
  if (error) throw error
  showCodeInput()
}

async function checkCode(email: string, code: string) {
  const { error } = await supabase.auth.verifyOtp({ email, token: code, type: 'email' })
  if (error) throw error
  goTo('/my-bookings')
}
```

## Links and codes are single use

A token works once. That protects users, but it causes a common support issue: some corporate email filters and link scanners open every link in an incoming email to check it, which uses up the token before the person clicks. The user then sees "Email link is invalid or has expired".

Ways around it:

- Offer the code as well as the link. Scanners don't type codes.
- Ask affected users to request a new email and click it straight away.

See [OTP or magic link "token has expired"](/kb/otp-expired) for more causes.

## Rate limits

Every magic link email counts toward the project's email limit, which SupaHost fixes at 30 (`GOTRUE_RATE_LIMIT_EMAIL_SENT=30`). Auth also refuses to send another email to the same address until a short wait has passed, and returns an error saying how long to wait. Disable the "Send" button for a minute after each click, and show the error message instead of retrying automatically. See [Auth rate limits](/docs/auth/rate-limits).

## Magic links with passwords

Passwordless and password sign-in aren't exclusive. A user created by a magic link can add a password later with `supabase.auth.updateUser({ password })`, and a password user can still sign in with a magic link. Both act on the same `auth.users` row.

## No SMS codes

Supabase Auth can also send codes by SMS or WhatsApp, but phone sign-in is turned off on SupaHost and can't be enabled. Use email codes, or an authenticator app for a second factor (see [MFA](/docs/auth/mfa)).

See the [signInWithOtp reference](https://supabase.com/docs/reference/javascript/auth-signinwithotp) and [verifyOtp reference](https://supabase.com/docs/reference/javascript/auth-verifyotp) for every option.
