Skip to main content

How to Implement Passwordless Auth with Magic Links

·APIScout Team
passwordless authmagic linksauthenticationtutorialsecurity

How to Implement Passwordless Auth with Magic Links

Magic links eliminate passwords entirely. User enters email, clicks a link, they're in. No password to forget, no credential stuffing attacks, no bcrypt. This guide covers building magic link auth from scratch and using providers that handle it for you.

What You'll Build

  • Email-based magic link login
  • Secure token generation and validation
  • Session management with JWTs
  • Rate limiting and security measures
  • Provider-based implementation (Resend + custom)

Prerequisites: Next.js 14+, a transactional email provider (Resend, SendGrid, etc.).

User enters email → Server generates token → Email sent with link
→ User clicks link → Server validates token → Session created

The token is a one-time-use, time-limited credential embedded in a URL. Click it, get authenticated. Simple.

2. Build It from Scratch

Token Generation

// lib/magic-link.ts
import crypto from 'crypto';
import { SignJWT, jwtVerify } from 'jose';

const SECRET = new TextEncoder().encode(process.env.MAGIC_LINK_SECRET!);
const TOKEN_EXPIRY = '15m'; // 15 minutes to click

// Generate a magic link token
export async function createMagicToken(email: string): Promise<string> {
  const token = await new SignJWT({
    email,
    nonce: crypto.randomBytes(16).toString('hex'),
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(TOKEN_EXPIRY)
    .setJti(crypto.randomUUID()) // Unique token ID for one-time use
    .sign(SECRET);

  return token;
}

// Verify a magic link token
export async function verifyMagicToken(token: string): Promise<{
  email: string;
  jti: string;
} | null> {
  try {
    const { payload } = await jwtVerify(token, SECRET);
    return {
      email: payload.email as string,
      jti: payload.jti as string,
    };
  } catch {
    return null; // Expired, tampered, or invalid
  }
}

Token Store (One-Time Use)

// lib/token-store.ts
// In production, use Redis or your database

const usedTokens = new Set<string>();

export function markTokenUsed(jti: string): boolean {
  if (usedTokens.has(jti)) return false; // Already used
  usedTokens.add(jti);

  // Clean up after 30 minutes (tokens expire in 15)
  setTimeout(() => usedTokens.delete(jti), 30 * 60 * 1000);
  return true;
}

// Redis version (production):
// export async function markTokenUsed(jti: string): Promise<boolean> {
//   const result = await redis.set(`magic:${jti}`, '1', 'NX', 'EX', 1800);
//   return result === 'OK';
// }
// lib/send-magic-email.ts
import { Resend } from 'resend';

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

export async function sendMagicLink(email: string, token: string) {
  const magicUrl = `${APP_URL}/api/auth/verify?token=${token}`;

  await resend.emails.send({
    from: 'Your App <login@yourdomain.com>',
    to: email,
    subject: 'Your login link',
    html: `
      <h2>Log in to Your App</h2>
      <p>Click the link below to log in. This link expires in 15 minutes.</p>
      <a href="${magicUrl}" style="
        display: inline-block;
        padding: 12px 32px;
        background: #2563eb;
        color: white;
        text-decoration: none;
        border-radius: 6px;
        font-weight: bold;
      ">Log In</a>
      <p style="color: #666; font-size: 14px; margin-top: 16px;">
        If you didn't request this, ignore this email.
      </p>
      <p style="color: #999; font-size: 12px;">
        Or copy this URL: ${magicUrl}
      </p>
    `,
  });
}

Login API Route

// app/api/auth/login/route.ts
import { NextResponse } from 'next/server';
import { createMagicToken } from '@/lib/magic-link';
import { sendMagicLink } from '@/lib/send-magic-email';

// Rate limiting (simple in-memory)
const loginAttempts = new Map<string, { count: number; resetAt: number }>();

export async function POST(req: Request) {
  const { email } = await req.json();

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

  // Rate limit: 5 attempts per email per 15 minutes
  const now = Date.now();
  const attempts = loginAttempts.get(email);
  if (attempts && attempts.resetAt > now && attempts.count >= 5) {
    return NextResponse.json(
      { error: 'Too many attempts. Try again later.' },
      { status: 429 }
    );
  }

  if (!attempts || attempts.resetAt <= now) {
    loginAttempts.set(email, { count: 1, resetAt: now + 15 * 60 * 1000 });
  } else {
    attempts.count++;
  }

  const token = await createMagicToken(email);
  await sendMagicLink(email, token);

  // Always return success (don't reveal if email exists)
  return NextResponse.json({ message: 'Check your email for a login link' });
}

Verify API Route

// app/api/auth/verify/route.ts
import { NextResponse } from 'next/server';
import { verifyMagicToken } from '@/lib/magic-link';
import { markTokenUsed } from '@/lib/token-store';
import { createSession } from '@/lib/session';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const token = searchParams.get('token');

  if (!token) {
    return NextResponse.redirect(new URL('/login?error=missing_token', req.url));
  }

  // Verify token signature and expiry
  const payload = await verifyMagicToken(token);
  if (!payload) {
    return NextResponse.redirect(new URL('/login?error=invalid_or_expired', req.url));
  }

  // Ensure one-time use
  const isFirstUse = markTokenUsed(payload.jti);
  if (!isFirstUse) {
    return NextResponse.redirect(new URL('/login?error=already_used', req.url));
  }

  // Create or find user
  const user = await findOrCreateUser(payload.email);

  // Create session
  const sessionToken = await createSession(user.id);

  // Set cookie and redirect
  const response = NextResponse.redirect(new URL('/dashboard', req.url));
  response.cookies.set('session', sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 30, // 30 days
    path: '/',
  });

  return response;
}

async function findOrCreateUser(email: string) {
  // Check if user exists in your database
  // If not, create a new user record
  // Return the user object
  return { id: 'user_id', email };
}

Session Management

// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';

const SESSION_SECRET = new TextEncoder().encode(process.env.SESSION_SECRET!);

export async function createSession(userId: string): Promise<string> {
  return new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('30d')
    .sign(SESSION_SECRET);
}

export async function getSession(token: string): Promise<{ userId: string } | null> {
  try {
    const { payload } = await jwtVerify(token, SESSION_SECRET);
    return { userId: payload.userId as string };
  } catch {
    return null;
  }
}

Login Page

// app/login/page.tsx
'use client';
import { useState } from 'react';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);
  const [loading, setLoading] = useState(false);

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

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

    setSent(true);
    setLoading(false);
  };

  if (sent) {
    return (
      <div style={{ textAlign: 'center', padding: '60px 20px' }}>
        <h2>Check your email</h2>
        <p>We sent a login link to <strong>{email}</strong></p>
        <p style={{ color: '#666' }}>
          The link expires in 15 minutes. Check spam if you don't see it.
        </p>
        <button onClick={() => setSent(false)}>
          Use a different email
        </button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '60px auto' }}>
      <h2>Log in</h2>
      <p>Enter your email to receive a login link.</p>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="you@example.com"
        required
        style={{ width: '100%', padding: '12px', fontSize: '16px' }}
      />
      <button
        type="submit"
        disabled={loading}
        style={{
          width: '100%',
          padding: '12px',
          marginTop: '12px',
          background: '#2563eb',
          color: 'white',
          border: 'none',
          borderRadius: '6px',
          fontSize: '16px',
          cursor: loading ? 'wait' : 'pointer',
        }}
      >
        {loading ? 'Sending...' : 'Send Login Link'}
      </button>
    </form>
  );
}

3. Provider-Based (Faster Setup)

With Clerk

// Clerk handles magic links out of the box
import { ClerkProvider, SignIn } from '@clerk/nextjs';

// In your sign-in page:
<SignIn
  appearance={{
    elements: {
      rootBox: { width: '100%' },
    },
  }}
  // Magic link is enabled by default alongside password
/>

With Auth0

Auth0 Dashboard → Authentication → Passwordless
→ Enable "Email" → Configure email template
→ Set OTP/Magic Link mode

With Supabase

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Send magic link
const { error } = await supabase.auth.signInWithOtp({
  email: 'user@example.com',
  options: {
    emailRedirectTo: 'https://yourapp.com/auth/callback',
  },
});

// Handle callback (app/auth/callback/route.ts)
import { NextResponse } from 'next/server';
import { createServerClient } from '@supabase/ssr';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');

  if (code) {
    const supabase = createServerClient(/* ... */);
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(new URL('/dashboard', request.url));
}

4. Security Best Practices

Token Security Checklist

MeasureWhyImplementation
Short expiry (15 min)Limits attack windowsetExpirationTime('15m')
One-time usePrevents replay attacksStore used token IDs
Cryptographic randomnessPrevents guessingcrypto.randomBytes()
HTTPS onlyPrevents interceptionsecure: true on cookies
Rate limitingPrevents email bombing5 per email per 15 min
Don't reveal user existencePrevents enumerationAlways return "check email"

Additional Security

// Bind token to IP (optional, can cause issues with VPNs)
const token = await new SignJWT({
  email,
  ip: req.headers.get('x-forwarded-for'),
})

// Verify IP matches on validation
if (payload.ip && payload.ip !== req.headers.get('x-forwarded-for')) {
  return null; // IP mismatch
}
MethodUXSecuritySetup Complexity
Magic LinksGood (email required)HighLow
Passkeys/WebAuthnExcellent (biometric)Very HighMedium
SMS OTPGood (phone required)Medium (SIM swap risk)Medium
Email OTPGoodHighLow
Social LoginExcellent (one click)Depends on providerLow

Common Mistakes

MistakeImpactFix
Long token expiry (24h+)Wider attack windowKeep to 15 minutes max
Reusable tokensReplay attacks possibleTrack used token IDs, enforce one-time use
Token in URL query params logged by analyticsToken leaked to third partiesUse POST-based verification or strip params
No rate limiting on login endpointEmail bombing, abuseRate limit by email and IP
Revealing "email not found"User enumerationAlways show "check your email"

Choosing an auth method? Compare Auth0 vs Clerk vs Firebase Auth on APIScout — passwordless support, pricing, and developer experience.

Comments