← 返回博客

Express.js 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Express 里做注册/登录邮箱验证码:crypto.randomInt、HMAC 落库、过期与限流、用 fetch 调 POST /emails,以及 Idempotency-Key——Rovela 只负责投递。

更新于 2026年11月4日
Express.js 发送邮箱验证码(2026):用 HTTP API 发 OTP

为什么是 Express + 验证码(不是魔法链接 SDK)

Express 本来就适合做注册/登录:几条路由、限流中间件、服务端密钥与存储。你不需要 Rovela 的 Node SDK,也不需要所谓「OTP 产品」接口,更不要用 Passport 之类把邮件 API 装成身份提供方。

你需要的是文档里的配方:邮箱验证码——用 crypto.randomInt 生成六位数字,只存 HMAC(绝不存明文),设过期与尝试次数,再用 POST /emails 同时发 html 与 text。Rovela 负责排队、DKIM 签名、重试与事件。它不会生成验证码、校验验证码,也不会建会话。

Rovela 不提供 OTP 功能。 密码学、数据库行、重发节流、兑换逻辑、Session / JWT 都属于你的 Express 应用;Rovela 只负责投递。

本文面向 Express.js(CommonJS / ESM,TypeScript 友好):send code / verify code 路由、用 fetch 调 Rovela,以及 Idempotency-Key,避免超时重试发出两封互相打架的验证码。这不是魔法链接教程——那些见 Next.js、NestJS、FastAPI。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 确认链接。与语言无关的 OTP 总览是 用邮件 API 发 OTP;Node 发信总览是 Node.js 发邮件——本文不重复泛化的 https/axios 教程。

你负责什么,Rovela 发什么

Express 应用负责:

  • crypto.randomInt(0, 1_000_000) → 六位补零(绝不用 Math.random())
  • 对 email + ":" + code 做 HMAC-SHA256(CODE_PEPPER)
  • 过期(文档示例约 10 分钟)、尝试计数、「每地址每分钟最多一封」重发门闩
  • 按规范化邮箱 upsert,新发送作废旧码
  • 校验时恒定时间比较;成功后删除行
  • 是否暴露「账号是否存在」由产品决定,但对外文案要一致
  • 对 /auth/otp/send 与 /auth/otp/verify 做限流

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(环境变量 ROVELA_API_KEY——勿提交仓库、勿下发浏览器)
  • 排队、签名、重试、退信/投诉处理

发送接口返回 200 只表示已接受并入队,不等于「已进收件箱」。

环境变量与精简 Rovela 客户端

// src/config.ts
export const config = {
  rovelaApiKey: process.env.ROVELA_API_KEY!, // sending_access
  codePepper: process.env.CODE_PEPPER!, // 足够长的随机密钥
  otpFrom: process.env.OTP_FROM ?? "Acme <security@mail.acme.com>",
  otpTtlMinutes: Number(process.env.OTP_TTL_MINUTES ?? 10),
  resendCooldownSec: Number(process.env.OTP_RESEND_COOLDOWN_SEC ?? 60),
  maxAttempts: Number(process.env.OTP_MAX_ATTEMPTS ?? 5),
};
// src/rovela.ts
import { config } from "./config.js";

export type SendEmailInput = {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
};

export async function sendEmail(input: SendEmailInput) {
  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${config.rovelaApiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": input.idempotencyKey,
    },
    body: JSON.stringify({
      from: config.otpFrom,
      to: input.to,
      subject: input.subject,
      html: input.html,
      text: input.text,
    }),
  });

  if (!res.ok) {
    const errText = await res.text();
    throw new Error(`Rovela send failed: ${res.status} ${errText}`);
  }
  return res.json() as Promise<{ object: string; id: string }>;
}

ROVELA_API_KEY、CODE_PEPPER 与其它服务端密钥同样管理。生产 OTP 发送用 sending_access 就够、也更合适(鉴权、快速开始)。

落库哈希:randomInt + HMAC,不是裸 SHA-256

六位数字只有一百万种可能。泄露一张裸 SHA-256 表,瞬间就能反推。按文档:用永远不离开服务器的 pepper 做 HMAC。

// src/otp/crypto.ts
import crypto from "node:crypto";
import { config } from "../config.js";

export function normalizeEmail(raw: string) {
  return raw.trim().toLowerCase();
}

export function mintCode(): string {
  // 操作系统 CSPRNG — Math.random() 不能用来生成密钥材料
  return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}

export function hashCode(email: string, code: string) {
  return crypto
    .createHmac("sha256", config.codePepper)
    .update(`${email}:${code}`)
    .digest("hex");
}

export function safeEqualHex(a: string, b: string) {
  const ba = Buffer.from(a, "hex");
  const bb = Buffer.from(b, "hex");
  if (ba.length !== bb.length) return false;
  return crypto.timingSafeEqual(ba, bb);
}

建议表结构(每个邮箱一条有效码),与验证码示例一致:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

服务层:发送与校验

下面用任意存储(pg / Prisma / Drizzle)示意,把 SQL 换成你的实现即可。

// src/otp/service.ts
import { config } from "../config.js";
import { sendEmail } from "../rovela.js";
import {
  hashCode,
  mintCode,
  normalizeEmail,
  safeEqualHex,
} from "./crypto.js";
// import { db } from "../db.js";

const SAFE_SEND_MSG = "若该地址可收信,验证码已在路上。";

export async function sendOtp(rawEmail: string) {
  const email = normalizeEmail(rawEmail);

  // 每地址一分钟一封 —— 保护对方收件箱与你的额度
  const recent = await db.query(
    `SELECT 1 FROM verification_codes
     WHERE email = $1 AND sent_at > NOW() - ($2 || ' seconds')::interval`,
    [email, String(config.resendCooldownSec)]
  );
  if (recent.rowCount > 0) {
    return { ok: false as const, error: "too_soon" };
  }

  const code = mintCode();
  const codeHash = hashCode(email, code);
  const expiresAt = new Date(Date.now() + config.otpTtlMinutes * 60_000);

  await db.query(
    `INSERT INTO verification_codes (email, code_hash, expires_at, attempts, sent_at)
     VALUES ($1, $2, $3, 0, NOW())
     ON CONFLICT (email) DO UPDATE
       SET code_hash = EXCLUDED.code_hash,
           expires_at = EXCLUDED.expires_at,
           attempts = 0,
           sent_at = NOW()`,
    [email, codeHash, expiresAt.toISOString()]
  );

  // 每次逻辑发送稳定:哈希前缀。新码 ⇒ 新 Key。
  const idempotencyKey = `otp-${codeHash.slice(0, 32)}`;

  await sendEmail({
    to: email,
    subject: `Your Acme code: ${code}`,
    html: `<p>Your verification code is</p>
<p style="font-size:28px;font-weight:600;letter-spacing:4px">${code}</p>
<p>It expires in ${config.otpTtlMinutes} minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Your verification code is ${code}. It expires in ${config.otpTtlMinutes} minutes. If you did not ask for it, ignore this email.`,
    idempotencyKey,
  });

  return { ok: true as const, message: SAFE_SEND_MSG };
}

export async function verifyOtp(rawEmail: string, rawCode: string) {
  const email = normalizeEmail(rawEmail);
  const code = rawCode.trim();
  if (!/^\d{6}$/.test(code)) {
    return { ok: false as const, error: "invalid" };
  }

  const { rows } = await db.query(
    `SELECT code_hash, expires_at, attempts FROM verification_codes WHERE email = $1`,
    [email]
  );
  const row = rows[0];
  if (!row) return { ok: false as const, error: "invalid" };

  if (new Date(row.expires_at).getTime() < Date.now()) {
    await db.query(`DELETE FROM verification_codes WHERE email = $1`, [email]);
    return { ok: false as const, error: "expired" };
  }

  if (row.attempts >= config.maxAttempts) {
    return { ok: false as const, error: "locked" };
  }

  const candidate = hashCode(email, code);
  const match = safeEqualHex(candidate, row.code_hash);

  if (!match) {
    await db.query(
      `UPDATE verification_codes SET attempts = attempts + 1 WHERE email = $1`,
      [email]
    );
    return { ok: false as const, error: "invalid" };
  }

  await db.query(`DELETE FROM verification_codes WHERE email = $1`, [email]);
  // 在此签发 session / JWT
  return { ok: true as const };
}

把验证码放进主题有利于锁屏预览——多数产品可以,公共大屏慎用(验证码文档)。务必同时发 html 与 text,并在纯文本里带上数字(POST /emails)。

Express 路由 + 限流草图

// src/routes/otp.ts
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { sendOtp, verifyOtp } from "../otp/service.js";

export const otpRouter = Router();

const sendLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20, // 每 IP —— 按威胁模型收紧
  standardHeaders: true,
  legacyHeaders: false,
});

const verifyLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 40,
  standardHeaders: true,
  legacyHeaders: false,
});

otpRouter.post("/auth/otp/send", sendLimiter, async (req, res) => {
  const email = String(req.body?.email ?? "");
  if (!email.includes("@")) {
    return res.status(400).json({ error: "invalid_email" });
  }
  const result = await sendOtp(email);
  if (!result.ok && result.error === "too_soon") {
    return res.status(429).json({ error: "too_soon" });
  }
  // 若要隐藏账号是否存在,生产环境更倾向统一 200 文案
  return res.status(200).json({ message: result.message ?? "ok" });
});

otpRouter.post("/auth/otp/verify", verifyLimiter, async (req, res) => {
  const email = String(req.body?.email ?? "");
  const code = String(req.body?.code ?? "");
  const result = await verifyOtp(email, code);
  if (!result.ok) {
    const status =
      result.error === "locked" || result.error === "too_soon" ? 429 : 400;
    return res.status(status).json({ error: result.error });
  }
  // Set cookie / return JWT
  return res.status(200).json({ ok: true });
});

用 app.use(otpRouter) 挂上,并启用 express.json()。服务内「每邮箱冷却」与 express-rate-limit「每 IP」解决的是不同问题——两者都要。加固清单见 邮箱 OTP 安全。

Idempotency-Key:重试不发双码

Worker、代理、AI agent 都会重试。没有 Idempotency-Key,一次超时就可能发出两封内容不一致的验证码。

  • Header 最长 256 字符;Rovela 记住 24 小时,重放返回原响应(API: emails)。
  • 用码哈希派生(otp- + 前 32 位 hex),一次逻辑 mint 对应一封入队邮件。
  • 冷却后的主动重发会 mint 新码 → 新哈希 → 新 Key。不要拿旧 Key 发新码。
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

验证码投递注意(不是魔法 URL)

OTP 邮件短、敏感。优先用已验证子域(如 security@mail.example.com)、对齐 SPF/DKIM/DMARC,HTML 尽量克制。正文(以及常见情况下的主题)里是数字,通常不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你可控的发送域上。

若进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。要不要用数字码还是确认链接,见 验证码 vs 链接。

Express 里:验证码 vs 魔法链接

六位 OTP(本文)魔法链接
用户动作输入数字点击 URL
密钥熵低 → 必须 HMAC + pepper高熵 URL token → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Express 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪次要安全发送域建议关闭

两者都走同一个 POST /emails。表与服务分开——不要把 OTP 行复用成魔法链接 token。

上线前清单

  1. crypto.randomInt + HMAC pepper;永不存明文码。
  2. 约 10 分钟 TTL、尝试上限、每邮箱重发冷却、每 IP 限流。
  3. Upsert 作废旧码;校验成功删除。
  4. 同时有 html 与 text;主题是否带码按场景决定。
  5. ROVELA_API_KEY(sending_access)只留在服务端。
  6. 每次逻辑发送带稳定 Idempotency-Key;新码 → 新 Key。
  7. 若隐藏账号存在性,对外文案统一。
  8. From 域已验证;进垃圾箱则走 spam 清单。

延伸阅读

  1. 文档:邮箱验证码 · POST /emails · 快速开始 · 鉴权。
  2. 通用 OTP:用邮件 API 发 OTP · 邮箱 OTP 安全。
  3. 产品选择:验证码 vs 确认链接。
  4. 投递:OTP / 魔法链接进垃圾箱。
  5. 魔法链接兄弟篇(另一套原语):Next.js · NestJS · FastAPI。
  6. 仅 Node 传输:Node.js 发邮件。

一句话: 把注册/登录 OTP 放进 Express 路由——用 randomInt 生成、HMAC 落库、限流 send/verify,再用带 Idempotency-Key 的一次 fetch POST 交给 Rovela——别把邮件 API 当成你的认证服务器。