Send OTP Emails in Express.js (2026) with a Plain HTTP API
Build signup/login OTP in Express: crypto.randomInt, HMAC-hashed codes, expiry, rate limits, fetch to POST /emails, and Idempotency-Key—Rovela delivers only.
Why Express + OTP codes (not a magic-link SDK)
Express already gives signup and login a boring, reliable shape: a few routes, middleware for rate limits, and a server-side secret store. You do not need a Rovela Node SDK, an “OTP product” endpoint, or a Passport strategy that pretends the email API is an identity provider.
What you need is the recipe in Email verification codes: your app mint a six-digit code with crypto.randomInt, store an HMAC of it (never the digits), set expiry and attempt limits, then 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. Express owns cryptography, the database row, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.
This article is Express.js focused (CommonJS or ESM, TypeScript-friendly): route handlers for “send code” and “verify code,” a thin Rovela client with fetch, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. It is not a magic-link walkthrough—for those see Next.js, NestJS, and FastAPI. For product choice between digits and URLs, see Email verification: code vs link. The language-agnostic OTP guide is Sending OTP codes by email; the Node transport overview is Sending email in Node.js—this post does not rehash generic https/axios sending.
What you own vs what Rovela sends
Your Express app owns:
crypto.randomInt(0, 1_000_000)→ six digits, zero-padded (neverMath.random())- HMAC-SHA256 of
email + ":" + codewith a server pepper (CODE_PEPPER) - 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
- Constant-time compare on redeem; delete the row on success
- Identical public responses whether or not you want to reveal account existence (product choice)
- Rate limits on
/auth/otp/sendand/auth/otp/verify
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin env—never commit it, never ship it to the browser)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Env and a tiny Rovela client
// src/config.ts
export const config = {
rovelaApiKey: process.env.ROVELA_API_KEY!, // sending_access
codePepper: process.env.CODE_PEPPER!, // long random secret
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 }>;
}
Wire ROVELA_API_KEY and CODE_PEPPER the same way you wire any other server secret. A sending_access key is enough—and preferred—for production OTP senders (Authentication, Quick Start).
Hash at rest: randomInt + HMAC, not bare SHA-256
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.
// 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 {
// OS CSPRNG — Math.random() is not for secrets
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);
}
Suggested row shape (one active code per email), matching the verification-code example:
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()
);
Service: send + verify
Sketch against any store (pg, Prisma, Drizzle). Replace the SQL helpers with your own.
// src/otp/service.ts
import crypto from "node:crypto";
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 =
"If that address can receive mail, a code is on its way.";
export async function sendOtp(rawEmail: string) {
const email = normalizeEmail(rawEmail);
// One code a minute per address — protects inboxes and your send quota
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()]
);
// Stable per logical send: hash prefix. New code ⇒ new 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]);
// issue session / JWT 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).
Express routes + a rate-limit sketch
// 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, // per IP — tighten for your threat model
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" });
}
// Prefer a uniform 200 body in production if you hide account existence
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 });
});
Mount with app.use(otpRouter) behind express.json(). Per-email cooldown inside the service and per-IP express-rate-limit solve different problems—use both. Hardening checklist: Email OTP security.
Idempotency-Key: retries without double codes
Workers, proxies, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.
- Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
- Derive the key from the code hash (
otp-+ first 32 hex chars) so one logical mint maps to one queued message. - A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
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."
}'
Deliverability notes for auth digits (not magic URLs)
OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.
If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.
OTP codes vs magic links in Express
| Six-digit OTP (this post) | Magic link | |
|---|---|---|
| User action | Type digits | Click URL |
| Secret entropy | Low → HMAC + pepper required | High URL token → SHA-256 of token is usually enough |
| Docs shape | Verification codes | Password reset / link pattern |
| Express surface | POST /auth/otp/send + POST /auth/otp/verify | POST /auth/magic/request + GET /auth/magic?token= |
| Click tracking | Optional concern | Prefer off on the security sending domain |
Both call the same POST /emails. Keep separate tables and services—do not reuse an OTP row as a magic-link token.
Checklist before you ship
crypto.randomInt+ HMAC pepper; never store raw codes.- ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limits.
- Upsert invalidates the previous code; verify deletes on success.
htmlandtext; optional subject code for previews.ROVELA_API_KEY(sending_access) only on the server.Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- Verified from-domain; spam checklist if inbox placement slips.
Related reading
- Docs: Email verification codes · POST /emails · Quick Start · Authentication.
- Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
- UX choice: Email verification: code vs link.
- Deliverability: Why OTP and magic-link emails go to spam.
- Magic-link siblings (different primitive): Next.js · NestJS · FastAPI.
- Node transport only: Sending email in Node.js.
Bottom line: put signup/login OTP in Express routes that mint randomInt codes, HMAC them at rest, rate-limit send/verify, and call one fetch POST to Rovela with an Idempotency-Key—and never pretend the email API is your auth server.