← Back to blog

Send OTP Emails in Nuxt (2026) with a Plain HTTP API

Build signup/login OTP in Nuxt 3 with Nitro server routes: Web Crypto, HMAC storage, $fetch POST /emails with Idempotency-Key—Rovela delivers only.

Updated November 4, 2026
Send OTP Emails in Nuxt (2026) with a Plain HTTP API

Why Nuxt 3 + Nitro for email OTP

Nuxt 3 already gives signup and login a server-first shape: Nitro server/api routes, runtimeConfig secrets, and $fetch / fetch that never ship your mail key to the browser. You do not need a Rovela Nuxt module, an “OTP product” endpoint, or a Vue plugin that pretends the email API is an identity provider.

What you need is the recipe in Email verification codes: your Nuxt app mints a six-digit code with Web Crypto (crypto.getRandomValues) or node:crypto on the Nitro Node preset, stores an HMAC of it (never the digits), sets expiry and attempt limits in Redis / KV / SQL, then $fetch / fetch POST /emails with both html and text. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Your Nuxt server routes own cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Nuxt 3 / Nitro / TypeScript focused (server/api, useRuntimeConfig, Nitro storage or Redis). It is not a rehash of Send OTP Emails in Express.js, Send OTP Emails with Hono, or Magic Link Login in Next.js (token URLs, not six digits). Sibling OTP posts: Go, Django, Laravel, Spring Boot. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.

What you own vs what Rovela sends

Your Nuxt / Nitro app owns:

  • CSPRNG six digits, zero-padded (never Math.random())
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) — Web Crypto or node:crypto
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (Redis, Postgres, or Nitro useStorage for single-instance demos)
  • Constant-time compare on redeem; delete / invalidate on success
  • Identical public responses whether or not you want to reveal account existence (product choice)
  • Rate limits on /api/auth/otp/send and /api/auth/otp/verify (Nitro middleware, reverse proxy, or your edge)

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in runtimeConfig / env—never commit it, never put it in client bundles or NUXT_PUBLIC_*)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Env via runtimeConfig (server-only)

Put secrets in .env / host env and map them under runtimeConfig (not 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 (server only — never 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>"

Auth: Authentication · wire format: API: emails · first send: Quick Start. A sending_access key is enough—and preferred—for production OTP senders.

Tiny Rovela client ($fetch / fetch)

Nitro can call Rovela with $fetch (ofetch) or undici fetch. Keep this module under 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 throws on non-2xx; catch if you need status-specific handling
  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,
      },
    },
  );
}

Do not log the API key. Prefer a verified auth subdomain for from (security@mail.…).

Mint + HMAC (Web Crypto, Nitro-friendly)

Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server. Web Crypto works on Nitro Node and many edge presets; on Node-only you may swap in node:crypto (randomInt / createHmac) like the Express OTP twin.

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

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

/** Six digits from CSPRNG — never 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);
}

/** Constant-time hex compare (equal length assumed after hash). */
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;
}

Store: Redis (prod) or Nitro useStorage (demo)

Suggested JSON value (one active code per email), matching the verification-code example:

// 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>;
}

/** Single-instance / local smoke — replace with Redis or SQL in multi-instance prod */
export function memoryLikeStore(): OtpStore {
  const storage = useStorage("otp"); // configure a durable driver in nuxt.config for real deploys
  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);
    },
  };
}

For multi-instance Nuxt (several Node processes, serverless isolates, or edge), use Redis / Upstash / Postgres—not a process-local Map pretending to be shared. Wire your Redis client the same way you would for sessions.

Service: send + verify

// 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,
  );

  // Stable per logical send: hash prefix. New code ⇒ new 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);
  // issue session / JWT / set-cookie here
  return { ok: true as const };
}

Putting the code in the subject helps lock-screen previews—fine for many products, skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).

Nitro routes: 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) => {
  // Also throttle by IP (your reverse proxy / Cloudflare / Nitro middleware).
  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 };
});

From a Vue page / composable, call only these same-origin API routes—never $fetch Rovela from <script setup>:

// Example client call (no Rovela key here)
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, deliverability

  1. Derive Idempotency-Key from the code hash (otp- + prefix) so a timeout retry does not enqueue a second message with a different body. A deliberate resend mints a new code and therefore a new key.
  2. Always send both html and text; put the six digits in the text part.
  3. Use a subdomain with reputation separated from marketing mail (verification-code docs).
  4. If codes land in spam, follow Why OTP and Magic-Link Emails Go to Spam.

Rate limit / single-use / expiry checklist

  1. Issue throttle — one new code per email per minute; also cap by IP at the edge / Nitro middleware.
  2. Short TTL — ~10 minutes (docs example); shorter is fine for step-up.
  3. Hash at rest — HMAC-SHA256 of email:code + pepper; never store digits.
  4. Upsert — new send invalidates the previous unused hash for that email.
  5. Attempt cap — lock after ~5 failures; delete on success.
  6. Enumeration-safe send — same status/body whether or not you reveal accounts.
  7. Idempotency-Key — stable per logical send; new code → new key.
  8. Shared store — Redis/SQL for multi-instance Nuxt; Nitro memory storage only for single process demos.
  9. No public env — never NUXT_PUBLIC_ROVELA_*.

curl equivalent (agents / smoke tests)

Same wire format your Nitro handler posts—useful for local smoke tests and AI agents that call HTTP directly (AI agent send email):

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."
  }'

Production keys need sending_access; from must be on a verified domain (Quick Start).

Nuxt vs Next magic link vs Hono OTP (clarity check)

This article (Nuxt OTP)Next.js magic linkHono OTP
StackNuxt 3 + Nitro server/apiNext.js App RouterHono on edge or Node
UX primitiveSix-digit OTPMagic link token URLSix-digit OTP
CryptoWeb Crypto / node:crypto + HMACHigh-entropy token + SHA-256Web Crypto + HMAC
ConfigruntimeConfig (server-only)Server env / Route Handlersc.env / process env
FocusVue/Nuxt signup-login codesPasswordless link loginPortable edge OTP routes

All three call the same POST /emails. Do not paste Next magic-link handlers into Nuxt and call them “OTP,” and do not treat Rovela as Auth.js / nuxt-auth.

OTP vs magic link in the same Nuxt app

OTP (six digits)Magic link
Docs shapeVerification codesSame as password reset
Nuxt placement/api/auth/otp/send + /api/auth/otp/verifySeparate magic routes / pages
UXType digitsOne click (+ Continue)
StorageHMAC of email:code + pepperSHA-256 of high-entropy token
Typical TTL~10 minutes~30 minutes
Good forShared screens, step-up, mobileDesktop/mobile browser sign-in

Both call the same POST /emails. Keep separate stores—do not reuse an OTP record as a magic-link token. Product choice: code vs link. For Next-style magic links see Magic Link Login in Next.js; for Remix / SvelteKit siblings see Remix and SvelteKit.

Deploy checklist

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. NUXT_ROVELA_API_KEY with sending_access only in server env / runtimeConfig—never NUXT_PUBLIC_*.
  3. NUXT_CODE_PEPPER long and random; never committed.
  4. NUXT_OTP_FROM uses the verified subdomain; html + text both present; digits in text (and optionally subject).
  5. Idempotency-Key on every logical send; store upsert + attempt cap + delete on success.
  6. Multi-instance: Redis/SQL—not naive Nitro memory storage alone.
  7. Treat send 200 as queued; use webhooks for bounces/complaints if you need delivery truth (Webhooks).

What this post is not

  • Not a full production auth framework (OAuth2, MFA, passkeys, nuxt-auth).
  • Not a duplicate of the Express / Hono OTP posts or the Next.js magic link post.
  • Not a claim that Rovela stores, validates, or expires your codes.
  • Not China-domestic ESP comparison or invented product features.

Next steps

  1. Implement mint + HMAC + store from the verification-code example.
  2. Send with POST /emails · Quick Start · Authentication.
  3. Harden deliverability: OTP / magic-link spam.
  4. Compare UX primitives: code vs link.
  5. Sibling stacks: Hono OTP · Express OTP · Next.js magic link · Workers transactional.
  6. Limits: Pricing.

Bottom line: put OTP in Nuxt Nitro server/api routes that mint codes with Web Crypto, HMAC them into Redis or durable storage, and $fetch one POST /emails to Rovela—keep secrets in runtimeConfig, and never pretend the email API is an identity provider.