How to Send Password Reset Emails with MailAnvil + Node.js (Secure Flow)
Every account has a "forgot password" link. Most are done wrong — tokens stored in plaintext, links that never expire, no rate limit on the reset endpoint. For an Indonesian fintech or e-commerce app, a broken reset flow is a support ticket factory and a security hole at once.
This tutorial builds the reset flow correctly with MailAnvil + Node.js/Express. It's the same pattern every serious Indonesian product needs: GoPay-style e-wallets, Tokopedia-style marketplaces, and any SaaS with login.
Why MailAnvil
Two things matter for password reset emails specifically: speed and deliverability.
| MailAnvil | SendGrid | Resend | |
|---|---|---|---|
| Reset email in Jakarta | ~10ms edge | ~200ms | ~180ms |
| Payment | QRIS, GoPay, bank transfer | Credit card only | Credit card only |
| 10k emails | Rp 149rb/mo | $19.95 (~Rp 319rb) | $20 (~Rp 320rb) |
| Docs | Bahasa + English | English | English |
A reset email that lands late is a user who abandons the flow. MailAnvil's Cloudflare Workers edge serves Jakarta in single-digit milliseconds, and you pay in IDR.
The security model (do not skip)
Four rules before any code:
- Never store the raw token. Store a SHA-256 hash. If the DB leaks, attackers can't reset anyone's password.
- Expire tokens. 30 minutes is standard.
- Single-use. Invalidate on use.
- Rate limit the reset endpoint. One email per address per few minutes.
This mirrors how MailAnvil itself stores API keys — SHA-256 hashes only.
Step 1: Setup
mkdir reset-flow && cd reset-flow
npm init -y
npm install express
.env:
MAILANVIL_API_KEY=ma_key_xxxxxxxxxxxxx
MAILANVIL_BASE=https://api.mailanvil.com/v1
APP_BASE=https://app.tokokamu.id
PORT=3000
Step 2: Generate and store the token (hashed)
// reset.js
const crypto = require('crypto');
function createResetToken() {
// 32 random bytes → 64 hex chars. Raw token goes to the email only.
return crypto.randomBytes(32).toString('hex');
}
function hashToken(token) {
// SHA-256. Same pattern MailAnvil uses for API keys.
return crypto.createHash('sha256').update(token).digest('hex');
}
Store { user_id, token_hash, expires_at } in your DB (Postgres, D1, whatever you use). The raw token is never written to disk.
Step 3: Email the raw token via MailAnvil
// email.js
const MAILANVIL_BASE = process.env.MAILANVIL_BASE;
const MAILANVIL_API_KEY = process.env.MAILANVIL_API_KEY;
async function sendResetEmail({ to, resetUrl }) {
const html = `
<div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;color:#222">
<div style="background:#ff801f;padding:20px;text-align:center">
<h1 style="color:#fff;margin:0;font-size:22px">Reset Password</h1>
</div>
<div style="padding:24px">
<p>You requested a password reset. This link expires in <strong>30 minutes</strong>.</p>
<p style="text-align:center;margin:28px 0">
<a href="${resetUrl}"
style="background:#ff801f;color:#fff;padding:12px 28px;border-radius:999px;text-decoration:none;font-weight:bold">
Reset my password
</a>
</p>
<p style="color:#666;font-size:13px">
If you didn't request this, ignore this email. Your password is unchanged.
</p>
</div>
</div>`;
const response = await fetch(`${MAILANVIL_BASE}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MAILANVIL_API_KEY}`,
},
body: JSON.stringify({
from: '[email protected]',
to: [to],
subject: 'Reset your password',
html,
text: `Reset your password: ${resetUrl} (expires in 30 minutes)`,
}),
});
const data = await response.json();
if (response.status === 202) return data;
throw new Error(`MailAnvil error: ${data.error?.message || response.status}`);
}
Step 4: The request + confirm endpoints
// server.js
require('dotenv').config();
const express = require('express');
const { createResetToken, hashToken } = require('./reset');
const { sendResetEmail } = require('./email');
const app = express();
app.use(express.json());
// Minimal in-memory rate limiter (use Redis/DO in production)
const lastRequest = new Map();
app.post('/forgot-password', async (req, res) => {
const { email } = req.body;
// Rate limit: 1 request / 3 min per email
const last = lastRequest.get(email);
if (last && Date.now() - last < 180_000) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
lastRequest.set(email, Date.now());
const token = createResetToken();
const expiresAt = new Date(Date.now() + 30 * 60 * 1000);
// Store hash only. Never the raw token.
await db.run(
'INSERT INTO reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)',
[userId, hashToken(token), expiresAt.toISOString()]
);
const resetUrl = `${process.env.APP_BASE}/reset?token=${token}`;
await sendResetEmail({ to: email, resetUrl });
// Always 200 — don't reveal whether the email exists
res.json({ ok: true });
});
app.post('/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
const row = await db.first(
'SELECT * FROM reset_tokens WHERE token_hash = ? AND expires_at > datetime("now")',
[hashToken(token)]
);
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
// Update password, then invalidate the token (single-use)
await db.run('UPDATE users SET password_hash = ? WHERE id = ?', [hash(newPassword), row.user_id]);
await db.run('DELETE FROM reset_tokens WHERE id = ?', [row.id]);
res.json({ ok: true });
});
app.listen(process.env.PORT, () => console.log(`Listening on ${process.env.PORT}`));
Real Indonesian use cases
- E-wallets (GoPay/OVO-style). Reset requests spike when a user reinstalls the app. Edge delivery means no queue backlog at 9 AM payday.
- Marketplaces (Tokopedia/Shopee-style). Millions of accounts. Idempotency matters — MailAnvil dedupes on
Idempotency-Key, so a double-clicked "send reset link" button doesn't fire twice. - Fintech (P2P lending, e-KYC apps). OJK scrutiny means audit trails. MailAnvil keeps 90-day logs, so you can prove a reset email was sent and delivered.
Best practices
- Always 200 on forgot-password. Returning 404 for an unknown email lets attackers enumerate accounts.
- Hash the token. If you store plaintext, a leaked DB = full account takeover.
- Expire + single-use. A reset link that works forever is a backdoor.
- Rate limit. Brute-forcing reset tokens is how accounts get drained.
Try it free
500 emails/month free, no credit card. Verify your domain, grab an API key, and the flow above works as-is.
➡️ Try MailAnvil free — mailanvil.com
Pay in IDR via QRIS or GoPay. Docs in Bahasa Indonesia and English. Support via Telegram.