← 返回博客

Nuxt 发送邮箱验证码(2026):用 HTTP API 发 OTP

用 Nuxt 3 Nitro 服务端路由做注册/登录邮箱验证码:Web Crypto、HMAC 存储、$fetch 调 POST /emails 并带 Idempotency-Key——Rovela 只负责投递。

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

为什么用 Nuxt 3 + Nitro 发邮箱 OTP

Nuxt 3 做注册/登录本来就是服务端优先:Nitro 的 server/api 路由、runtimeConfig 密钥,以及永远不会把发信密钥打进浏览器的 $fetch / fetch。你不需要 Rovela 的 Nuxt 模块,也不需要产品侧专门的 OTP 接口,更不要用 Vue 插件把邮件 API 伪装成身份提供方。

你需要的是 邮箱验证码示例 那套配方:应用用 Web Crypto(crypto.getRandomValues)或 Nitro Node preset 下的 node:crypto 生成六位数字,只存 HMAC(永不存明文),在 Redis / KV / SQL 里设过期与尝试次数,再用 $fetch / fetch 调 POST /emails 同时发 html 与 text。Rovela 负责排队、DKIM 签名、重试与事件轨迹。它不会生成验证码、校验验证码,也不会创建 session。

Rovela 不提供 OTP 功能。 密码学、存储条目、重发节流、核销逻辑、session cookie / JWT 都归你的 Nuxt 服务端路由。Rovela 只负责投递。

本文聚焦 Nuxt 3 / Nitro / TypeScript(server/api、useRuntimeConfig、Nitro storage 或 Redis)。它不是 Express.js 发送邮箱验证码、用 Hono 发邮箱验证码 的复读,也不是 Next.js 魔法登录(token URL,不是六位数字)。姊妹 OTP 文:Go、Django、Laravel、Spring Boot。验证码 vs 链接选型:邮箱验证:验证码还是链接。与语言无关的 OTP:用邮件发送 OTP。

你负责什么,Rovela 发什么

你的 Nuxt / Nitro 应用负责:

  • CSPRNG 六位数字并补零(不要用 Math.random())
  • 对 email + ":" + code 做 HMAC-SHA256,密钥为服务端 pepper(CODE_PEPPER)——Web Crypto 或 node:crypto
  • 过期(文档示例约 10 分钟)、尝试计数,以及「同一地址每分钟最多发一次」的重发闸门
  • 按规范化邮箱 upsert,使新发送作废旧码(Redis、Postgres,或单实例演示用的 Nitro useStorage)
  • 核销时做恒定时间比较;成功后删除 / 作废
  • 不论是否暴露账号存在性,对外响应保持一致(产品选择)
  • 对 /api/auth/otp/send 与 /api/auth/otp/verify 限流(Nitro 中间件、反代或边缘)

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + 具备 sending_access 的密钥(ROVELA_API_KEY 放 runtimeConfig / 环境变量——不要提交进仓库,也不要放进客户端包或 NUXT_PUBLIC_*)
  • 排队、签名、重试、退信/投诉处理

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

用 runtimeConfig 放环境变量(仅服务端)

把密钥放进 .env / 主机环境,并映射到 runtimeConfig(不要放进 runtimeConfig.public):

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    rovelaApiKey: "", // NUXT_ROVELA_API_KEY
    codePepper: "", // NUXT_CODE_PEPPER
    otpFrom: "Acme <security@mail.acme.com>", // NUXT_OTP_FROM
  },
});
# .env(仅服务端 — 不要用 NUXT_PUBLIC_*)
NUXT_ROVELA_API_KEY=re_your_sending_key
NUXT_CODE_PEPPER=long-random-server-secret
NUXT_OTP_FROM="Acme <security@mail.acme.com>"

鉴权见 Authentication,报文见 API: emails,首次发送见 Quick Start。生产环境 OTP 发送用 sending_access 就够——也更合适。

精简 Rovela 客户端($fetch / fetch)

Nitro 可用 $fetch(ofetch)或 undici fetch 调 Rovela。该模块放在 server/ 下:

// server/utils/rovela.ts
export async function sendEmail(input: {
  apiKey: string;
  from: string;
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
}) {
  // $fetch 在非 2xx 时会抛错;若要按状态码分支可自行 catch
  return await $fetch<{ object: string; id: string }>(
    "https://api.rovela.dev/emails",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${input.apiKey}`,
        "Content-Type": "application/json",
        Accept: "application/json",
        "Idempotency-Key": input.idempotencyKey.slice(0, 256),
      },
      body: {
        from: input.from,
        to: input.to,
        subject: input.subject,
        html: input.html,
        text: input.text,
      },
    },
  );
}

不要打印 API 密钥。from 优先用已验证的鉴权子域(如 security@mail.…)。

铸造 + HMAC(Web Crypto,适配 Nitro)

六位数字只有一百万种可能。泄漏的裸 SHA-256 表不到一秒就能反推。按文档:用永远不离开服务器的 pepper 做 HMAC。Web Crypto 可跑在 Nitro Node 与多种边缘 preset;仅 Node 部署时也可以改用 node:crypto(randomInt / createHmac),见姊妹文 Express OTP。

// server/utils/otpCrypto.ts
const textEncoder = new TextEncoder();

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

/** CSPRNG 六位数字 — 不要用 Math.random() */
export function mintCode(): string {
  const buf = new Uint32Array(1);
  crypto.getRandomValues(buf);
  const n = buf[0]! % 1_000_000;
  return n.toString().padStart(6, "0");
}

async function importPepper(pepper: string) {
  return crypto.subtle.importKey(
    "raw",
    textEncoder.encode(pepper),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
}

function toHex(buf: ArrayBuffer) {
  return [...new Uint8Array(buf)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function hashCode(pepper: string, email: string, code: string) {
  const key = await importPepper(pepper);
  const mac = await crypto.subtle.sign(
    "HMAC",
    key,
    textEncoder.encode(`${email}:${code}`),
  );
  return toHex(mac);
}

/** 恒定时间十六进制比较(哈希后等长)。 */
export function safeEqualHex(a: string, b: string) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i)! ^ b.charCodeAt(i)!;
  }
  return diff === 0;
}

存储:Redis(生产)或 Nitro useStorage(演示)

建议的 JSON 值(每个邮箱一条有效码),对齐 验证码示例:

// server/utils/otpStore.ts
export type CodeRecord = {
  codeHash: string;
  expiresAt: number; // epoch ms
  attempts: number;
  sentAt: number;
};

export interface OtpStore {
  get(email: string): Promise<CodeRecord | null>;
  put(email: string, record: CodeRecord, ttlSeconds: number): Promise<void>;
  del(email: string): Promise<void>;
}

/** 单实例 / 本地冒烟 — 多实例生产请换成 Redis 或 SQL */
export function memoryLikeStore(): OtpStore {
  const storage = useStorage("otp"); // 真实部署请在 nuxt.config 配置持久 driver
  return {
    async get(email) {
      return (await storage.getItem<CodeRecord>(email)) ?? null;
    },
    async put(email, record, _ttl) {
      await storage.setItem(email, record);
    },
    async del(email) {
      await storage.removeItem(email);
    },
  };
}

多实例 Nuxt(多个 Node 进程、serverless isolate 或边缘)请用 Redis / Upstash / Postgres——不要用进程内 Map 假装能共享。Redis 客户端接法与 session 相同。

Service:发送 + 校验

// server/utils/otpService.ts
import { sendEmail } from "./rovela";
import {
  hashCode,
  mintCode,
  normalizeEmail,
  safeEqualHex,
} from "./otpCrypto";
import type { OtpStore } from "./otpStore";

const SAFE_SEND_MSG =
  "If that address can receive mail, a code is on its way.";

const TTL_MINUTES = 10;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ATTEMPTS = 5;

export async function sendOtp(opts: {
  store: OtpStore;
  apiKey: string;
  pepper: string;
  from: string;
  rawEmail: string;
}) {
  const email = normalizeEmail(opts.rawEmail);
  const existing = await opts.store.get(email);
  if (existing && Date.now() - existing.sentAt < RESEND_COOLDOWN_MS) {
    return { ok: false as const, error: "too_soon" };
  }

  const code = mintCode();
  const codeHash = await hashCode(opts.pepper, email, code);
  const now = Date.now();
  const expiresAt = now + TTL_MINUTES * 60_000;

  await opts.store.put(
    email,
    { codeHash, expiresAt, attempts: 0, sentAt: now },
    TTL_MINUTES * 60 + 60,
  );

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

  await sendEmail({
    apiKey: opts.apiKey,
    from: opts.from,
    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 ${TTL_MINUTES} minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Your verification code is ${code}. It expires in ${TTL_MINUTES} 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(opts: {
  store: OtpStore;
  pepper: string;
  rawEmail: string;
  rawCode: string;
}) {
  const email = normalizeEmail(opts.rawEmail);
  const code = opts.rawCode.trim();
  if (!/^\d{6}$/.test(code)) {
    return { ok: false as const, error: "invalid" };
  }

  const row = await opts.store.get(email);
  if (!row) return { ok: false as const, error: "invalid" };

  if (row.expiresAt < Date.now()) {
    await opts.store.del(email);
    return { ok: false as const, error: "expired" };
  }

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

  const candidate = await hashCode(opts.pepper, email, code);
  const match = safeEqualHex(candidate, row.codeHash);

  if (!match) {
    await opts.store.put(
      email,
      { ...row, attempts: row.attempts + 1 },
      Math.max(60, Math.ceil((row.expiresAt - Date.now()) / 1000)),
    );
    return { ok: false as const, error: "invalid" };
  }

  await opts.store.del(email);
  // 在此签发 session / JWT / set-cookie
  return { ok: true as const };
}

把验证码放进 subject 方便锁屏预览——多数产品可以;共享屏幕场景可省略(验证码文档)。务必同时发 html 与 text,并在纯文本里带数字(POST /emails)。

Nitro 路由:server/api/auth/otp/*

// server/api/auth/otp/send.post.ts
import { sendOtp } from "../../../utils/otpService";
import { memoryLikeStore } from "../../../utils/otpStore";

export default defineEventHandler(async (event) => {
  // 同时按 IP 限流(反代 / Cloudflare / Nitro 中间件)。
  const body = await readBody<{ email?: string }>(event).catch(() => ({}));
  const email = String(body?.email ?? "");
  if (!email.includes("@")) {
    throw createError({ statusCode: 400, statusMessage: "invalid_email" });
  }

  const config = useRuntimeConfig(event);
  const result = await sendOtp({
    store: memoryLikeStore(),
    apiKey: config.rovelaApiKey,
    pepper: config.codePepper,
    from: config.otpFrom,
    rawEmail: email,
  });

  if (!result.ok && result.error === "too_soon") {
    throw createError({ statusCode: 429, statusMessage: "too_soon" });
  }
  return { message: result.message ?? "ok" };
});
// server/api/auth/otp/verify.post.ts
import { verifyOtp } from "../../../utils/otpService";
import { memoryLikeStore } from "../../../utils/otpStore";

export default defineEventHandler(async (event) => {
  const body = await readBody<{ email?: string; code?: string }>(event).catch(
    () => ({}),
  );
  const config = useRuntimeConfig(event);
  const result = await verifyOtp({
    store: memoryLikeStore(),
    pepper: config.codePepper,
    rawEmail: String(body?.email ?? ""),
    rawCode: String(body?.code ?? ""),
  });

  if (!result.ok) {
    const status =
      result.error === "locked" || result.error === "too_soon" ? 429 : 400;
    throw createError({ statusCode: status, statusMessage: result.error });
  }
  return { ok: true };
});

在 Vue 页面 / composable 里只调这些同源 API——永远不要在 <script setup> 里直接 $fetch Rovela:

// 客户端调用示例(这里没有 Rovela 密钥)
await $fetch("/api/auth/otp/send", { method: "POST", body: { email } });
await $fetch("/api/auth/otp/verify", {
  method: "POST",
  body: { email, code },
});

Idempotency-Key、html + text、投递

  1. Idempotency-Key 由验证码哈希派生(otp- + 前缀),超时重试不会再入队一封内容不同的信。真正「重发」应换新码,因而换新 key。
  2. 务必同时发 html 与 text;纯文本里带六位数字。
  3. 用与营销信隔离声誉的子域发送(验证码文档)。
  4. 若验证码进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。

限流 / 单次 / 过期清单

  1. 发放限流 — 同一邮箱每分钟一条新码;边缘 / Nitro 中间件同时按 IP 封顶。
  2. 短 TTL — 约 10 分钟(文档示例);二次验证可以更短。
  3. 落库只存哈希 — email:code 的 HMAC-SHA256 + pepper;永不存数字。
  4. Upsert — 新发送作废该邮箱上一条未用哈希。
  5. 尝试上限 — 约 5 次失败后锁定;成功后删除。
  6. 防枚举发送 — 不论是否暴露账号,状态码与正文一致。
  7. Idempotency-Key — 每次逻辑发送稳定;新码 → 新 key。
  8. 共享存储 — 多实例 Nuxt 用 Redis/SQL;Nitro 内存 storage 仅适合单进程演示。
  9. 禁止公开环境变量 — 不要使用 NUXT_PUBLIC_ROVELA_*。

curl 等价(代理 / 冒烟)

与 Nitro handler 发出的报文相同——便于本地冒烟,也方便直接调 HTTP 的 AI agent(AI agent 发邮件):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is</p><p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">482913</p><p>It expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. It expires in 10 minutes."
  }'

生产密钥需具备 sending_access;from 必须落在已验证域名上(Quick Start)。

Nuxt vs Next 魔法链接 vs Hono OTP(避免混淆)

本文(Nuxt OTP)Next.js 魔法登录Hono OTP
技术栈Nuxt 3 + Nitro server/apiNext.js App Router边缘或 Node 上的 Hono
UX 原语六位 OTP魔法链接 token URL六位 OTP
密码学Web Crypto / node:crypto + HMAC高熵 token + SHA-256Web Crypto + HMAC
配置runtimeConfig(仅服务端)服务端环境 / Route Handlersc.env / process env
重点Vue/Nuxt 注册登录验证码无密码链接登录可移植的边缘 OTP 路由

三者都调同一个 POST /emails。不要把 Next 魔法链接 handler 贴进 Nuxt 却叫「OTP」,也不要把 Rovela 当成 Auth.js / nuxt-auth。

同一 Nuxt 应用里:OTP vs 魔法链接

OTP(六位数字)魔法链接
文档形状验证码同 密码重置
Nuxt 位置/api/auth/otp/send + /api/auth/otp/verify独立魔法路由 / 页面
体验输入数字一点即登(+ Continue)
存储email:code 的 HMAC + pepper高熵 token 的 SHA-256
常见 TTL~10 分钟~30 分钟
适合共享屏幕、二次验证、手机桌面/手机浏览器登录

两者都调同一个 POST /emails。存储要分开——不要把 OTP 记录当魔法链接 token 用。产品选型:验证码 vs 链接。Next 风格魔法链接见 Next.js 魔法登录;Remix / SvelteKit 姊妹文见 Remix 与 SvelteKit。

上线清单

  1. 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
  2. 仅具备 sending_access 的 NUXT_ROVELA_API_KEY 放在服务端环境 / runtimeConfig——不要用 NUXT_PUBLIC_*。
  3. NUXT_CODE_PEPPER 足够长且随机;永不提交。
  4. NUXT_OTP_FROM 用已验证子域;html + text 都有;纯文本(以及可选 subject)含数字。
  5. 每次逻辑发送带 Idempotency-Key;存储 upsert + 尝试上限 + 成功删除。
  6. 多实例:用 Redis/SQL——不要只靠天真的 Nitro 内存 storage。
  7. 把发送 200 当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。

本文不是什么

  • 不是完整生产级鉴权框架(OAuth2、MFA、通行密钥、nuxt-auth)。
  • 不是 Express / Hono OTP 文或 Next.js 魔法登录 的重复。
  • 不是声称 Rovela 会存储、校验或过期你的验证码。
  • 不是国内 ESP 对比,也不捏造产品功能。

下一步

  1. 按 验证码示例 实现铸造 + HMAC + 存储。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 选型:验证码 vs 链接。
  5. 姊妹栈:Hono OTP · Express OTP · Next.js 魔法登录 · Workers 事务邮件。
  6. 额度:Pricing。

一句话: 把 OTP 放进 Nuxt Nitro 的 server/api:用 Web Crypto 铸造验证码,HMAC 后写入 Redis 或持久存储,再对 Rovela $fetch 一次 POST /emails——密钥留在 runtimeConfig,也永远不要把邮件 API 当成身份提供方。