DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Passwordless Auth: Magic Links and OTP Without the Complexity

Passwordless Auth: Magic Links and OTP Without the Complexity

Passwords are the weakest link in auth. Users reuse them, forget them, and phishing steals them. Magic links and OTPs remove the password entirely.

Magic Link Flow

  1. User enters email
  2. Server generates a short-lived token, emails a link
  3. User clicks link, server validates token, creates session
  4. Token is single-use and expires in 15 minutes

Implementation

import { randomBytes } from 'crypto';

async function sendMagicLink(email: string) {
  // Generate a secure random token
  const token = randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min

  // Store token hash (never store plaintext tokens)
  const tokenHash = createHash('sha256').update(token).digest('hex');
  await db.magicLinks.create({
    data: { email, tokenHash, expiresAt, used: false },
  });

  // Send email with link
  const link = `${process.env.APP_URL}/auth/verify?token=${token}`;
  await resend.emails.send({
    from: 'auth@yourapp.com',
    to: email,
    subject: 'Your login link',
    html: `<a href='${link}'>Click to sign in</a> (expires in 15 minutes)`,
  });
}

async function verifyMagicLink(token: string) {
  const tokenHash = createHash('sha256').update(token).digest('hex');

  const link = await db.magicLinks.findFirst({
    where: { tokenHash, used: false, expiresAt: { gt: new Date() } },
  });

  if (!link) throw new Error('Invalid or expired link');

  // Mark as used — prevents replay attacks
  await db.magicLinks.update({
    where: { id: link.id },
    data: { used: true },
  });

  // Get or create user
  const user = await db.users.upsert({
    where: { email: link.email },
    create: { email: link.email },
    update: {},
  });

  return createSession(user);
}
Enter fullscreen mode Exit fullscreen mode

OTP (One-Time Password)

function generateOTP(): string {
  // 6-digit code, cryptographically random
  return String(randomInt(100000, 999999));
}

async function sendOTP(email: string) {
  const otp = generateOTP();
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 min

  // Store hashed OTP
  await redis.setex(
    `otp:${email}`,
    600, // 10 minutes TTL
    createHash('sha256').update(otp).digest('hex')
  );

  await sendSMS(email, `Your code: ${otp}`);
}
Enter fullscreen mode Exit fullscreen mode

NextAuth Passwordless Setup

// NextAuth handles magic links natively
providers: [
  EmailProvider({
    server: process.env.EMAIL_SERVER,
    from: process.env.EMAIL_FROM,
    // Optional: custom token expiry
    maxAge: 15 * 60, // 15 minutes
  }),
],
Enter fullscreen mode Exit fullscreen mode

Passwordless auth with NextAuth, magic links, and session management are pre-built in the AI SaaS Starter Kit.

Top comments (0)