← Back to blog

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.

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

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 runssrc/routes/api/auth/otp/* or "use server" actionExpress / Hono / Nest / cloud function
SecretsServer env (ROVELA_API_KEY, CODE_PEPPER)Same — never VITE_* public
ClientSolid components call same-origin /api/... or actionSolid app calls your BFF over HTTPS
Rovela rolePOST /emails delivery onlySame

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 (never Math.random())
  • HMAC-SHA256 of email + ":" + code with 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 Map across 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/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in server env—never commit it, never put it in VITE_* / 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

  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.
  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 SolidStart; memory only for single-process demos.
  9. 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 OTPSvelteKit magic link
StackSolidStart API routes / "use server"Nuxt 3 + Nitro server/apiSvelteKit form actions
UX primitiveSix-digit OTPSix-digit OTPMagic link token URL
Cryptonode:crypto randomInt + HMACWeb Crypto / node:crypto + HMACHigh-entropy token + SHA-256
ConfigServer process.envruntimeConfig (server-only)$env/dynamic/private
FocusSolid fine-grained UI + server BFFVue/Nuxt signup-login codesPasswordless 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 shapeVerification codesSame idea as password reset
SolidStart 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. ROVELA_API_KEY with sending_access only in server env—never VITE_*.
  3. CODE_PEPPER long and random; never committed.
  4. 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 a naive in-memory Map alone.
  7. Treat send 200 as 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

  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: Nuxt OTP · Hono OTP · Express OTP · SvelteKit magic link.
  6. 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.