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

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:

  1. Never store the raw token. Store a SHA-256 hash. If the DB leaks, attackers can't reset anyone's password.
  2. Expire tokens. 30 minutes is standard.
  3. Single-use. Invalidate on use.
  4. 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

Best practices

  1. Always 200 on forgot-password. Returning 404 for an unknown email lets attackers enumerate accounts.
  2. Hash the token. If you store plaintext, a leaked DB = full account takeover.
  3. Expire + single-use. A reset link that works forever is a backdoor.
  4. 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.