Magic Links

Passwordless authentication with email magic links.

Note: This is mock/placeholder content for demonstration purposes.

Magic links provide passwordless authentication by sending a one-time link to the user's email.

How It Works

  1. User enters their email address
  2. System sends an email with a unique link
  3. User clicks the link in their email
  4. User is automatically signed in

Benefits

  • No password to remember - Better UX
  • More secure - No password to steal
  • Lower friction - Faster sign-up process
  • Email verification - Confirms email ownership

Implementation

'use client';

import { useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { toast } from '@kit/ui/sonner';
import { sendMagicLinkAction } from '../_lib/actions';

export function MagicLinkForm() {
  const { register, handleSubmit } = useForm();
  const [sent, setSent] = useState(false);
  const { execute, isPending } = useAction(sendMagicLinkAction, {
    onSuccess: () => {
      setSent(true);
    },
    onError: () => {
      toast.error('Could not send the magic link');
    },
  });

  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((data) => execute(data))}>
      <div>
        <label>Email address</label>
        <input
          type="email"
          {...register('email', { required: true })}
          placeholder="you@example.com"
        />
      </div>

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

Server Action

'use server';

import { publicActionClient } from '@kit/next/safe-action';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import * as z from 'zod';

const EmailSchema = z.object({
  email: z.string().email(),
});

export const sendMagicLinkAction = publicActionClient
  .inputSchema(EmailSchema)
  .action(async ({ parsedInput: 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',
    };
  });

Configuration

Enable in Supabase

  1. Go to AuthenticationProvidersEmail
  2. Enable "Enable Email Provider"
  3. Enable "Enable Email Confirmations"

Configure Email Template

Customize the magic link email in Supabase Dashboard:

  1. Go to AuthenticationEmail Templates
  2. Select "Magic Link"
  3. Customize the template:
<h2>Sign in to {{ .SiteURL }}</h2>
<p>Click the link below to sign in:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=magiclink&amp;callback={{ .RedirectTo }}">Sign in</a></p>
<p>This link expires in {{ .TokenExpiryHours }} hours.</p>

Callback Handler

Handle the magic link callback:

// app/auth/confirm/route.ts
import { NextRequest, NextResponse } from 'next/server';

import { createAuthCallbackService } from '@kit/supabase/auth';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

import pathsConfig from '~/config/paths.config';

export async function GET(request: NextRequest) {
  const service = createAuthCallbackService(getSupabaseServerClient());

  const url = await service.verifyTokenHash(request, {
    joinTeamPath: pathsConfig.app.joinTeam,
    redirectPath: pathsConfig.app.home,
  });

  return NextResponse.redirect(url);
}

Advanced Features

Custom Redirect

Specify where users go after clicking the link:

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

Disable Auto Sign-Up

Require users to sign up first:

await client.auth.signInWithOtp({
  email: data.email,
  options: {
    shouldCreateUser: false, // Don't create new users
  },
});

Token Expiry

Configure link expiration (default: 1 hour):

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

Rate Limiting

Prevent abuse by rate limiting magic link requests:

import { ratelimit } from '~/lib/rate-limit';
import { publicActionClient } from '@kit/next/safe-action';
import { headers } from 'next/headers';

export const sendMagicLinkAction = publicActionClient
  .inputSchema(EmailSchema)
  .action(async ({ parsedInput: data }) => {
    // Rate limit by IP
    const headersStore = await headers();
    const ip = headersStore.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 };
  });

Security Considerations

Magic links should expire quickly:

  • Default: 1 hour
  • Recommended: 15-30 minutes for production
  • Shorter for sensitive actions

One-Time Use

Links should be invalidated after use:

// Supabase handles this automatically
// Each link can only be used once

Email Verification

Ensure emails are verified:

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

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

User Experience

Loading State

Show feedback while sending:

export function MagicLinkForm() {
  const { execute, isPending, hasSucceeded } = useAction(sendMagicLinkAction);

  return (
    <>
      {!isPending && !hasSucceeded && <EmailForm onSubmit={execute} />}
      {isPending && <SendingMessage />}
      {hasSucceeded && <CheckEmailMessage />}
    </>
  );
}

Allow users to request a new link:

'use client';

import { useEffect, useState } from 'react';
import { useAction } from 'next-safe-action/hooks';
import { toast } from '@kit/ui/sonner';
import { sendMagicLinkAction } from '../_lib/actions';

export function ResendMagicLink({ email }: { email: string }) {
  const [canResend, setCanResend] = useState(false);
  const [countdown, setCountdown] = useState(60);
  const { execute, isPending } = useAction(sendMagicLinkAction, {
    onSuccess: () => {
      setCountdown(60);
      setCanResend(false);
    },
    onError: () => {
      toast.error('Could not resend the magic link');
    },
  });

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

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

Email Deliverability

SPF, DKIM, DMARC

Configure email authentication:

  1. Add SPF record to DNS
  2. Enable DKIM signing
  3. Set up DMARC policy

Custom Email Domain

Use your own domain for better deliverability:

  1. Go to Project SettingsAuth
  2. Configure custom SMTP
  3. Verify domain ownership

Monitor Bounces

Track email delivery issues:

// Handle email bounces
export async function handleEmailBounce(email: string) {
  await client.from('email_bounces').insert({
    email,
    bounced_at: new Date(),
  });

  // Notify user via other channel
}

Testing

Local Development

In development, emails go to InBucket:

http://localhost:54324

Check this URL to see magic link emails during testing.

Test Mode

Create a test link without sending email:

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

Best Practices

  1. Clear communication - Tell users to check spam
  2. Short expiry - 15-30 minutes for security
  3. Rate limiting - Prevent abuse
  4. Fallback option - Offer password auth as backup
  5. Custom domain - Better deliverability
  6. Monitor delivery - Track bounces and failures
  7. Resend option - Let users request new link
  8. Mobile-friendly - Ensure links work on mobile