← Back to all posts
2026-08-21 · MailAnvil Team

Passwordless Magic-Link Login with MailAnvil + Fastify

Passwords leak. Users forget them. Support queues fill with "reset my password" tickets. For a 2026 SaaS, the cleanest fix is to drop passwords entirely: send the user a one-time magic link that logs them in on click.

This tutorial builds the whole flow — request login, send magic link via MailAnvil, verify the token — with Fastify + MailAnvil.

Why Fastify + MailAnvil?

Fastify is the fastest Node web framework and the natural home for an API-first backend. MailAnvil is a transactional email API that's edge-native (Cloudflare Workers) — the two pair cleanly for a login flow where email delivery speed is the difference between "logged in" and "user left."

MailAnvil Resend SendGrid
10K emails/mo Rp 149rb ~Rp 320rb ~Rp 319rb
Payment QRIS, GoPay Credit card Credit card
Docs Bahasa + English English English
Latency Edge (Jakarta PoP) us-east us-east

What We Build

POST /auth/magic-link  →  Fastify route  →  MailAnvil /v1/send  →  user inbox
GET  /auth/verify      →  Fastify route  →  check token       →  session

Step 1: Scaffold Fastify

mkdir magic-link-app && cd magic-link-app
npm init -y
npm install fastify @fastify/env

Step 2: Store Secrets

Never hardcode keys. Use .env:

MAILANVIL_API_KEY=mk_live_xxxxxxxx
[email protected]
APP_BASE_URL=https://yourapp.id

Step 3: Magic-Link Route

server.js:

import Fastify from 'fastify';
import crypto from 'node:crypto';

const fastify = Fastify({ logger: true });

const BASE = 'https://api.mailanvil.com/v1';

// In-memory token store — swap for Redis/DB in production.
const tokens = new Map();

fastify.post('/auth/magic-link', async (req, reply) => {
  const { email } = req.body;
  if (!email) return reply.code(400).send({ error: 'email is required' });

  // One-time, expiring, unguessable token.
  const token = crypto.randomBytes(32).toString('hex');
  const expires = Date.now() + 15 * 60 * 1000; // 15 minutes
  tokens.set(token, { email, expires });

  const link = `${process.env.APP_BASE_URL}/auth/verify?token=${token}`;

  const res = await fetch(`${BASE}/send`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.MAILANVIL_API_KEY}`,
      'Idempotency-Key': `magic-${token}`,
    },
    body: JSON.stringify({
      from: process.env.MAILANVIL_FROM,
      to: [email],
      subject: 'Your sign-in link',
      html: buildMagicLinkHtml(link),
      text: `Sign in to yourapp.id: ${link}`,
    }),
  });

  if (res.status !== 202) {
    const err = await res.json();
    return reply.code(502).send({ error: err.error?.message || 'send failed' });
  }

  // Always return the same shape — don't leak whether the email exists.
  return { ok: true, message: 'If that email exists, a sign-in link is on its way.' };
});

Step 4: Verify Route

fastify.get('/auth/verify', async (req, reply) => {
  const { token } = req.query;
  const entry = tokens.get(token);

  if (!entry) return reply.code(401).send({ error: 'invalid or expired link' });
  if (entry.expires < Date.now()) {
    tokens.delete(token);
    return reply.code(401).send({ error: 'link expired' });
  }

  tokens.delete(token); // single use — burn after verify
  // Create a real session here (JWT / cookie), then:
  return reply.redirect('/dashboard');
});

Step 5: Email Template

function buildMagicLinkHtml(link) {
  return `
    <div style="font-family:sans-serif;max-width:480px;margin:0 auto">
      <h1 style="color:#ff801f">Sign in to yourapp.id</h1>
      <p>Click the button to sign in. The link expires in 15 minutes.</p>
      <a href="${link}" style="display:inline-block;background:#ff801f;color:#fff;padding:12px 24px;border-radius:9999px;text-decoration:none">Sign in</a>
      <p style="margin-top:24px;color:#666">If you didn't request this, ignore this email.</p>
    </div>`;
}

Step 6: Test

node server.js
curl -X POST http://localhost:3000/auth/magic-link \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

Response {"ok":true,"message":"..."} and the link lands in the inbox in under a second.

Best Practices

  1. Single-use, expiring tokens — burn the token on verify; 15-minute TTL caps the blast radius of a leaked link.
  2. Idempotency-Key — key on the token so a retry never double-sends the login email.
  3. Uniform response — return the same message whether the email exists or not, so attackers can't enumerate accounts.
  4. Store hashes, not raw tokens — hash the token server-side (like MailAnvil hashes API keys) so a DB leak doesn't mint logins.
  5. Rate-limit the endpoint — cap requests per IP to stop someone using your email quota to spam strangers.

Start Free

Free plan: 500 emails/month, no credit card. Pay with QRIS/GoPay. Full Bahasa Indonesia docs.

➡️ Try MailAnvil Free — mailanvil.com