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
- Single-use, expiring tokens — burn the token on verify; 15-minute TTL caps the blast radius of a leaked link.
- Idempotency-Key — key on the token so a retry never double-sends the login email.
- Uniform response — return the same message whether the email exists or not, so attackers can't enumerate accounts.
- Store hashes, not raw tokens — hash the token server-side (like MailAnvil hashes API keys) so a DB leak doesn't mint logins.
- 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.