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.
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.