TrueSpec

Passwordless Magic Link Auth

Email magic link flow with Resend and custom token verification

What You’ll Build

After following this guide, you will have a working implementation of passwordless magic link auth in your project. Build passwordless authentication where users click a link in their email to log in — no passwords needed. Uses Resend to send the email, a signed JWT as the magic token, and an Express endpoint to verify and create a session.

Use Cases & Problems Solved

  • Protect routes so only authenticated users can access sensitive pages
  • Allow users to sign up, log in, and manage their accounts securely
  • Avoid storing raw passwords or building session management from scratch

Prerequisites

  • Resend account with verified domain
  • Node.js 18+
  • Express.js project

Step-by-Step Implementation

Install dependencies

The following snippet shows how to install dependencies. Copy this into your project and adjust the values for your environment.

npm install resend jsonwebtoken cookie-parser

The following snippet shows how to send the magic link email. Copy this into your project and adjust the values for your environment.

const jwt = require('jsonwebtoken');
const { Resend } = require('resend');
const resend = new Resend(process.env.RESEND_API_KEY);

app.post('/auth/magic-link', async (req, res) => {
  const { email } = req.body;
  const token = jwt.sign({ email }, process.env.MAGIC_SECRET, { expiresIn: '15m' });
  const link = `${process.env.APP_URL}/auth/verify?token=${token}`;

  await resend.emails.send({
    from: 'auth@yourdomain.com',
    to: email,
    subject: 'Your sign-in link',
    html: `<a href="${link}">Click here to sign in</a><p>Expires in 15 minutes.</p>`,
  });
  res.json({ message: 'Check your email for the sign-in link' });
});

Verify the token and create session

The following snippet shows how to verify the token and create session. Copy this into your project and adjust the values for your environment.

app.get('/auth/verify', async (req, res) => {
  try {
    const { email } = jwt.verify(req.query.token, process.env.MAGIC_SECRET);
    let user = await User.findByEmail(email);
    if (!user) user = await User.create({ email });

    const sessionToken = jwt.sign({ id: user.id }, process.env.SESSION_SECRET, { expiresIn: '7d' });
    res.cookie('session', sessionToken, { httpOnly: true, secure: true, sameSite: 'lax' });
    res.redirect('/dashboard');
  } catch (err) {
    res.status(400).send('Invalid or expired link. Please request a new one.');
  }
});

⚠️ Don’t Do This

// Never expires — if email is intercepted, attacker has permanent access
const token = jwt.sign({ email }, secret); // No expiresIn!

✅ Set a short expiration (10-15 minutes) and single-use tokens

const token = jwt.sign({ email }, secret, { expiresIn: '15m' });
// Also: mark token as used in DB after verification to prevent replay

Testing

Add these tests to verify your passwordless magic link auth implementation works correctly:

// __tests__/passwordless.test.ts
import { describe, it, expect } from 'vitest';

describe('Passwordless Magic Link Auth', () => {
  it('should handle successful authentication', async () => {
    // Test the happy path
    const result = await authenticate({ email: 'test@example.com', password: 'valid' });
    expect(result.user).toBeDefined();
    expect(result.error).toBeNull();
  });

  it('should reject invalid credentials', async () => {
    const result = await authenticate({ email: 'test@example.com', password: 'wrong' });
    expect(result.user).toBeNull();
    expect(result.error).toBeDefined();
  });

  it('should handle missing fields', async () => {
    const result = await authenticate({ email: '', password: '' });
    expect(result.error).toBeDefined();
  });
});

Verification

npm start
# POST to /auth/magic-link with your email
curl -X POST http://localhost:3000/auth/magic-link \
  -H 'Content-Type: application/json' -d '{"email":"you@example.com"}'
# Check your inbox and click the link

Related Specs

Beginner

Supabase Email/Password Auth

Sign up, sign in, password reset, and protected routes with Supabase Auth

Auth & Identity
Beginner

Clerk Auth in Next.js App Router

Drop-in auth with Clerk, middleware protection, and user metadata

Auth & Identity
Intermediate

Next.js Google OAuth Login

Complete Google OAuth flow with NextAuth.js, token handling, session management

Auth & Identity