Magic Links y OTP en Supabase

Un magic link es un enlace único que se envia al email del usuario y lo autentica al hacer clic, sin necesitar contraseña. Un OTP (One-Time Password -- contraseña de un solo uso) es un código numérico que se envia por email o SMS y que el usuario ingresa manualmente.

Ambos métodos eliminan la fricción de las contraseñas. El usuario no tiene que recordar nada -- solo necesita acceso a su email o teléfono.

CriterioMagic links / OTPEmail + password
Friccion de registroBaja (sin contraseña)Media (crear y recordar contraseña)
SeguridadAlta (token de un solo uso)Depende de la contraseña del usuario
DependenciaRequiere acceso al email/teléfonoSolo necesita recordar credenciales
UX en login frecuenteLento (esperar email cada vez)Rápido (escribir contraseña)
Apps internas / adminExcelente opciónTambién válido
Apps con login diarioNo ideal como método únicoMejor experiencia

La recomendación: ofrece magic links como opción adicional, no como único método. La combinación email+password con magic link como alternativa cubre la mayoría de casos.

typescript
import { createClient } from '@/lib/supabase/client'
 
const supabase = createClient()
 
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'usuario@ejemplo.com',
  options: {
    emailRedirectTo: `${window.location.origin}/auth/callback`
  }
})
 
if (error) {
  console.error('Error:', error.message)
} else {
  console.log('Magic link enviado. Revisa tu email.')
}

Cuando el usuario hace clic en el link del email, Supabase lo redirige a la URL que especificaste en emailRedirectTo con un código de autorización. Tu ruta de callback (la misma que usamos para OAuth) procesa ese código y crea la sesión.

Mismo usuario, nueva cuenta

Si el email no está registrado, signInWithOtp crea automáticamente una cuenta nueva. Si ya existe, simplemente envia el link de login. No necesitas manejar registro y login por separado.

Desactivar registro automático

Si no quieres que signInWithOtp cree cuentas nuevas automáticamente:

typescript
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'usuario@ejemplo.com',
  options: {
    emailRedirectTo: `${window.location.origin}/auth/callback`,
    shouldCreateUser: false  // No crea cuenta si no existe
  }
})

Con shouldCreateUser: false, si el email no está registrado, Supabase devuelve un error en vez de crear una cuenta nueva.

OTP por email (código numérico)

En vez de un link, puedes enviar un código de 6 dígitos que el usuario ingresa manualmente. Para esto, necesitas configurar el email template en el Dashboard de Supabase.

Configurar OTP por email

En el Dashboard: Authentication > Email Templates > Magic Link

Cambia el template para incluir el token como código en vez de link. Usa la variable {{ .Token }}:

html
<p>Tu código de verificación es: <strong>{{ .Token }}</strong></p>
<p>Este codigo expira en 1 hora.</p>

Enviar y verificar OTP por email

typescript
// Paso 1: Enviar el codigo
const { data, error } = await supabase.auth.signInWithOtp({
  email: 'usuario@ejemplo.com'
})
 
if (error) {
  console.error('Error enviando codigo:', error.message)
}
 
// Paso 2: El usuario ingresa el codigo, y tu lo verificas
const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({
  email: 'usuario@ejemplo.com',
  token: '123456',  // El codigo que ingreso el usuario
  type: 'email'
})
 
if (verifyError) {
  console.error('Código inválido:', verifyError.message)
} else {
  console.log('Autenticado:', sessionData.user)
}

OTP por teléfono (SMS)

Supabase también soporta enviar códigos OTP por SMS. Para esto necesitas configurar un proveedor de SMS.

Configurar proveedor de SMS

En el Dashboard: Authentication > Providers > Phone

Supabase soporta estos proveedores de SMS:

  • Twilio (el más común)
  • MessageBird
  • Vonage
  • TextLocal

Necesitas una cuenta en uno de estos servicios. Twilio es el más popular y tiene un tier gratuito para pruebas.

Enviar y verificar OTP por SMS

typescript
// Paso 1: Enviar codigo por SMS
const { data, error } = await supabase.auth.signInWithOtp({
  phone: '+521234567890'  // Formato E.164 con codigo de pais
})
 
if (error) {
  console.error('Error enviando SMS:', error.message)
}
 
// Paso 2: Verificar el codigo
const { data: sessionData, error: verifyError } = await supabase.auth.verifyOtp({
  phone: '+521234567890',
  token: '123456',
  type: 'sms'
})
 
if (verifyError) {
  console.error('Código inválido:', verifyError.message)
} else {
  console.log('Autenticado:', sessionData.user)
}
Formato de teléfono

El número de teléfono debe estar en formato E.164: código de pais + número, sin espacios ni guiones. Para Mexico: +52 seguido del número a 10 dígitos. Para Colombia: +57, Argentina: +54.

Parametros de verifyOtp

El método verifyOtp acepta diferentes type según el flujo:

typeUso
emailVerificar OTP enviado por email
smsVerificar OTP enviado por SMS
magiclinkVerificar token de magic link (raro, normalmente se maneja automáticamente)
signupVerificar email después del registro
recoveryVerificar token de recuperación de contraseña
email_changeVerificar cambio de email
typescript
'use client'
 
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
 
type Step = 'email' | 'code' | 'success'
 
export function MagicLinkForm() {
  const [step, setStep] = useState<Step>('email')
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')
  const supabase = createClient()
 
  const handleSendCode = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setMessage('')
 
    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        emailRedirectTo: `${window.location.origin}/auth/callback`
      }
    })
 
    if (error) {
      setMessage('No se pudo enviar el codigo. Intenta de nuevo.')
    } else {
      setStep('code')
      setMessage('Revisa tu email. Te enviamos un código de verificación.')
    }
 
    setLoading(false)
  }
 
  const handleVerifyCode = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setMessage('')
 
    const { error } = await supabase.auth.verifyOtp({
      email,
      token: code,
      type: 'email'
    })
 
    if (error) {
      setMessage('Código inválido o expirado. Intenta de nuevo.')
    } else {
      setStep('success')
      window.location.href = '/dashboard'
    }
 
    setLoading(false)
  }
 
  if (step === 'email') {
    return (
      <form onSubmit={handleSendCode} className="max-w-md mx-auto space-y-4">
        <h2 className="text-xl font-bold">Iniciar sesión sin contrasena</h2>
        <p className="text-gray-400 text-sm">
          Te enviaremos un código de verificación a tu email.
        </p>
 
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          placeholder="tu@email.com"
          className="w-full px-3 py-2 border border-gray-600 rounded-md bg-transparent"
        />
 
        <button
          type="submit"
          disabled={loading}
          className="w-full py-2 px-4 bg-green-600 text-white rounded-md
                     hover:bg-green-700 disabled:opacity-50"
        >
          {loading ? 'Enviando...' : 'Enviar codigo'}
        </button>
 
        {message && <p className="text-sm text-center">{message}</p>}
      </form>
    )
  }
 
  if (step === 'code') {
    return (
      <form onSubmit={handleVerifyCode} className="max-w-md mx-auto space-y-4">
        <h2 className="text-xl font-bold">Ingresa el codigo</h2>
        <p className="text-gray-400 text-sm">
          Enviamos un codigo de 6 dígitos a {email}
        </p>
 
        <input
          type="text"
          value={code}
          onChange={(e) => setCode(e.target.value)}
          required
          maxLength={6}
          placeholder="123456"
          className="w-full px-3 py-2 border border-gray-600 rounded-md
                     bg-transparent text-center text-2xl tracking-widest"
        />
 
        <button
          type="submit"
          disabled={loading}
          className="w-full py-2 px-4 bg-green-600 text-white rounded-md
                     hover:bg-green-700 disabled:opacity-50"
        >
          {loading ? 'Verificando...' : 'Verificar codigo'}
        </button>
 
        <button
          type="button"
          onClick={() => { setStep('email'); setMessage('') }}
          className="w-full text-sm text-gray-400 hover:text-white"
        >
          Usar otro email
        </button>
 
        {message && <p className="text-sm text-center">{message}</p>}
      </form>
    )
  }
 
  return <p>Redirigiendo...</p>
}

Tiempo de expiración

En Authentication > Providers > Email:

  • OTP Expiry: Tiempo en segundos antes de que el código expire (default: 3600 = 1 hora)

Para magic links, un tiempo más corto (15-30 minutos) es más seguro. Para OTP por email, 10-15 minutos es razonable.

Rate limiting

Supabase limita el envío de emails por defecto:

  • Maximo 3 emails por hora por dirección de email
  • Maximo 30 emails por hora por proyecto (en el free tier)

Si necesitas límites más altos, puedes configurar un proveedor de email externo (como Resend, SendGrid o Postmark) en Project Settings > Auth > SMTP Settings.

SMTP personalizado

Configurar tu propio SMTP no solo aumenta los límites, también permite que los emails salgan desde tu dominio (ejemplo: auth@tudominio.com) en vez de desde el dominio de Supabase. Esto mejora la entregabilidad y la confianza del usuario.

  • Magic link (link en el email): El usuario hace clic y queda autenticado. Más comodo, pero depende de que el email se abra en el mismo dispositivo/navegador dónde está tu app.
  • OTP (código numérico): El usuario copia el código manualmente. Funciona aunque el email se abra en otro dispositivo (ejemplo: abrir el email en el teléfono y escribir el código en la laptop).

Para la mejor experiencia, envias el magic link Y el código en el mismo email. Así el usuario elige como autenticarse.