Email Passwordless Example

Complete example of implementing passwordless authentication with email magic links.

Passwordless Authentication with Email Magic Links

This example demonstrates a complete passwordless authentication system using email magic links. Users receive a one-time link in their email to sign in without passwords.

How It Works

  1. User enters their email address
  2. System generates a secure, time-limited token
  3. Magic link is sent to the user's email
  4. User clicks the link in their email
  5. System validates the token and creates a session
  6. User is signed in and redirected to the application

Environment Setup

Configure your email provider credentials:

# Authentication
AUTH_SECRET=your-secret-key-min-32-chars-long

# SMTP Configuration (Gmail example)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com

# Or use Resend
RESEND_API_KEY=re_your_resend_api_key

# Application URLs
APP_URL=http://localhost:3000

Gmail App Password

For Gmail, you need to enable 2-factor authentication and generate an App Password. Go to Google Account → Security → 2-Step Verification → App passwords.

Next.js Implementation

Email Provider Configuration

Configure the email provider with Nodemailer (SMTP):

lib/email-provider.ts

Email provider configuration

import { email } from '@warpy-auth-sdk/core';

export const emailProvider = email({
  server: `${process.env.SMTP_HOST}:${process.env.SMTP_PORT}`,
  from: process.env.SMTP_FROM!,
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASS!,
  },
  // Optional: Custom token expiration (default: 15m)
  tokenExpiration: '15m',
});

// Or use Resend
import { email } from '@warpy-auth-sdk/core';

export const emailProviderResend = email({
  provider: 'resend',
  apiKey: process.env.RESEND_API_KEY!,
  from: process.env.SMTP_FROM!,
});

API Route for Magic Link

Create an API endpoint to send magic links:

app/api/auth/signin/email/route.ts

Magic link API endpoint

import { authenticate } from '@warpy-auth-sdk/core';
import { emailProvider } from '@/lib/email-provider';
import { NextRequest } from 'next/server';

export const runtime = 'nodejs'; // Required for Nodemailer

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { email: userEmail } = body;

    if (!userEmail || !userEmail.includes('@')) {
      return Response.json(
        { error: 'Valid email is required' },
        { status: 400 }
      );
    }

    // Send magic link
    const result = await authenticate(
      {
        secret: process.env.AUTH_SECRET!,
        provider: emailProvider,
        callbacks: {
          async user(user) {
            // Optional: Check if user exists in your database
            // const dbUser = await db.user.findUnique({
            //   where: { email: user.email },
            // });
            // if (!dbUser) {
            //   // Create new user
            //   await db.user.create({
            //     data: { email: user.email, name: user.name },
            //   });
            // }

            return {
              id: user.email, // Use email as ID or your DB user ID
              email: user.email,
              name: user.name || user.email.split('@')[0],
            };
          },
        },
      },
      request,
      { email: userEmail }
    );

    if (result.error) {
      return Response.json(
        { error: result.error },
        { status: 400 }
      );
    }

    return Response.json({
      success: true,
      message: 'Check your email for the magic link!',
    });
  } catch (error) {
    console.error('Magic link error:', error);
    return Response.json(
      { error: 'Failed to send magic link. Please try again.' },
      { status: 500 }
    );
  }
}

Login Page

Create a user-friendly login page for magic link authentication:

app/login/page.tsx

Magic link login page

'use client';

import { useAuth } from "@warpy-auth-sdk/core/hooks";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

export default function LoginPage() {
  const { session, loading } = useAuth();
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [message, setMessage] = useState<{
    type: 'success' | 'error';
    text: string;
  } | null>(null);

  useEffect(() => {
    if (session) {
      router.push("/dashboard");
    }
  }, [session, router]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setMessage(null);

    try {
      const response = await fetch('/api/auth/signin/email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });

      const data = await response.json();

      if (response.ok) {
        setMessage({
          type: 'success',
          text: 'Check your email for the magic link! It will expire in 15 minutes.',
        });
        setEmail(''); // Clear email field
      } else {
        setMessage({
          type: 'error',
          text: data.error || 'Failed to send magic link. Please try again.',
        });
      }
    } catch (error) {
      setMessage({
        type: 'error',
        text: 'An error occurred. Please try again.',
      });
    } finally {
      setIsSubmitting(false);
    }
  };

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
      </div>
    );
  }

  if (session) {
    return null; // Will redirect to dashboard
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full">
        <div className="bg-white rounded-2xl shadow-xl p-8">
          <div className="text-center mb-8">
            <h2 className="text-3xl font-bold text-gray-900">
              Welcome Back
            </h2>
            <p className="mt-2 text-sm text-gray-600">
              Enter your email to receive a magic link
            </p>
          </div>

          <form onSubmit={handleSubmit} className="space-y-6">
            <div>
              <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
                Email address
              </label>
              <input
                id="email"
                name="email"
                type="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                disabled={isSubmitting}
                className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                placeholder="you@example.com"
              />
            </div>

            {message && (
              <div
                className={`rounded-lg p-4 ${
                  message.type === 'success'
                    ? 'bg-green-50 border border-green-200'
                    : 'bg-red-50 border border-red-200'
                }`}
              >
                <div className="flex">
                  <div className="flex-shrink-0">
                    {message.type === 'success' ? (
                      <svg className="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
                        <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
                      </svg>
                    ) : (
                      <svg className="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
                        <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
                      </svg>
                    )}
                  </div>
                  <div className="ml-3">
                    <p className={`text-sm ${
                      message.type === 'success' ? 'text-green-800' : 'text-red-800'
                    }`}>
                      {message.text}
                    </p>
                  </div>
                </div>
              </div>
            )}

            <button
              type="submit"
              disabled={isSubmitting}
              className="w-full flex justify-center items-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isSubmitting ? (
                <>
                  <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                  </svg>
                  Sending magic link...
                </>
              ) : (
                'Send Magic Link'
              )}
            </button>
          </form>

          <div className="mt-6">
            <div className="relative">
              <div className="absolute inset-0 flex items-center">
                <div className="w-full border-t border-gray-300" />
              </div>
              <div className="relative flex justify-center text-sm">
                <span className="px-2 bg-white text-gray-500">
                  No password required
                </span>
              </div>
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-gray-600">
          By signing in, you agree to our Terms of Service and Privacy Policy.
        </p>
      </div>
    </div>
  );
}

Express Implementation

Implement magic links with Express:

Express Server with Magic Links

Complete Express server implementation

import express from "express";
import cookieParser from "cookie-parser";
import { authenticate, email, getSession } from "@warpy-auth-sdk/core";

const app = express();

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());

// Email provider configuration
const emailProvider = email({
  server: `${process.env.SMTP_HOST}:${process.env.SMTP_PORT}`,
  from: process.env.SMTP_FROM!,
  auth: {
    user: process.env.SMTP_USER!,
    pass: process.env.SMTP_PASS!,
  },
});

// Magic link sign-in endpoint
app.post("/api/auth/signin/email", async (req, res) => {
  try {
    const { email: userEmail } = req.body;

    if (!userEmail) {
      return res.status(400).json({ error: "Email is required" });
    }

    const result = await authenticate(
      {
        secret: process.env.AUTH_SECRET!,
        provider: emailProvider,
        callbacks: {
          async user(user) {
            return {
              id: user.email,
              email: user.email,
              name: user.name || user.email.split('@')[0],
            };
          },
        },
      },
      req,
      { email: userEmail }
    );

    if (result.error) {
      return res.status(400).json({ error: result.error });
    }

    res.json({
      success: true,
      message: "Magic link sent successfully",
    });
  } catch (error) {
    console.error("Magic link error:", error);
    res.status(500).json({ error: "Failed to send magic link" });
  }
});

// Session endpoint
app.get("/api/auth/session", async (req, res) => {
  const session = await getSession(req, process.env.AUTH_SECRET!);
  res.json({ session });
});

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

Custom Email Template

Customize the magic link email template using React Email:

Custom Email Template

Branded magic link email template

import {
  Html,
  Head,
  Body,
  Container,
  Section,
  Text,
  Button,
  Heading,
} from '@react-email/components';

interface MagicLinkEmailProps {
  magicLink: string;
  userEmail: string;
}

export const MagicLinkEmail = ({ magicLink, userEmail }: MagicLinkEmailProps) => {
  return (
    <Html>
      <Head />
      <Body style={main}>
        <Container style={container}>
          <Section style={box}>
            <Heading style={h1}>Welcome Back!</Heading>
            <Text style={text}>
              You requested a magic link to sign in to your account.
              Click the button below to securely sign in:
            </Text>
            <Button style={button} href={magicLink}>
              Sign In to Your Account
            </Button>
            <Text style={text}>
              Or copy and paste this URL into your browser:
            </Text>
            <Text style={link}>{magicLink}</Text>
            <Text style={footer}>
              This link will expire in 15 minutes for security reasons.
              If you didn't request this email, you can safely ignore it.
            </Text>
            <Text style={footer}>
              Sent to: {userEmail}
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
};

const main = {
  backgroundColor: '#f6f9fc',
  fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};

const container = {
  backgroundColor: '#ffffff',
  margin: '0 auto',
  padding: '20px 0 48px',
  marginBottom: '64px',
};

const box = {
  padding: '0 48px',
};

const h1 = {
  color: '#333',
  fontSize: '24px',
  fontWeight: 'bold',
  margin: '40px 0',
  padding: '0',
};

const text = {
  color: '#333',
  fontSize: '16px',
  lineHeight: '24px',
  margin: '16px 0',
};

const button = {
  backgroundColor: '#3b82f6',
  borderRadius: '8px',
  color: '#fff',
  fontSize: '16px',
  fontWeight: 'bold',
  textDecoration: 'none',
  textAlign: 'center' as const,
  display: 'block',
  padding: '12px 24px',
  margin: '24px 0',
};

const link = {
  color: '#3b82f6',
  fontSize: '14px',
  textDecoration: 'underline',
  wordBreak: 'break-all' as const,
};

const footer = {
  color: '#8898aa',
  fontSize: '12px',
  lineHeight: '16px',
  margin: '16px 0',
};

Testing Magic Links

Follow these steps to test your magic link implementation:

1. Start Development Server

npm run dev

2. Test Email Sending

curl -X POST http://localhost:3000/api/auth/signin/email \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com"}'

3. Check Your Email

Open your email inbox and look for the magic link email. Check spam folder if needed.

4. Click Magic Link

Click the link in your email. You should be:

  1. Redirected to your application
  2. Automatically signed in
  3. Session cookie set in your browser

5. Verify Session

curl http://localhost:3000/api/auth/session \
  -H "Cookie: auth-token=YOUR_SESSION_COOKIE"

Magic Link Flow Diagram

sequenceDiagram
    participant User
    participant App
    participant SDK
    participant Email

    User->>App: Enter email address
    App->>SDK: POST /api/auth/signin/email
    SDK->>SDK: Generate secure token
    SDK->>Email: Send magic link email
    Email->>User: Deliver email
    User->>User: Click magic link in email
    User->>SDK: GET /api/auth/callback?token=...
    SDK->>SDK: Validate token
    SDK->>SDK: Create session & JWT
    SDK->>App: Set session cookie
    SDK->>User: Redirect to dashboard
    App->>User: Show authenticated content

Security Features

  • Time-Limited Tokens: Links expire after 15 minutes (configurable)
  • Single-Use Tokens: Each token can only be used once
  • Cryptographically Secure: Tokens generated with crypto.randomBytes
  • Email Verification: Only email owner can access the link
  • Rate Limiting: Prevent email spam and abuse

Security Best Practices

  • Always use HTTPS in production
  • Set short token expiration times
  • Implement rate limiting on magic link requests
  • Log and monitor authentication attempts
  • Consider adding CAPTCHA for public applications

Production Deployment

Recommendations for production:

1. Use Professional Email Service

Switch from Gmail to a dedicated email service:

  • Resend: Modern email API with React Email support
  • SendGrid: Reliable email delivery with analytics
  • Mailgun: Developer-friendly email service
  • AWS SES: Cost-effective for high volume

2. Set Up Email Authentication

Configure SPF, DKIM, and DMARC records for your domain to improve deliverability and prevent emails from going to spam.

3. Monitor Email Delivery

  • Track email delivery success rates
  • Monitor bounce rates and complaints
  • Set up alerts for delivery failures
  • Keep an eye on spam folder placement

4. Implement Rate Limiting

Rate Limiting Example

Prevent magic link abuse with rate limiting

import rateLimit from 'express-rate-limit';

const magicLinkLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 3, // Max 3 requests per window
  message: 'Too many magic link requests. Please try again later.',
  standardHeaders: true,
  legacyHeaders: false,
});

app.post("/api/auth/signin/email", magicLinkLimiter, async (req, res) => {
  // Magic link logic...
});

Magic Links Complete

You've successfully implemented passwordless authentication with email magic links. Users can now sign in securely without passwords using only their email.

Next Steps

Enhance your authentication system:

Troubleshooting

Emails Not Sending

Check SMTP credentials and ensure your email provider allows SMTP access. For Gmail, enable 2FA and create an App Password.

Magic Links Expiring Too Quickly

Increase the token expiration time in your email provider configuration:

const emailProvider = email({
  // ... other config
  tokenExpiration: '30m', // Increase to 30 minutes
});

Links Going to Spam

Set up SPF, DKIM, and DMARC records for your domain. Use a professional email service with good sender reputation.

Email Passwordless Example | @warpy-auth-sdk/core