Skip to content
All essays
CraftMarch 27, 202414 min

Magic Links: Building Passwordless Authentication with Email

Implement secure magic link authentication for passwordless login using email tokens and JWT

Ü
Ümit Uz
Mobile & Full Stack Developer

Magic links provide a simple, secure passwordless authentication method. Users receive a unique link via email that logs them in when clicked. This approach eliminates password-related security issues while providing excellent user experience.

  1. 1User enters email address
  2. 2Server generates unique token
  3. 3Server sends email with magic link
  4. 4User clicks link
  5. 5Server validates token
  6. 6User is authenticated

Token Generation

Creating Secure Tokens

javascript
import crypto from 'crypto';
import { promisify } from 'util';

const randomBytes = promisify(crypto.randomBytes);

class MagicLinkService {
  constructor({ redis, jwt, emailService }) {
    this.redis = redis;
    this.jwt = jwt;
    this.emailService = emailService;
  }

  async generateToken(user) {
    // Generate secure random token
    const token = (await randomBytes(32)).toString('hex');

    // Store token in Redis with expiration
    const key = `magiclink:${token}`;
    const data = {
      userId: user.id,
      email: user.email,
      createdAt: Date.now()
    };

    await this.redis.setex(key, 900, JSON.stringify(data)); // 15 minutes

    return token;
  }

  async createMagicLink(user) {
    const token = await this.generateToken(user);

    // Create link
    const magicLink = `https://example.com/auth/verify?${new URLSearchParams({
      token,
      email: user.email
    })}`;

    return magicLink;
  }
}

Email Service Integration

javascript
class AuthService {
  async sendMagicLink(email) {
    // Find or create user
    let user = await db.users.findByEmail(email);

    if (!user) {
      user = await db.users.create({ email });
    }

    // Generate magic link
    const magicLink = await this.magicLinkService.createMagicLink(user);

    // Send email
    await this.emailService.send({
      to: email,
      subject: 'Sign in to My App',
      html: `
        <!DOCTYPE html>
        <html>
        <body>
          <h2>Sign in to My App</h2>
          <p>Click the button below to sign in:</p>
          <a href="${magicLink}"
             style="background: #007bff; color: white; padding: 12px 24px;
                    text-decoration: none; border-radius: 4px; display: inline-block;">
            Sign In
          </a>
          <p style="color: #666; font-size: 14px; margin-top: 20px;">
            This link will expire in 15 minutes.
          </p>
          <p style="color: #999; font-size: 12px;">
            If you didn't request this, please ignore this email.
          </p>
        </body>
        </html>
      `
    });

    return { success: true, message: 'Magic link sent!' };
  }
}

Token Validation

javascript
class MagicLinkService {
  async verifyToken(token, email) {
    const key = `magiclink:${token}`;
    const data = await this.redis.get(key);

    if (!data) {
      throw new Error('Invalid or expired token');
    }

    const { userId, email: storedEmail, createdAt } = JSON.parse(data);

    // Verify email matches
    if (email !== storedEmail) {
      throw new Error('Email mismatch');
    }

    // Check if token is too old (additional security)
    const age = Date.now() - createdAt;
    if (age > 15 * 60 * 1000) { // 15 minutes
      await this.redis.del(key);
      throw new Error('Token expired');
    }

    // Delete token after use (one-time use)
    await this.redis.del(key);

    return { userId, email };
  }

  async createSession(userId) {
    const user = await db.users.findById(userId);

    // Create JWT session token
    const sessionToken = this.jwt.sign(
      {
        userId: user.id,
        email: user.email,
        type: 'magic_link'
      },
      process.env.JWT_SECRET,
      {
        expiresIn: '7d'
      }
    );

    // Store session
    await db.sessions.create({
      userId,
      token: sessionToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

    return { token: sessionToken, user };
  }
}

Express.js Routes

javascript
const express = require('express');
const router = express.Router();

// Request magic link
router.post('/auth/magic-link', async (req, res) => {
  try {
    const { email } = req.body;

    if (!email) {
      return res.status(400).json({ error: 'Email required' });
    }

    await authService.sendMagicLink(email);

    // Always return success to prevent email enumeration
    res.json({
      success: true,
      message: 'If an account exists, a magic link has been sent.'
    });
  } catch (error) {
    console.error('Magic link error:', error);
    // Still return success to prevent enumeration
    res.json({
      success: true,
      message: 'If an account exists, a magic link has been sent.'
    });
  }
});

// Verify magic link
router.get('/auth/verify', async (req, res) => {
  try {
    const { token, email } = req.query;

    if (!token || !email) {
      return res.status(400).send('Invalid magic link');
    }

    // Verify token
    const { userId } = await magicLinkService.verifyToken(token, email);

    // Create session
    const { token: sessionToken, user } = await magicLinkService.createSession(userId);

    // Set HTTP-only cookie
    res.cookie('session', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    // Redirect to app
    res.redirect('/dashboard?authenticated=true');
  } catch (error) {
    console.error('Verification error:', error);
    res.redirect('/login?error=invalid_link');
  }
});

// Verify status (for client-side check)
router.get('/auth/status', async (req, res) => {
  try {
    const sessionToken = req.cookies.session;

    if (!sessionToken) {
      return res.json({ authenticated: false });
    }

    const decoded = jwt.verify(sessionToken, process.env.JWT_SECRET);
    const user = await db.users.findById(decoded.userId);

    res.json({
      authenticated: true,
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.json({ authenticated: false });
  }
});

// Logout
router.post('/auth/logout', async (req, res) => {
  res.clearCookie('session');
  res.json({ success: true });
});

Frontend Implementation

React Login Component

javascript
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

function MagicLinkLogin() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [sent, setSent] = useState(false);
  const navigate = useNavigate();

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

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

      const data = await response.json();

      if (data.success) {
        setSent(true);
      }
    } catch (error) {
      console.error('Error:', error);
    } finally {
      setLoading(false);
    }
  };

  const checkAuthStatus = async () => {
    try {
      const response = await fetch('/auth/status');
      const data = await response.json();

      if (data.authenticated) {
        navigate('/dashboard');
      }
    } catch (error) {
      console.error('Error checking status:', error);
    }
  };

  // Check authentication on mount (in case user just clicked link)
  React.useEffect(() => {
    checkAuthStatus();
    const interval = setInterval(checkAuthStatus, 3000);
    return () => clearInterval(interval);
  }, []);

  if (sent) {
    return (
      <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow">
        <div className="text-center">
          <div className="mb-4">
            <svg className="w-16 h-16 mx-auto text-green-500"
                 fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round"
                    d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
              />
            </svg>
          </div>
          <h2 className="text-2xl font-bold mb-2">Check your email</h2>
          <p className="text-gray-600 mb-4">
            We've sent a magic link to {email}
          </p>
          <p className="text-sm text-gray-500 mb-4">
            Click the link in the email to sign in. The link expires in 15 minutes.
          </p>
          <button
            onClick={() => setSent(false)}
            className="text-blue-600 hover:underline"
          >
            Use different email
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow">
      <h2 className="text-2xl font-bold mb-6">Sign in with Magic Link</h2>

      <form onSubmit={handleSubmit}>
        <div className="mb-4">
          <label className="block text-sm font-medium mb-2">
            Email address
          </label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-3 py-2 border rounded-lg"
            placeholder="you@example.com"
            required
          />
        </div>

        <button
          type="submit"
          disabled={loading}
          className="w-full bg-blue-600 text-white py-2 rounded-lg
                     hover:bg-blue-700 disabled:opacity-50"
        >
          {loading ? 'Sending...' : 'Send Magic Link'}
        </button>
      </form>

      <p className="mt-4 text-sm text-gray-600 text-center">
        We'll send you a link to sign in instantly
      </p>
    </div>
  );
}

Security Best Practices

Token Security

javascript
class SecureMagicLinkService {
  constructor(redis) {
    this.redis = redis;
    this.rateLimiter = new Map();
  }

  // Rate limiting per email
  async checkRateLimit(email) {
    const key = `ratelimit:${email}`;
    const count = await this.redis.get(key) || 0;

    if (count >= 5) {
      throw new Error('Too many requests. Please try again later.');
    }

    await this.redis.incr(key);
    await this.redis.expire(key, 3600); // 1 hour
  }

  // One-time use tokens
  async consumeToken(token) {
    const key = `magiclink:${token}`;

    // Atomic get and delete
    const result = await this.redis.multi()
      .get(key)
      .del(key)
      .exec();

    const data = result[0][1];

    if (!data) {
      throw new Error('Token already used or expired');
    }

    return JSON.parse(data);
  }

  // Detect suspicious activity
  async logAttempt(email, success) {
    await db.authAttempts.create({
      email,
      success,
      ip: this.ip,
      userAgent: this.userAgent,
      timestamp: new Date()
    });
  }
}

Enhanced Security Features

javascript
// Add device fingerprinting
async function createMagicLink(user, deviceFingerprint) {
  const token = await generateToken(user);

  const magicLink = `https://example.com/auth/verify?${new URLSearchParams({
    token,
    email: user.email,
    fp: deviceFingerprint // Device fingerprint
  })}`;

  return magicLink;
}

// Verify on expected device
async function verifyToken(token, email, deviceFingerprint) {
  const { userId, fp: storedFingerprint } = await validateToken(token);

  if (deviceFingerprint !== storedFingerprint) {
    // Optional: Allow but warn user
    console.warn('Magic link used on different device');
  }

  return userId;
}

Monitoring and Analytics

javascript
class MagicLinkAnalytics {
  async trackSent(email) {
    await analytics.track('magic_link_sent', { email });
  }

  async trackClicked(token) {
    const timeToClick = await this.getTimeToClick(token);
    await analytics.track('magic_link_clicked', {
      timeToClick,
      tokenAge: await this.getTokenAge(token)
    });
  }

  async trackSuccess(userId) {
    await analytics.track('magic_link_success', { userId });
  }

  async trackFailed(token, reason) {
    await analytics.track('magic_link_failed', {
      reason,
      tokenAge: await this.getTokenAge(token)
    });
  }
}

Conclusion

Magic links provide an excellent passwordless authentication experience. With proper security measures including rate limiting, token expiration, and one-time use, they offer both convenience and security. The key is to balance user experience with security best practices.

Related essays

Next essay
Content Security Policy: Complete Guide to Web Application Protection