← Back to blog

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.

Updated November 4, 2026
Send OTP Emails from React Native (2026) via a Backend HTTP API

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 (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 (memory map, Redis, or SQL)
  • Constant-time compare on redeem; delete / invalidate on success
  • Rate limits on /auth/otp/send and /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-store or 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/emails with Authorization: Bearer + a sending_access key (server env only—never in the RN bundle, never in EXPO_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 keyServer secret managerExtractable from the JS bundle / IPA / APK
OTP mint + HMAC storeServerHard to share across devices safely
Rate limitsPer IP + per email on your edgeAttacker uses your key as a free relay
IdempotencyStable key per mint on serverEasy to get wrong on flaky mobile retries
Session issueAfter verify on serverStill 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 actionType digits (good on mobile keyboards)Open URL (deep link / universal link)
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
RN surfaceEmail + PIN fields → your BFFLinking / Expo Router → your BFF
Click trackingOptional concernPrefer 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

  1. crypto.randomInt (or OS CSPRNG) + HMAC pepper on the server; never store raw codes; never use Math.random() for OTP.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit on the BFF.
  3. Upsert / overwrite invalidates the previous code; verify deletes on success.
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server—not in RN, not in EXPO_PUBLIC_*.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. RN talks only to your API; use one-time-code autofill hints; store session tokens in Secure Store / Keychain.
  9. Verified from-domain; spam checklist if inbox placement slips.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Framework siblings: Flutter OTP · Express OTP · Go OTP · Hono OTP.
  6. 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.