Send OTP Emails from React Native (2026) via a Backend HTTP API
Build React Native / Expo signup OTP with a Node Express BFF: crypto.randomInt + HMAC, Idempotency-Key POST /emails—never put sending_access in the mobile app; Rovela delivers only.
Why React Native needs a backend for email OTP
React Native (and Expo) shine at signup and login UI—TextInput, keyboard types, autofill, and a six-digit field that feels native on iOS and Android. They are a poor place to hold a Rovela sending_access key. Anything you ship in the IPA/APK (or bake into expo.extra, app.config.js, or an .env that Metro bundles) can be extracted. Putting ROVELA_API_KEY in EXPO_PUBLIC_*, AsyncStorage, or a “secret” remote config still leaves a sending credential on every phone.
What you need is the recipe in Email verification codes: a Node BFF (Express, Fastify, Nest, or an Expo API route you control on the server) 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 React Native app only calls your /auth/otp/send and /auth/otp/verify over HTTPS with fetch. 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 React Native / Expo focused: a thin Express (or Node) OTP BFF that talks to Rovela, plus a minimal RN client that never sees the sending key. It is not Flutter OTP (Dart shelf + Flutter widgets), and not the web-only Express OTP walkthrough (same Node crypto, no mobile client boundary). 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 Node BFF 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 (memory map, Redis, or SQL)
- 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 React Native / Expo app owns:
- Collecting email + code, calling your BFF with
fetch, storing the session (expo-secure-storeor Keychain/Keystore) - 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 env only—never in the RN 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: phone → your API → Rovela
React Native / Expo (UI)
│ HTTPS JSON (no Rovela key)
▼
Node Express / Fastify / Nest BFF (ROVELA_API_KEY + CODE_PEPPER)
│ POST /emails + Idempotency-Key
▼
api.rovela.dev → mailbox
Prefer a thin Node BFF if the rest of your stack is already TypeScript/JavaScript, so OTP crypto and the mobile API stay in one language. Go, Python, or Dart BFFs work the same—the RN side does not change. Do not call Rovela from fetch inside the React Native project for production OTP.
Env on the server (never in the app)
# server only — Fly.io / Railway / Cloud Run / VPS secrets
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
Wire keys like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders. Expo’s EXPO_PUBLIC_ prefix is for non-secrets that ship to the client; never put Rovela keys there.
Node BFF: mint, HMAC, POST /emails (Express sketch)
// server/src/otp.ts — CommonJS or ESM; TypeScript-friendly
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
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,
});
// Stable per logical send: hash prefix. New code ⇒ new key.
const idem = `otp-${codeHash.slice(0, 32)}`;
await sendOtpEmail({
to: email,
code,
idempotencyKey: idem,
ttlMinutes: Math.round(TTL_MS / 60_000),
});
// Uniform copy — product choice whether to reveal account existence
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);
// issue session / JWT here
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()
);
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).
React Native client: call your BFF only
// src/api/otp.ts — NO Rovela key here
const BASE = process.env.EXPO_PUBLIC_API_BASE!; // your BFF, e.g. https://api.yourapp.com
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; // store with expo-secure-store
}
// src/screens/OtpLogin.tsx (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 "../api/otp";
export function OtpLogin() {
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);
// navigate home
} 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, not as an SMS Retriever payload. Keep EXPO_PUBLIC_API_BASE as your BFF origin only.
Idempotency-Key: retries without double codes
Workers, 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 React Native 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-rn-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 React Native app
BFF → POST /emails (this post) | RN / Expo → Rovela directly | |
|---|---|---|
sending_access key | Server secret manager | Extractable from the JS bundle / IPA / APK |
| 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’s public env vars and Metro bundling make “hide the key in the client” especially fragile. Generic Node/fetch sending patterns live in Express OTP and Sending email in Node.js. Those posts are server-transport focused; this post is signup/login OTP with a mobile client boundary.
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 for React Native
| 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 |
| RN surface | Email + PIN fields → 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 on the server—not in RN, not inEXPO_PUBLIC_*.Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- RN talks only to your API; use one-time-code autofill hints; store session tokens in Secure Store / Keychain.
- 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.
- Framework siblings: Flutter OTP · Express OTP · Go OTP · Hono OTP.
- Node transport only (not mobile OTP): Sending email in Node.js.
Bottom line: keep React Native / Expo as the OTP UI, put mint/HMAC/verify and the Rovela sending_access key on a Node BFF, call POST /emails with an Idempotency-Key, and never ship the sending key inside the mobile client—Rovela delivers the mail; your app still owns auth.