Send OTP Emails with Hono (2026) via a Plain HTTP API
Build signup/login OTP in Hono on edge or Node: Web Crypto getRandomValues, HMAC in KV or a memory map, fetch POST /emails with Idempotency-Key—Rovela delivers only.
Why Hono + OTP codes on the edge
Hono already gives signup and login a small, portable shape: a few routes that run on Node, Bun, Cloudflare Workers, Deno, or any Fetch-compatible runtime. You do not need a Rovela Hono plugin, an “OTP product” endpoint, or a Worker-only mail binding that pretends the email API is an identity provider.
What you need is the recipe in Email verification codes: your app mints a six-digit code with Web Crypto (crypto.getRandomValues), stores an HMAC of it (never the digits), sets expiry and attempt limits in KV or an in-memory map, then 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 Hono app owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.
This article is Hono / TypeScript focused (edge-safe Web Crypto, KV or memory map, c.env secrets). It is not a rehash of Send OTP Emails in Express.js (Node crypto.randomInt + SQL), and it is not Send transactional email from Cloudflare Workers (generic edge send without the OTP store). 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 Hono app owns:
crypto.getRandomValues→ six digits, zero-padded (neverMath.random())- HMAC-SHA256 of
email + ":" + codewith a server pepper (CODE_PEPPER) via Web 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 (Workers KV, Upstash Redis, Durable Object, or a process-local
Mapfor single-instance Node/Bun) - 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
/auth/otp/sendand/auth/otp/verify(middleware or your edge rate product)
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin env / Worker secrets—never commit it, never put it in client bundles)- 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 (fetch)
On Cloudflare Workers / Pages, put secrets in wrangler secret / dashboard bindings. On Node or Bun, use process env the same way as any other server secret.
// src/env.ts
export type AppEnv = {
Bindings: {
ROVELA_API_KEY: string; // sending_access
CODE_PEPPER: string; // long random secret
OTP_FROM?: string;
OTP_KV?: KVNamespace; // optional Workers KV
};
Variables: {
// optional request-scoped fields
};
};
// src/rovela.ts
export async function sendEmail(
apiKey: string,
from: string,
input: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
},
) {
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": input.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
});
if (!res.ok) {
// Do not log the API key.
const errText = await res.text();
throw new Error(`Rovela send failed: ${res.status} ${errText}`);
}
return (await res.json()) as { object: string; id: string };
}
Auth: Authentication · wire format: API: emails · first send: Quick Start. A sending_access key is enough—and preferred—for production OTP senders.
Web Crypto: getRandomValues + HMAC (edge-safe)
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. Prefer Web Crypto so the same module runs on Workers, Deno, Bun, and Node 18+.
// src/otp/crypto.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;
}
On Node-only deployments you may use node:crypto (randomInt / createHmac) instead—see the Express OTP twin. Keep one style per deploy target so Workers builds do not pull Node builtins.
Store: Workers KV or a process-local Map
Suggested JSON value (one active code per email), matching the verification-code example:
// src/otp/store.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 Node/Bun / local smoke tests */
export class MemoryOtpStore implements OtpStore {
private map = new Map<string, CodeRecord>();
async get(email: string) {
return this.map.get(email) ?? null;
}
async put(email: string, record: CodeRecord, _ttl: number) {
this.map.set(email, record);
}
async del(email: string) {
this.map.delete(email);
}
}
/** Cloudflare Workers KV — key = normalized email */
export class KvOtpStore implements OtpStore {
constructor(private kv: KVNamespace) {}
async get(email: string) {
return (await this.kv.get(email, "json")) as CodeRecord | null;
}
async put(email: string, record: CodeRecord, ttlSeconds: number) {
await this.kv.put(email, JSON.stringify(record), {
expirationTtl: Math.max(60, ttlSeconds),
});
}
async del(email: string) {
await this.kv.delete(email);
}
}
For multi-isolate Workers, prefer KV (or Redis / Durable Objects). A process-local Map is fine for a single Node/Bun process and for demos—do not pretend it is shared across edge isolates.
Service: send + verify
// src/otp/service.ts
import { sendEmail } from "../rovela";
import {
hashCode,
mintCode,
normalizeEmail,
safeEqualHex,
} from "./crypto";
import type { OtpStore } from "./store";
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(opts.apiKey, 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 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).
Hono routes
// src/index.ts
import { Hono } from "hono";
import type { AppEnv } from "./env";
import { sendOtp, verifyOtp } from "./otp/service";
import { KvOtpStore, MemoryOtpStore } from "./otp/store";
const app = new Hono<AppEnv>();
function storeFor(c: { env: AppEnv["Bindings"] }) {
if (c.env.OTP_KV) return new KvOtpStore(c.env.OTP_KV);
// Fallback for local `hono` on Node/Bun — replace in multi-instance prod
return new MemoryOtpStore();
}
app.post("/auth/otp/send", async (c) => {
// Also throttle by IP (Cloudflare rate limiting / your middleware).
const body = await c.req.json<{ email?: string }>().catch(() => ({}));
const email = String(body.email ?? "");
if (!email.includes("@")) {
return c.json({ error: "invalid_email" }, 400);
}
const result = await sendOtp({
store: storeFor(c),
apiKey: c.env.ROVELA_API_KEY,
pepper: c.env.CODE_PEPPER,
from: c.env.OTP_FROM ?? "Acme <security@mail.acme.com>",
rawEmail: email,
});
if (!result.ok && result.error === "too_soon") {
return c.json({ error: "too_soon" }, 429);
}
return c.json({ message: result.message ?? "ok" });
});
app.post("/auth/otp/verify", async (c) => {
const body = await c.req
.json<{ email?: string; code?: string }>()
.catch(() => ({}));
const result = await verifyOtp({
store: storeFor(c),
pepper: c.env.CODE_PEPPER,
rawEmail: String(body.email ?? ""),
rawCode: String(body.code ?? ""),
});
if (!result.ok) {
const status =
result.error === "locked" || result.error === "too_soon" ? 429 : 400;
return c.json({ error: result.error }, status);
}
return c.json({ ok: true });
});
export default app;
Bind ROVELA_API_KEY, CODE_PEPPER, and optionally OTP_KV in your runtime. The Rovela key never leaves the worker / server process. Prefer a verified auth subdomain for from (security@mail.…).
Idempotency-Key, html + text, deliverability
- Derive
Idempotency-Keyfrom 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. - Always send both
htmlandtext; put the six digits in the text part. - Use a subdomain with reputation separated from marketing mail (verification-code docs).
- If codes land in spam, follow Why OTP and Magic-Link Emails Go to Spam.
Rate limit / single-use / expiry checklist
- Issue throttle — one new code per email per minute; also cap by IP at the edge.
- Short TTL — ~10 minutes (docs example); shorter is fine for step-up.
- Hash at rest — HMAC-SHA256 of
email:code+ pepper; never store digits. - Upsert — new send invalidates the previous unused hash for that email.
- Attempt cap — lock after ~5 failures; delete on success.
- Enumeration-safe send — same status/body whether or not you reveal accounts.
- Idempotency-Key — stable per logical send; new code → new key.
- Edge store — KV / Redis for multi-isolate; memory map only for single process.
curl equivalent (agents / smoke tests)
Same wire format your Hono 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).
Hono vs Express vs Workers (clarity check)
| This article (Hono OTP) | Express OTP | Workers transactional | |
|---|---|---|---|
| Runtime | Edge or Node/Bun via Hono | Node Express | Cloudflare Worker fetch |
| Crypto | Web Crypto getRandomValues + HMAC | node:crypto randomInt + HMAC | N/A (generic send) |
| Store | KV / memory map (edge-friendly) | SQL / your ORM | Optional—article is not OTP |
| Focus | Signup/login OTP in Hono | Signup/login OTP in Express | Any transactional send from the edge |
All three call the same POST /emails. Do not confuse Hono OTP routes with Cloudflare Email Workers, and do not treat the Workers transactional post as a substitute for hashed OTP storage.
OTP vs magic link in the same Hono app
| OTP (six digits) | Magic link | |
|---|---|---|
| Docs shape | Verification codes | Same as password reset |
| Hono placement | /auth/otp/send + /auth/otp/verify | Separate magic routes |
| UX | Type digits | One click (+ Continue) |
| Storage | HMAC of email:code + pepper | SHA-256 of high-entropy token |
| Typical TTL | ~10 minutes | ~30 minutes |
| Good for | Shared screens, step-up, mobile | Desktop/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 Remix-style magic links see Magic Link Login in Remix.
Deploy checklist
- Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
ROVELA_API_KEYwithsending_accessonly in Worker secrets / server env—never in public client config.CODE_PEPPERlong and random; never committed.OTP_FROMuses the verified subdomain; html + text both present; digits in text (and optionally subject).Idempotency-Keyon every logical send; store upsert + attempt cap + delete on success.- Multi-isolate: KV/Redis—not a naive global
Map. - Treat send
200as 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).
- Not a duplicate of the Express OTP or Cloudflare Workers transactional posts.
- Not a claim that Rovela stores, validates, or expires your codes.
- Not China-domestic ESP comparison or invented product features.
Next steps
- Implement mint + HMAC + store from the verification-code example.
- Send with POST /emails · Quick Start · Authentication.
- Harden deliverability: OTP / magic-link spam.
- Compare UX primitives: code vs link.
- Sibling stacks: Express OTP · Go OTP · Workers transactional · Remix magic link.
- Limits: Pricing.
Bottom line: put OTP in Hono routes that mint codes with Web Crypto, HMAC them into KV or a memory map, and fetch one POST /emails to Rovela—keep secrets on the server/edge, and never pretend the email API is an identity provider.