Enlaces mágicos

Autenticación sin contraseña con enlaces mágicos por correo electrónico.

Nota: Este es contenido de ejemplo/marcador de posición con fines de demostración.

Los enlaces mágicos ofrecen autenticación sin contraseña mediante el envío de un enlace de un solo uso al correo electrónico del usuario.

Cómo funciona

  1. El usuario introduce su dirección de correo electrónico
  2. El sistema envía un correo electrónico con un enlace único
  3. El usuario hace clic en el enlace de su correo electrónico
  4. El usuario inicia sesión automáticamente

Ventajas

  • Sin contraseñas que recordar - Mejor experiencia de usuario
  • Más seguro - No hay contraseña que robar
  • Menos fricción - Proceso de registro más rápido
  • Verificación del correo electrónico - Confirma la titularidad del correo electrónico

Implementación

Formulario de enlace mágico

'use client';

import { useForm } from 'react-hook-form';
import { sendMagicLinkAction } from '../_lib/actions';

export function MagicLinkForm() {
  const { register, handleSubmit, formState: { isSubmitting } } = useForm();
  const [sent, setSent] = useState(false);

  const onSubmit = async (data) => {
    const result = await sendMagicLinkAction(data);

    if (result.success) {
      setSent(true);
    }
  };

  if (sent) {
    return (
      <div className="text-center">
        <h2>Check your email</h2>
        <p>We've sent you a magic link to sign in.</p>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Email address</label>
        <input
          type="email"
          {...register('email', { required: true })}
          placeholder="you@example.com"
        />
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send magic link'}
      </button>
    </form>
  );
}

Server Action

'use server';

import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { z } from 'zod';

export const sendMagicLinkAction = enhanceAction(
  async (data) => {
    const client = getSupabaseServerClient();
    const origin = process.env.NEXT_PUBLIC_SITE_URL!;

    const { error } = await client.auth.signInWithOtp({
      email: data.email,
      options: {
        emailRedirectTo: `${origin}/auth/callback`,
        shouldCreateUser: true,
      },
    });

    if (error) throw error;

    return {
      success: true,
      message: 'Check your email for the magic link',
    };
  },
  {
    schema: z.object({
      email: z.string().email(),
    }),
  }
);

Configuración

Activar en Supabase

  1. Ve a Authentication → Providers → Email
  2. Activa "Enable Email Provider"
  3. Activa "Enable Email Confirmations"

Configurar la plantilla de correo electrónico

Personaliza el correo electrónico del enlace mágico en el panel de Supabase:

  1. Ve a Authentication → Email Templates
  2. Selecciona "Magic Link"
  3. Personaliza la plantilla:
<h2>Sign in to {{ .SiteURL }}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{{ .ConfirmationURL }}">Sign in</a></p>
<p>This link expires in {{ .TokenExpiryHours }} hours.</p>

Controlador de callback

Gestiona el callback del enlace mágico:

// app/auth/callback/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const requestUrl = new URL(request.url);
  const token_hash = requestUrl.searchParams.get('token_hash');
  const type = requestUrl.searchParams.get('type');

  if (token_hash && type === 'magiclink') {
    const cookieStore = cookies();
    const supabase = createRouteHandlerClient({ cookies: () => cookieStore });

    const { error } = await supabase.auth.verifyOtp({
      token_hash,
      type: 'magiclink',
    });

    if (!error) {
      return NextResponse.redirect(new URL('/home', request.url));
    }
  }

  // Devuelve un error si la verificación ha fallado
  return NextResponse.redirect(
    new URL('/auth/sign-in?error=invalid_link', request.url)
  );
}

Funcionalidades avanzadas

Redirección personalizada

Especifica adónde van los usuarios después de hacer clic en el enlace:

await client.auth.signInWithOtp({
  email: data.email,
  options: {
    emailRedirectTo: `${origin}/onboarding`,
  },
});

Desactivar el registro automático

Exige que los usuarios se registren primero:

await client.auth.signInWithOtp({
  email: data.email,
  options: {
    shouldCreateUser: false, // No crear usuarios nuevos
  },
});

Caducidad del token

Configura la caducidad del enlace (valor predeterminado: 1 hora):

-- En el SQL Editor de Supabase
ALTER TABLE auth.users
SET default_token_lifetime = '15 minutes';

Limitación de solicitudes

Evita los abusos limitando la frecuencia de las solicitudes de enlaces mágicos:

import { ratelimit } from '~/lib/rate-limit';

export const sendMagicLinkAction = enhanceAction(
  async (data, user, request) => {
    // Limitar por IP
    const ip = request.headers.get('x-forwarded-for') || 'unknown';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      throw new Error('Too many requests. Please try again later.');
    }

    const client = getSupabaseServerClient();

    await client.auth.signInWithOtp({
      email: data.email,
    });

    return { success: true };
  },
  { schema: EmailSchema }
);

Consideraciones de seguridad

Caducidad de los enlaces

Los enlaces mágicos deben caducar rápidamente:

  • Valor predeterminado: 1 hora
  • Recomendado: 15-30 minutos en producción
  • Más corto para acciones sensibles

Un solo uso

Los enlaces deben invalidarse después de usarse:

// Supabase se encarga de esto automáticamente
// Cada enlace solo se puede usar una vez

Verificación del correo electrónico

Asegúrate de que los correos electrónicos estén verificados:

const { data: { user } } = await client.auth.getUser();

if (!user.email_confirmed_at) {
  redirect('/verify-email');
}

Experiencia de usuario

Estado de carga

Muestra información mientras se realiza el envío:

export function MagicLinkForm() {
  const [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle');

  const onSubmit = async (data) => {
    setStatus('sending');
    await sendMagicLinkAction(data);
    setStatus('sent');
  };

  return (
    <>
      {status === 'idle' && <EmailForm onSubmit={onSubmit} />}
      {status === 'sending' && <SendingMessage />}
      {status === 'sent' && <CheckEmailMessage />}
    </>
  );
}

Reenviar el enlace

Permite que los usuarios soliciten un nuevo enlace:

export function ResendMagicLink({ email }: { email: string }) {
  const [canResend, setCanResend] = useState(false);
  const [countdown, setCountdown] = useState(60);

  useEffect(() => {
    if (countdown > 0) {
      const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
      return () => clearTimeout(timer);
    } else {
      setCanResend(true);
    }
  }, [countdown]);

  const handleResend = async () => {
    await sendMagicLinkAction({ email });
    setCountdown(60);
    setCanResend(false);
  };

  return (
    <button onClick={handleResend} disabled={!canResend}>
      {canResend ? 'Resend link' : `Resend in ${countdown}s`}
    </button>
  );
}

Entregabilidad del correo electrónico

SPF, DKIM, DMARC

Configura la autenticación del correo electrónico:

  1. Añade un registro SPF al DNS
  2. Activa la firma DKIM
  3. Configura una política DMARC

Dominio de correo electrónico personalizado

Usa tu propio dominio para mejorar la entregabilidad:

  1. Ve a Project Settings → Auth
  2. Configura un SMTP personalizado
  3. Verifica la titularidad del dominio

Supervisar los rebotes

Haz un seguimiento de los problemas de entrega de correo electrónico:

// Gestionar los rebotes de correo electrónico
export async function handleEmailBounce(email: string) {
  await client.from('email_bounces').insert({
    email,
    bounced_at: new Date(),
  });

  // Notificar al usuario por otro canal
}

Pruebas

Desarrollo local

En desarrollo, los correos electrónicos se envían a InBucket:

http://localhost:54324

Consulta esta URL para ver los correos electrónicos con enlaces mágicos durante las pruebas.

Modo de prueba

Crea un enlace de prueba sin enviar ningún correo electrónico:

if (process.env.NODE_ENV === 'development') {
  console.log('Magic link URL:', confirmationUrl);
}

Buenas prácticas

  1. Comunicación clara - Indica a los usuarios que revisen la carpeta de spam
  2. Caducidad corta - 15-30 minutos por seguridad
  3. Limitación de solicitudes - Evita los abusos
  4. Opción alternativa - Ofrece la autenticación con contraseña como respaldo
  5. Dominio personalizado - Mejor entregabilidad
  6. Supervisa la entrega - Haz un seguimiento de los rebotes y los fallos
  7. Opción de reenvío - Permite que los usuarios soliciten un nuevo enlace
  8. Compatible con dispositivos móviles - Asegúrate de que los enlaces funcionen en móviles