Send OTP Emails from Expo Managed Apps (2026) via a Backend HTTP API
Build Expo managed-workflow signup OTP with Expo Router + Express or API routes: crypto.randomInt + HMAC, EAS secrets, Idempotency-Key POST /emails—never put sending_access in the client; Rovela delivers only.
Why Expo managed apps still need a server for email OTP
Expo’s managed workflow is ideal for signup and login UI: Expo Router screens, TextInput, number-pad keyboards, expo-secure-store, and OTA updates without ejecting. It is a poor place to hold a Rovela sending_access key. Anything that lands in the JS bundle—EXPO_PUBLIC_*, app.config.js extra, EAS Update payloads, or a “secret” remote config—can be extracted from the IPA/APK or a cached update.
What you need is the recipe in Email verification codes: a server you control mints a six-digit code with crypto.randomInt, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POSTs https://api.rovela.dev/emails with both html and text and a stable Idempotency-Key. The Expo client only calls your /auth/otp/send and /auth/otp/verify over HTTPS. 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 backend owns cryptography, the store entry, resend throttling, redeem logic, and the session / JWT. Rovela only delivers the mail.
This article is Expo managed-workflow focused: Expo Router UI + a Node BFF that can be a separate Express service or an Expo Router API route / server function you deploy with EAS secrets. It is not the general React Native OTP post (RN/Expo UI with a generic Express BFF), not Flutter OTP, and not the web-only Express OTP walkthrough. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.
Managed vs bare: same rule for the sending key
| Expo managed (this post) | Bare React Native | |
|---|---|---|
| Client packaging | Metro + EAS Build / Update | Metro + native project |
| Where secrets break | EXPO_PUBLIC_*, extra, OTA updates | Same JS extraction risk |
| Typical BFF | Express or Expo API route + EAS secrets | Express / Nest / cloud function |
| OTP ownership | Still your server | Still your server |
| Rovela role | POST /emails delivery only | Same |
Managed does not mean “call Rovela from the phone.” It means you keep the native toolchain simpler while the auth secrets stay on a host that never ships to devices.
What you own vs what Rovela sends
Your BFF (Express, Fastify, Nest, or Expo API route) 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, SQL, or durable KV—not an in-memory
Mapacross EAS replicas) - Constant-time compare on redeem; delete / invalidate on success
- Rate limits on
/auth/otp/sendand/auth/otp/verify - Issuing the session cookie / JWT after a successful verify
Your Expo managed app owns:
- Collecting email + code, calling your BFF with
fetch, storing the session (expo-secure-store) - UX: resend cooldown timer, error copy, offline / airplane-mode handling
- Optional
textContentType="oneTimeCode"/autoComplete="sms-otp"hints (email OTP still arrives in Mail, not as SMS Retriever)
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (server / EAS secret only—never in the client bundle, never inEXPO_PUBLIC_*)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Architecture: Expo UI → your API → Rovela
Expo managed app (Expo Router UI)
│ HTTPS JSON (no Rovela key)
▼
BFF: Express OR Expo Router API route
(EAS secret: ROVELA_API_KEY + CODE_PEPPER)
│ POST /emails + Idempotency-Key
▼
api.rovela.dev → mailbox
Prefer a thin Node BFF when the rest of your stack is TypeScript. An Expo API route is fine only if it runs on a server runtime with EAS/environment secrets that never become EXPO_PUBLIC_*. Do not call Rovela from fetch inside a client component or a shared module that Metro bundles for the device.
Env: EAS secrets vs EXPO_PUBLIC_
# Server / EAS secrets only — never EXPO_PUBLIC_
# eas secret:create --name ROVELA_API_KEY --value re_... --type string
export ROVELA_API_KEY="re_..." # sending_access
export CODE_PEPPER="long-random-pepper"
export OTP_FROM="Acme <security@mail.acme.com>"
export OTP_TTL_MINUTES=10
export OTP_RESEND_COOLDOWN_SEC=60
export OTP_MAX_ATTEMPTS=5
# Client-safe (ships in the bundle)
EXPO_PUBLIC_API_BASE=https://api.yourapp.com
# WRONG: EXPO_PUBLIC_ROVELA_API_KEY=...
Wire keys like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders. Docs are explicit: never put an API key in a mobile app.
BFF option A: Express (same crypto as the RN sibling)
// server/src/otp.ts
import crypto from "node:crypto";
import express from "express";
const PEPPER = process.env.CODE_PEPPER!;
const API_KEY = process.env.ROVELA_API_KEY!; // sending_access
const FROM = process.env.OTP_FROM ?? "Acme <security@mail.acme.com>";
const TTL_MS = Number(process.env.OTP_TTL_MINUTES ?? 10) * 60_000;
const COOLDOWN_MS = Number(process.env.OTP_RESEND_COOLDOWN_SEC ?? 60) * 1000;
const MAX_ATTEMPTS = Number(process.env.OTP_MAX_ATTEMPTS ?? 5);
type Rec = { codeHash: string; expiresAt: number; sentAt: number; attempts: number };
const store = new Map<string, Rec>(); // demo only — use Redis/SQL for multi-instance / EAS
function normalizeEmail(raw: string) {
return raw.trim().toLowerCase();
}
function mintCode() {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
function hashCode(email: string, code: string) {
return crypto.createHmac("sha256", PEPPER).update(`${email}:${code}`).digest("hex");
}
async function sendOtpEmail(opts: {
to: string;
code: string;
idempotencyKey: string;
ttlMinutes: number;
}) {
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": opts.idempotencyKey,
},
body: JSON.stringify({
from: FROM,
to: opts.to,
subject: `Your Acme code: ${opts.code}`,
html:
`<p>Your verification code is</p>` +
`<p style="font-size:28px;font-weight:600;letter-spacing:4px">${opts.code}</p>` +
`<p>It expires in ${opts.ttlMinutes} minutes. If you did not ask for it, ignore this email.</p>`,
text:
`Your verification code is ${opts.code}. It expires in ${opts.ttlMinutes} minutes. ` +
`If you did not ask for it, ignore this email.`,
}),
});
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 }>;
}
export const otpRouter = express.Router();
otpRouter.post("/auth/otp/send", express.json(), async (req, res) => {
const email = normalizeEmail(String(req.body?.email ?? ""));
if (!email.includes("@")) return res.status(400).json({ error: "invalid_email" });
const now = Date.now();
const existing = store.get(email);
if (existing && now - existing.sentAt < COOLDOWN_MS) {
return res.status(429).json({ error: "too_soon" });
}
const code = mintCode();
const codeHash = hashCode(email, code);
store.set(email, {
codeHash,
expiresAt: now + TTL_MS,
sentAt: now,
attempts: 0,
});
const idem = `otp-${codeHash.slice(0, 32)}`;
await sendOtpEmail({
to: email,
code,
idempotencyKey: idem,
ttlMinutes: Math.round(TTL_MS / 60_000),
});
return res.json({
message: "If that address can receive mail, a code is on its way.",
});
});
otpRouter.post("/auth/otp/verify", express.json(), async (req, res) => {
const email = normalizeEmail(String(req.body?.email ?? ""));
const code = String(req.body?.code ?? "").trim();
if (!/^\d{6}$/.test(code)) return res.status(400).json({ error: "invalid" });
const rec = store.get(email);
const now = Date.now();
if (!rec) return res.status(400).json({ error: "invalid" });
if (now > rec.expiresAt) {
store.delete(email);
return res.status(400).json({ error: "expired" });
}
if (rec.attempts >= MAX_ATTEMPTS) {
return res.status(429).json({ error: "locked" });
}
const expected = Buffer.from(rec.codeHash, "hex");
const actual = Buffer.from(hashCode(email, code), "hex");
if (
expected.length !== actual.length ||
!crypto.timingSafeEqual(expected, actual)
) {
rec.attempts += 1;
return res.status(400).json({ error: "invalid" });
}
store.delete(email);
return res.json({ ok: true, token: "replace-with-jwt" });
});
Suggested SQL 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()
);
BFF option B: Expo Router API route (server-only)
If you colocate auth with the Expo project, keep Rovela calls in a server route module and inject secrets from the host—not from EXPO_PUBLIC_*:
// app/api/auth/otp/send+api.ts (sketch — server runtime only)
import crypto from "node:crypto";
const PEPPER = process.env.CODE_PEPPER!;
const API_KEY = process.env.ROVELA_API_KEY!; // EAS secret / server env
const FROM = process.env.OTP_FROM ?? "Acme <security@mail.acme.com>";
function mintCode() {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");
}
function hashCode(email: string, code: string) {
return crypto.createHmac("sha256", PEPPER).update(`${email}:${code}`).digest("hex");
}
export async function POST(request: Request) {
const body = await request.json();
const email = String(body?.email ?? "").trim().toLowerCase();
if (!email.includes("@")) {
return Response.json({ error: "invalid_email" }, { status: 400 });
}
const code = mintCode();
const codeHash = hashCode(email, code);
// await upsertVerificationRow({ email, codeHash, ttlMinutes: 10 })
const idem = `otp-${codeHash.slice(0, 32)}`;
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idem,
},
body: JSON.stringify({
from: 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 10 minutes. If you did not ask for it, ignore this email.</p>`,
text: `Your verification code is ${code}. It expires in 10 minutes.`,
}),
});
if (!res.ok) {
const errText = await res.text();
return Response.json({ error: "send_failed", detail: errText }, { status: 502 });
}
return Response.json({
message: "If that address can receive mail, a code is on its way.",
});
}
Treat file naming and export shape as framework-specific; the invariant is: Metro must never see ROVELA_API_KEY. Prefer a separate Express/Fly/Railway service if your Expo API hosting model is unclear about secret isolation.
Putting the code in the subject helps lock-screen previews—fine for many products; skip it on highly shared screens. Always send both html and text with the digits in the text part (POST /emails).
Expo client: Expo Router screen calling your BFF only
// src/api/otp.ts — NO Rovela key here
const BASE = process.env.EXPO_PUBLIC_API_BASE!; // your BFF origin
export async function sendCode(email: string) {
const res = await fetch(`${BASE}/auth/otp/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
});
if (res.status === 429) throw new Error("too_soon");
if (!res.ok) throw new Error("send_failed");
}
export async function verifyCode(email: string, code: string) {
const res = await fetch(`${BASE}/auth/otp/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), code: code.trim() }),
});
if (!res.ok) throw new Error("invalid_or_locked");
const data = (await res.json()) as { token: string };
return data.token;
}
// app/(auth)/otp.tsx — Expo Router screen (sketch)
import { useState } from "react";
import {
View,
TextInput,
Button,
Text,
ActivityIndicator,
Platform,
} from "react-native";
import * as SecureStore from "expo-secure-store";
import { sendCode, verifyCode } from "../../src/api/otp";
export default function OtpScreen() {
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<0 | 1>(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSend() {
setBusy(true);
setError(null);
try {
await sendCode(email);
setStep(1);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
async function onVerify() {
setBusy(true);
setError(null);
try {
const token = await verifyCode(email, code);
await SecureStore.setItemAsync("session", token);
// router.replace("/(app)")
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
return (
<View style={{ padding: 24, gap: 12 }}>
{step === 0 ? (
<>
<TextInput
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
textContentType="emailAddress"
placeholder="Email"
/>
{busy ? <ActivityIndicator /> : <Button title="Send code" onPress={onSend} />}
</>
) : (
<>
<TextInput
value={code}
onChangeText={setCode}
keyboardType="number-pad"
maxLength={6}
autoComplete={Platform.OS === "android" ? "sms-otp" : "one-time-code"}
textContentType="oneTimeCode"
placeholder="6-digit code"
/>
{busy ? (
<ActivityIndicator />
) : (
<>
<Button title="Verify" onPress={onVerify} />
<Button title="Resend code" onPress={onSend} />
</>
)}
</>
)}
{error ? <Text style={{ color: "crimson" }}>{error}</Text> : null}
</View>
);
}
Use textContentType="oneTimeCode" (iOS) and autoComplete="sms-otp" / "one-time-code" so the OS can suggest codes from notifications when available. Treat SMS Autofill libraries as optional—email OTP arrives in the Mail app. Keep EXPO_PUBLIC_API_BASE as your BFF origin only.
Idempotency-Key: retries without double codes
EAS deploys, container restarts, and flaky mobile networks all 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.
- The Expo client does not set this header on Rovela; your BFF does when it calls
POST /emails.
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-expo-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."
}'
Why not call Rovela from the Expo app
BFF → POST /emails (this post) | Expo client → Rovela directly | |
|---|---|---|
sending_access key | EAS / server secret manager | Extractable from JS bundle / IPA / APK / OTA |
| OTP mint + HMAC store | Server | Hard to share across devices safely |
| Rate limits | Per IP + per email on your edge | Attacker uses your key as a free relay |
| Idempotency | Stable key per mint on server | Easy to get wrong on flaky mobile retries |
| Session issue | After verify on server | Still need a backend for a real session |
EXPO_PUBLIC_* and EAS Update make “hide the key in the client” especially fragile in managed apps. The sibling React Native OTP post covers the same boundary with a generic RN + Express angle; this post stresses managed workflow, Expo Router, and EAS secrets.
Deliverability notes for auth digits
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 Expo
| Six-digit OTP (this post) | Magic link | |
|---|---|---|
| User action | Type digits (good on mobile keyboards) | Open URL (deep link / universal link) |
| Secret entropy | Low → HMAC + pepper required | High URL token → SHA-256 of token is usually enough |
| Docs shape | Verification codes | Password reset / link pattern |
| Expo surface | Expo Router email + PIN → your BFF | Linking / Expo Router → your BFF |
| Click tracking | Optional concern | Prefer off on the security sending domain |
Both call the same POST /emails from the server. Keep separate stores—do not reuse an OTP record as a magic-link token.
Checklist before you ship
crypto.randomInt(or OS CSPRNG) + HMAC pepper on the server; never store raw codes; never useMath.random()for OTP.- ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit on the BFF.
- Upsert / overwrite invalidates the previous code; verify deletes on success.
htmlandtext; optional subject code for previews.ROVELA_API_KEY(sending_access) only as an EAS/server secret—not in Expo client, not inEXPO_PUBLIC_*.Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- Expo talks only to your API; use one-time-code autofill hints; store session tokens in Secure Store.
- Verified from-domain; spam checklist if inbox placement slips.
- Durable OTP store (Redis/SQL)—not a process-local
Mapbehind multiple EAS instances.
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.
- Framework siblings: React Native OTP · Flutter OTP · Express OTP · Hono OTP.
- Node transport only (not mobile OTP): Sending email in Node.js.
Bottom line: keep the Expo managed app as the OTP UI, put mint/HMAC/verify and the Rovela sending_access key on a server (Express or Expo API route with EAS secrets), call POST /emails with an Idempotency-Key, and never ship the sending key inside the client—Rovela delivers the mail; your app still owns auth.