← Back to blog

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.

Updated November 4, 2026
Send OTP Emails from Expo Managed Apps (2026) via a Backend HTTP API

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 packagingMetro + EAS Build / UpdateMetro + native project
Where secrets breakEXPO_PUBLIC_*, extra, OTA updatesSame JS extraction risk
Typical BFFExpress or Expo API route + EAS secretsExpress / Nest / cloud function
OTP ownershipStill your serverStill your server
Rovela rolePOST /emails delivery onlySame

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 (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, SQL, or durable KV—not an in-memory Map across EAS replicas)
  • 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 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/emails with Authorization: Bearer + a sending_access key (server / EAS secret only—never in the client 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: 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 keyEAS / server secret managerExtractable from JS bundle / IPA / APK / OTA
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_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 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
Expo surfaceExpo Router email + PIN → 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 as an EAS/server secret—not in Expo client, not in EXPO_PUBLIC_*.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Expo talks only to your API; use one-time-code autofill hints; store session tokens in Secure Store.
  9. Verified from-domain; spam checklist if inbox placement slips.
  10. Durable OTP store (Redis/SQL)—not a process-local Map behind multiple EAS instances.

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: React Native OTP · Flutter OTP · Express OTP · Hono OTP.
  6. 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.