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 { useForm } from '@tanstack/react-form';

import { Field, FieldLabel } from '@kit/ui/field';
import { Input } from '@kit/ui/input';

import { sendMagicLinkFunction } from '../_lib/functions';

export function MagicLinkForm() {
  const [sent, setSent] = useState(false);

  const form = useForm({
    defaultValues: { email: '' },
    onSubmit: async ({ value }) => {
      const result = await sendMagicLinkFunction({ data: value });

      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={(e) => {
        e.preventDefault();
        void form.handleSubmit();
      }}
    >
      <form.Field name="email">
        {(field) => (
          <Field>
            <FieldLabel>Email address</FieldLabel>
            <Input
              type="email"
              placeholder="you@example.com"
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
          </Field>
        )}
      </form.Field>

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

Server Function

import { createServerFn } from '@tanstack/react-start';
import * as z from 'zod';

import { errorMiddleware } from '@kit/function-middleware/server';

export const sendMagicLinkFunction = createServerFn({ method: 'POST' })
  .middleware([errorMiddleware])
  .validator(z.object({ email: z.email() }))
  .handler(async ({ data }) => {
    const origin = process.env.VITE_SITE_URL!;

    await sendMagicLink({
      email: data.email,
      redirectTo: `${origin}/auth/callback`,
      createUser: true,
    });

    return {
      success: true,
      message: 'Check your email for the magic link',
    };
  });

Configuration

Configure magic links in your auth configuration:

// config/auth.config.ts
export const authConfig = {
  providers: {
    emailLink: true,
  },
};

Configure Email Template

Customize the magic link email template:

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

Callback Handler

Handle the magic link callback:

// src/routes/auth/callback.ts
import { createFileRoute } from '@tanstack/react-router';

import { verifyMagicLink } from '@kit/auth/server';

export const Route = createFileRoute('/auth/callback')({
  server: {
    handlers: {
      GET: async ({ request }) => {
        const requestUrl = new URL(request.url);
        const token = requestUrl.searchParams.get('token');

        if (token) {
          try {
            await verifyMagicLink(token);
            return Response.redirect(new URL('/home', request.url));
          } catch (error) {
            // Redirect back with an error if verification failed
            return Response.redirect(
              new URL('/auth/sign-in?error=invalid_link', request.url),
            );
          }
        }

        return Response.redirect(
          new URL('/auth/sign-in?error=invalid_link', request.url),
        );
      },
    },
  },
});

Advanced Features

Custom Redirect

Specify where users go after clicking the link:

await sendMagicLink({
  email: data.email,
  redirectTo: `${origin}/onboarding`,
});

Disable Auto Sign-Up

Require users to sign up first:

await sendMagicLink({
  email: data.email,
  createUser: false, // Don't create new users
});

Token Expiry

Configure link expiration (default: 1 hour) in your auth configuration:

// config/auth.config.ts
export const authConfig = {
  magicLink: {
    expiresIn: '15 minutes',
  },
};

Rate Limiting

Prevent abuse by rate limiting magic link requests:

import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';

import { errorMiddleware } from '@kit/function-middleware/server';
import { ratelimit } from '~/lib/rate-limit';

export const sendMagicLinkFunction = createServerFn({ method: 'POST' })
  .middleware([errorMiddleware])
  .validator(EmailSchema)
  .handler(async ({ data }) => {
    // Rate limit by IP
    const ip = getRequestHeaders().get('x-forwarded-for') || 'unknown';
    const { success } = await ratelimit.limit(ip);

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

    await sendMagicLink({
      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:

// Auth service handles this automatically
// Each link can only be used once

Email Verification

Ensure emails are verified:

import { getSession } from '@kit/auth/server';

const session = await getSession();

if (!session?.user.emailVerified) {
  throw redirect({ href: '/verify-email' });
}

User Experience

Loading State

Show feedback while sending:

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

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

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

Allow users to request a new link:

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 sendMagicLinkFunction({ email });
    setCountdown(60);
    setCanResend(false);
  };

  return (
    <button onClick={handleResend} disabled={!canResend}>
      {canResend ? '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