Skip to content

Email verification in React Native

Verify an email in a React Native or Expo app with a 6-digit code: a backend that keeps the API key, TextInput props for one-time-code autofill, and secure session storage.

OTPBy Rushi Patel, Senior iOS developerUpdated 6 November 20267 min read

Email verification with a 6-digit code works the same in every React Native app: the app asks your server to send a code, the user types it, the app asks your server to check it, and your server hands back a session. This post builds both halves: two backend routes that call the SignOps email OTP API, and a screen with the right TextInput props.

The flow

Step Where What happens
1. User types an email App POST /auth/email/send to your server
2. Send the code Your server POST /v1/otp/send with your secret key, otp_id back to the app
3. User types the 6 digits App POST /auth/email/verify with otp_id and code
4. Check the code Your server POST /v1/otp/verify; a session token only when valid is true
5. Keep the session App Token saved in secure storage

The key never ships in the app. The otp_id can go to the app: checking a code needs your secret key, every code allows 5 tries, and the server signs in the email the API answers, not one the app sends.

The server (Node and Express)

// server.js
import express from "express";

const app = express();
app.use(express.json());
app.set("trust proxy", 1); // so req.ip is the user's address behind a load balancer

async function signops(path, body) {
  const res = await fetch(`https://api.signops.dev/v1${path}`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SIGNOPS_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  return { status: res.status, retryAfter: res.headers.get("retry-after"), data: await res.json() };
}

app.post("/auth/email/send", async (req, res) => {
  const r = await signops("/otp/send", { email: req.body.email, app_name: "Corner Market", ip: req.ip });
  if (r.status !== 200) {
    if (r.retryAfter) res.set("Retry-After", r.retryAfter);
    return res.status(r.status).json({ error: r.data.error.message });
  }
  res.json({ otpId: r.data.otp_id });
});

app.post("/auth/email/verify", async (req, res) => {
  const r = await signops("/otp/verify", { otp_id: req.body.otpId, code: req.body.code });
  if (r.status !== 200) return res.status(r.status).json({ error: r.data.error.message });
  if (!r.data.valid) return res.status(400).json({ error: "Wrong code", triesLeft: r.data.tries_left });
  res.json({ token: await issueSessionToken(r.data.email) }); // your own sessions or JWTs
});

app.listen(3000);

Any backend works the same way: see Next.js and Laravel.

The screen

// VerifyEmail.tsx
import { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
import * as SecureStore from "expo-secure-store";

const API = "https://api.yourapp.com";

async function post(path: string, body: object) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return { ok: res.ok, data: await res.json() };
}

export function VerifyEmail({ onDone }: { onDone: () => void }) {
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [otpId, setOtpId] = useState<string | null>(null);
  const [error, setError] = useState("");

  async function send() {
    const r = await post("/auth/email/send", { email });
    setError(r.ok ? "" : r.data.error);
    if (r.ok) setOtpId(r.data.otpId);
  }

  async function verify() {
    const r = await post("/auth/email/verify", { otpId, code });
    if (!r.ok) return setError(r.data.triesLeft ? `Wrong code, ${r.data.triesLeft} tries left` : r.data.error);
    await SecureStore.setItemAsync("session", r.data.token);
    onDone();
  }

  return (
    <View>
      {!otpId ? (
        <>
          <TextInput value={email} onChangeText={setEmail} placeholder="you@example.com"
            keyboardType="email-address" autoCapitalize="none" autoComplete="email" textContentType="emailAddress" />
          <Button title="Email me a code" onPress={send} />
        </>
      ) : (
        <>
          <TextInput value={code} onChangeText={setCode} placeholder="6-digit code" maxLength={6}
            keyboardType="number-pad" autoComplete="one-time-code" textContentType="oneTimeCode" />
          <Button title="Verify" onPress={verify} disabled={code.length !== 6} />
        </>
      )}
      {error ? <Text accessibilityRole="alert">{error}</Text> : null}
    </View>
  );
}

TextInput props that matter

Prop Value Why
keyboardType email-address, then number-pad The right keyboard for each step
autoCapitalize none Stops “Ananya@…” on the email field
autoComplete email, then one-time-code Autofill on iOS and Android
textContentType emailAddress, then oneTimeCode iOS; takes precedence over autoComplete there
maxLength 6 The code is always 6 digits

What can come back

Answer Meaning What to show
200, valid: true The code is right; it cannot be used again Continue into the app
200, valid: false Wrong code, tries_left says how many remain The count under the field
429 rate_limited Sent too soon or too often; Retry-After has the seconds A countdown on the resend button
410 expired Past 10 minutes, or the email bounced “Send a new code”
422 too_many_tries Five wrong codes “Send a new code”

With SignOps

SignOps Email OTP makes the code, keeps only a hash of it, sends it from your own domain and checks it: 10 minutes, 5 tries, a 30-second resend wait and hourly limits per address and per IP. 500 codes are free to try, then $1 per 1,000 codes. The same API key also sends push notifications to the app and adds passkeys. Dashboard setup: the email OTP guide.

Sources: React Native: TextInput, Expo: SecureStore, Express: behind proxies, SignOps API docs.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Can a React Native app call an email OTP API directly?

No. The API key is a secret, and anything shipped in an app bundle can be read. The app calls your own backend, and your backend calls the OTP API with the key.

How do I get the phone to offer the code from the email?

Set autoComplete to one-time-code on the TextInput, which works on iOS and Android, and textContentType to oneTimeCode on iOS. Whether a code from an email is offered depends on the phone's OS and mail app.

Where should the app keep the session after the code is checked?

In the device's secure storage, such as expo-secure-store, which uses the Keychain on iOS and the Keystore on Android. Not in AsyncStorage, which is not encrypted.

How long does the code last?

With SignOps, 10 minutes and 5 tries. A resend to the same address waits 30 seconds, and one address gets at most 5 codes an hour by default.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.