How to Implement Magic Link Authentication

Share
How-To Guide

How to Implement Magic Link Authentication

Passwordless login that's both secure and user-friendly

TL;DR

TL;DR (25 minutes): Generate 32+ byte random tokens, store hashed with 15-minute expiration, send via email with clear context, verify and invalidate in a single atomic operation, rate limit to 3-5 requests per email per hour. Magic links trade password security for email security - only use when that tradeoff makes sense.

Prerequisites:

  • Email sending capability (Resend, SendGrid, etc.)
  • Database for token storage
  • HTTPS-enabled domain

Why This Matters

Magic links eliminate password-related vulnerabilities: weak passwords, password reuse, and credential stuffing. But they shift security to email - if someone has access to the user's email, they can log in. Implement magic links carefully with proper rate limiting and token security.

Step-by-Step Guide

1

Create the database schema

// Prisma schema
model MagicLink {
  id        String   @id @default(cuid())
  tokenHash String   @unique  // Store hashed, not raw
  email     String
  expiresAt DateTime
  usedAt    DateTime?
  createdAt DateTime @default(now())

  @@index([email])
  @@index([expiresAt])
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  emailVerified DateTime?
  // ... other fields
}
2

Generate secure tokens

import crypto from 'crypto';

// Generate cryptographically secure token
function generateMagicLinkToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

// Hash token for storage
function hashToken(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

// Create magic link
async function createMagicLink(email: string) {
  const token = generateMagicLinkToken();
  const tokenHash = hashToken(token);

  // Delete any existing unused tokens for this email
  await prisma.magicLink.deleteMany({
    where: {
      email,
      usedAt: null
    }
  });

  // Create new token with 15-minute expiration
  await prisma.magicLink.create({
    data: {
      tokenHash,
      email: email.toLowerCase(),
      expiresAt: new Date(Date.now() + 15 * 60 * 1000)
    }
  });

  // Return the raw token (for the URL)
  return token;
}
3

Implement rate limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_URL,
  token: process.env.UPSTASH_REDIS_TOKEN
});

// Rate limit per email: 3 requests per hour
const emailRateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(3, '1 h')
});

// Rate limit per IP: 10 requests per hour
const ipRateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '1 h')
});

async function checkRateLimits(email: string, ip: string) {
  const [emailLimit, ipLimit] = await Promise.all([
    emailRateLimiter.limit(email),
    ipRateLimiter.limit(ip)
  ]);

  if (!emailLimit.success) {
    return {
      allowed: false,
      message: 'Too many login attempts for this email. Please try again later.'
    };
  }

  if (!ipLimit.success) {
    return {
      allowed: false,
      message: 'Too many requests. Please try again later.'
    };
  }

  return { allowed: true };
}
4
import { z } from 'zod';

const requestSchema = z.object({
  email: z.string().email().toLowerCase()
});

async function requestMagicLink(req, res) {
  const result = requestSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: 'Invalid email address' });
  }

  const { email } = result.data;

  // Check rate limits
  const rateLimit = await checkRateLimits(email, req.ip);
  if (!rateLimit.allowed) {
    return res.status(429).json({ error: rateLimit.message });
  }

  // Always return success to prevent email enumeration
  // Even if user doesn't exist, we show the same message
  const userExists = await prisma.user.findUnique({
    where: { email }
  });

  if (userExists) {
    const token = await createMagicLink(email);
    const magicLink = `${process.env.APP_URL}/auth/verify?token=${token}`;

    await sendMagicLinkEmail(email, magicLink);
  }

  // Same response regardless of whether user exists
  return res.json({
    message: 'If an account exists, a login link has been sent to your email.'
  });
}
5

Send the email

import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function sendMagicLinkEmail(email: string, magicLink: string) {
  await resend.emails.send({
    from: 'MyApp ',
    to: email,
    subject: 'Your login link for MyApp',
    html: `
      Sign in to MyApp
      Click the button below to sign in. This link expires in 15 minutes.

      
        Sign in to MyApp
      

      
        If you didn't request this link, you can safely ignore this email.
        Someone may have typed your email address by mistake.
      

      
        Link not working? Copy and paste this URL:
        ${magicLink}
      

      

      
        This link expires in 15 minutes and can only be used once.
        Never share this link with anyone.
      
    `
  });
}
6
async function verifyMagicLink(req, res) {
  const { token } = req.query;

  if (!token || typeof token !== 'string') {
    return res.redirect('/login?error=invalid_link');
  }

  const tokenHash = hashToken(token);

  // Find and validate token atomically
  const magicLink = await prisma.magicLink.findUnique({
    where: { tokenHash }
  });

  // Check if token exists
  if (!magicLink) {
    return res.redirect('/login?error=invalid_link');
  }

  // Check if already used
  if (magicLink.usedAt) {
    return res.redirect('/login?error=link_already_used');
  }

  // Check if expired
  if (magicLink.expiresAt < new Date()) {
    return res.redirect('/login?error=link_expired');
  }

  // Mark as used immediately (prevent race conditions)
  await prisma.magicLink.update({
    where: { id: magicLink.id },
    data: { usedAt: new Date() }
  });

  // Find or create user
  let user = await prisma.user.findUnique({
    where: { email: magicLink.email }
  });

  if (!user) {
    user = await prisma.user.create({
      data: {
        email: magicLink.email,
        emailVerified: new Date()  // Email is verified by using magic link
      }
    });
  } else if (!user.emailVerified) {
    // Mark email as verified
    await prisma.user.update({
      where: { id: user.id },
      data: { emailVerified: new Date() }
    });
  }

  // Create session
  const session = await createSession(user.id, req);
  setSessionCookie(res, session.sessionId, session.expiresAt);

  return res.redirect('/dashboard');
}
7

Clean up expired tokens

// Run periodically (cron job or scheduled function)
async function cleanupExpiredTokens() {
  const result = await prisma.magicLink.deleteMany({
    where: {
      OR: [
        { expiresAt: { lt: new Date() } },
        // Also clean up used tokens older than 24 hours
        {
          usedAt: { not: null },
          createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) }
        }
      ]
    }
  });

  console.log(`Cleaned up ${result.count} magic link tokens`);
}

Magic Link Security Considerations:

  • Short expiration (15 minutes max) - reduces window for interception
  • Single use - token invalidated after first use
  • Rate limiting - prevent abuse and enumeration
  • Hash tokens - raw tokens never stored in database
  • HTTPS only - tokens should never travel over HTTP
  • Clear email messaging - help users identify phishing
  • Consider adding device fingerprinting for suspicious logins

How to Verify It Worked

  1. Test single use: Use a magic link, try using it again - should fail
  2. Test expiration: Wait 15+ minutes, try the link - should fail
  3. Test rate limiting: Request 4+ links quickly - should be blocked
  4. Check token storage: Verify only hashes are in database

Common Errors & Troubleshooting

Check server time synchronization. Time differences between servers can cause premature expiration.

Emails not arriving

Check spam folders, verify sender domain authentication (SPF, DKIM, DMARC), and check email service logs.

Use database transactions or atomic operations to check and mark as used in a single query.

Some email clients break long URLs. Include a copy-paste option and consider shorter tokens (UUID v4).

Magic links vs passwords - which is more secure?

It depends on your users. Magic links eliminate password-related attacks but shift security to email. If users have strong email security (2FA), magic links are great. If not, you're trusting their email provider's security.

Should I offer both magic links and passwords?

Yes, many apps offer both. Users can choose their preference. Consider requiring 2FA if users set a password.

How do I handle mobile email apps?

Mobile email apps often preview links, potentially "using" them. Consider: longer tokens that are harder to guess if previewed, or a confirmation page before consuming the token.

Related guides:Session Management · OAuth Setup · Two-Factor Auth

How-To Guides

How to Implement Magic Link Authentication