Send OTP Emails in SolidStart / SolidJS (2026) with a Plain HTTP API
Build signup/login OTP in SolidStart with API routes or "use server" actions: crypto.randomInt + HMAC, Idempotency-Key POST /emails—never put sending_access in the browser; Rovela delivers only.
Why SolidStart for email OTP
SolidStart already gives signup and login a server-first shape: file-based API routes under src/routes/api, "use server" actions from @solidjs/router, and env that never ships your mail key into the browser bundle. You do not need a Rovela Solid plugin, an “OTP product” endpoint, or a client-side SDK that pretends the email API is an identity provider.
What you need is the recipe in Email verification codes: your SolidStart (or SolidJS SPA + BFF) server mints a six-digit code with crypto.randomInt, stores an HMAC of it (never the digits), sets expiry and attempt limits in Redis / SQL / KV, then fetches 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 SolidStart server functions / API routes own cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.
This article is SolidStart + SolidJS focused (APIEvent routes, "use server" actions, Node crypto). It is not a rehash of Send OTP Emails in Nuxt, Magic Link Login in SvelteKit, Remix magic link, or Express OTP. Sibling OTP posts: Hono, Go, Django. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.
SolidStart vs a pure SolidJS SPA
| SolidStart (this post) | SolidJS SPA + separate BFF | |
|---|---|---|
| Where OTP runs | src/routes/api/auth/otp/* or "use server" action | Express / Hono / Nest / cloud function |
| Secrets | Server env (ROVELA_API_KEY, CODE_PEPPER) | Same — never VITE_* public |
| Client | Solid components call same-origin /api/... or action | Solid app calls your BFF over HTTPS |
| Rovela role | POST /emails delivery only | Same |
A client-only SolidJS SPA must not call api.rovela.dev from the browser. Put mint + send behind a BFF the same way Expo / React Native posts do. SolidStart just co-locates that BFF with the UI.
What you own vs what Rovela sends
Your SolidStart / BFF 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 (Redis, Postgres, or durable KV—not a process-local
Mapacross replicas) - 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 send and verify (middleware, reverse proxy, or your edge)
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin server env—never commit it, never put it inVITE_*/ client bundles)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Env (server-only)
# .env (server only — never VITE_ROVELA_* / import.meta.env.VITE_*)
ROVELA_API_KEY=re_your_sending_key
CODE_PEPPER=long-random-server-secret
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 (server module)
Keep this under a server-only path (src/lib/server/ or imported only from API routes / "use server" modules):
// src/lib/server/rovela.ts
export async function sendEmail(input: {
apiKey: string;
from: string;
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const res = await fetch("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: JSON.stringify({
from: input.from,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
`Rovela ${res.status}: ${(body as { message?: string }).message ?? "send failed"}`,
);
}
return body as { object: string; id: string };
}
Do not log the API key. Prefer a verified auth subdomain for from (security@mail.…).
Mint + HMAC (node:crypto)
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/lib/server/otpCrypto.ts
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
export function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
/** Six digits from CSPRNG — never Math.random() */
export function mintCode(): string {
return randomInt(0, 1_000_000).toString().padStart(6, "0");
}
export function hashCode(pepper: string, email: string, code: string) {
return createHmac("sha256", pepper)
.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 timingSafeEqual(ba, bb);
}
On edge runtimes without node:crypto, swap in Web Crypto (crypto.getRandomValues + crypto.subtle HMAC) the same way the Nuxt OTP and Hono OTP posts do—keep the pepper server-only either way.
Store sketch (Redis / SQL)
Suggested shape (one active code per email), matching the verification-code example:
// src/lib/server/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>;
}
For multi-instance SolidStart (several Node processes, serverless isolates), use Redis / Upstash / Postgres—not a process-local Map.
Service: send + verify
// src/lib/server/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 = 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 = 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).
Option A: SolidStart API routes
// src/routes/api/auth/otp/send.ts
import type { APIEvent } from "@solidjs/start/server";
import { sendOtp } from "~/lib/server/otpService";
import { getStore } from "~/lib/server/otpStoreRedis"; // your Redis/SQL adapter
export async function POST(event: APIEvent) {
// Also throttle by IP (reverse proxy / Cloudflare / middleware).
const body = (await event.request.json().catch(() => ({}))) as {
email?: string;
};
const email = String(body?.email ?? "");
if (!email.includes("@")) {
return Response.json({ error: "invalid_email" }, { status: 400 });
}
const result = await sendOtp({
store: getStore(),
apiKey: process.env.ROVELA_API_KEY!,
pepper: process.env.CODE_PEPPER!,
from: process.env.OTP_FROM!,
rawEmail: email,
});
if (!result.ok && result.error === "too_soon") {
return Response.json({ error: "too_soon" }, { status: 429 });
}
return Response.json({ message: result.message ?? "ok" });
}
// src/routes/api/auth/otp/verify.ts
import type { APIEvent } from "@solidjs/start/server";
import { verifyOtp } from "~/lib/server/otpService";
import { getStore } from "~/lib/server/otpStoreRedis";
export async function POST(event: APIEvent) {
const body = (await event.request.json().catch(() => ({}))) as {
email?: string;
code?: string;
};
const result = await verifyOtp({
store: getStore(),
pepper: process.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 Response.json({ error: result.error }, { status });
}
return Response.json({ ok: true });
}
From a Solid component, call only these same-origin API routes—never fetch Rovela from the browser:
await fetch("/api/auth/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
await fetch("/api/auth/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
});
Option B: "use server" action
When the UI is a form inside SolidStart, an action with "use server" keeps the key off the client without a separate REST path:
// src/lib/server/otpActions.ts
import { action } from "@solidjs/router";
import { sendOtp, verifyOtp } from "./otpService";
import { getStore } from "./otpStoreRedis";
export const sendOtpAction = action(async (formData: FormData) => {
"use server";
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { ok: false as const, error: "invalid_email" };
return sendOtp({
store: getStore(),
apiKey: process.env.ROVELA_API_KEY!,
pepper: process.env.CODE_PEPPER!,
from: process.env.OTP_FROM!,
rawEmail: email,
});
}, "send-otp");
export const verifyOtpAction = action(async (formData: FormData) => {
"use server";
return verifyOtp({
store: getStore(),
pepper: process.env.CODE_PEPPER!,
rawEmail: String(formData.get("email") ?? ""),
rawCode: String(formData.get("code") ?? ""),
});
}, "verify-otp");
Prefer API routes when mobile apps, agents, or other services share the same send/verify contract. Prefer actions when only your SolidStart UI posts FormData. Either way: sending_access never appears in client JS.
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.
- Shared store — Redis/SQL for multi-instance SolidStart; memory only for single-process demos.
- No public env — never
VITE_ROVELA_*/ client-exposed secrets.
curl equivalent (agents / smoke tests)
Same wire format your SolidStart 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).
SolidStart vs Nuxt OTP vs SvelteKit magic link
| This article (SolidStart OTP) | Nuxt OTP | SvelteKit magic link | |
|---|---|---|---|
| Stack | SolidStart API routes / "use server" | Nuxt 3 + Nitro server/api | SvelteKit form actions |
| UX primitive | Six-digit OTP | Six-digit OTP | Magic link token URL |
| Crypto | node:crypto randomInt + HMAC | Web Crypto / node:crypto + HMAC | High-entropy token + SHA-256 |
| Config | Server process.env | runtimeConfig (server-only) | $env/dynamic/private |
| Focus | Solid fine-grained UI + server BFF | Vue/Nuxt signup-login codes | Passwordless link login |
All three call the same POST /emails. Do not paste Next/SvelteKit magic-link handlers into SolidStart and call them “OTP,” and do not treat Rovela as Auth.js / solid-auth.
OTP vs magic link in the same SolidStart app
| OTP (six digits) | Magic link | |
|---|---|---|
| Docs shape | Verification codes | Same idea as password reset |
| SolidStart placement | /api/auth/otp/send + /api/auth/otp/verify | Separate magic routes / pages |
| 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 Next-style magic links see Magic Link Login in Next.js; for Remix / SvelteKit siblings see Remix and SvelteKit.
Deploy checklist
- Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
ROVELA_API_KEYwithsending_accessonly in server env—neverVITE_*.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-instance: Redis/SQL—not a naive in-memory Map alone.
- Treat send
200as queued; use webhooks for bounces/complaints if you need delivery truth.
What this post is not
- Not a full production auth framework (OAuth2, MFA, passkeys, solid-auth).
- Not a duplicate of the Nuxt / Express / Hono OTP posts or the SvelteKit / Remix magic link 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: Nuxt OTP · Hono OTP · Express OTP · SvelteKit magic link.
- Limits: Pricing.
Bottom line: put OTP in SolidStart API routes or "use server" actions that mint codes with crypto.randomInt, HMAC them into Redis or durable storage, and fetch one POST /emails to Rovela—keep sending_access on the server, and never pretend the email API is an identity provider.