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.
How Magic Links Work
- 1User enters email address
- 2Server generates unique token
- 3Server sends email with magic link
- 4User clicks link
- 5Server validates token
- 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;
}
}Sending Magic Links
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!' };
}
}Verifying Magic Links
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.