Verificação de telefone em app mobile: React Native e Flutter
Fluxo completo de verificação de telefone em React Native e Flutter: por que passar pelo backend, tela de telefone com máscara, tela de código com auto-preenchimento e reenvio, com exemplos.
A verificação de telefone em app mobile é o primeiro contato real do usuário com o seu produto: ele digita o número, espera o código chegar pelo WhatsApp ou SMS, digita (ou deixa o sistema preencher) e entra. Quando esse fluxo demora, falha ou pede demais, a pessoa desinstala antes de ver qualquer tela que importa.
Em React Native e em Flutter, as peças são as mesmas: um backend que conversa com a API de OTP, uma tela de telefone com máscara e formato internacional, e uma tela de código com auto-preenchimento, contador de reenvio e mensagens de erro claras.
O guia abaixo percorre cada peça na ordem em que o usuário a encontra, com um exemplo de tela de código em cada framework e o backend mínimo que os dois compartilham.
Verificação de telefone em app mobile: por que não chamar a API direto
A tentação é grande: a API de envio tem um endpoint HTTP simples, e o app já sabe fazer requisições. Chamá-la direto, porém, cria três problemas que não têm correção depois.
O primeiro é a chave. Qualquer segredo embarcado no binário pode ser extraído com ferramentas comuns de engenharia reversa; o ak_live_ da sua conta passaria a enviar mensagens pagas para quem quisesse. O segundo é o controle: sem um servidor no meio, não há como limitar envios por telefone ou por conta, nem bloquear um número abusivo. O terceiro é a confiança: a validação precisa acontecer onde o app não consegue mentir. Se o próprio app decide "o código conferiu", um cliente modificado pula a etapa.
Por isso a regra é fixa: o app fala apenas com o seu backend, e o backend fala com a API de OTP. A API da aut2fa, por exemplo, nem libera CORS, justamente para desencorajar chamadas de cliente.
O backend como intermediário
O backend expõe duas rotas, as mesmas para iOS e Android, e guarda o estado entre elas:
| Rota | Entrada | O que faz | Resposta |
|---|---|---|---|
POST /otp/send | telefone | Checa cooldown e limites, chama POST /v1/send, guarda o id do envio | expires_at |
POST /otp/verify | telefone, codigo | Recupera o id, chama POST /v1/send/{id}/verify, cria a sessão se valid: true | ok e tentativas restantes |
Em Node.js, o núcleo cabe em poucas linhas:
// otp.js — o app nunca vê a chave nem o id do envio
import crypto from "node:crypto";
const API = "https://api.aut2fa.com";
const headers = {
Authorization: `Bearer ${process.env.AUT2FA_API_KEY}`,
"Content-Type": "application/json",
};
const envios = new Map(); // telefone -> id (em produção: Redis com TTL)
app.post("/otp/send", async (req, res) => {
const { telefone } = req.body; // validado antes: formato +55DDDNÚMERO
const r = await fetch(`${API}/v1/send`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ to: telefone, locale: "pt-BR" }),
});
if (r.status === 429) return res.status(429).set("Retry-After", r.headers.get("Retry-After") ?? "60").end();
const envio = await r.json();
envios.set(telefone, envio.id);
res.status(202).json({ expires_at: envio.expires_at });
});
app.post("/otp/verify", async (req, res) => {
const { telefone, codigo } = req.body;
const id = envios.get(telefone);
if (!id) return res.status(400).json({ ok: false, erro: "sem_codigo_ativo" });
const r = await fetch(`${API}/v1/send/${id}/verify`, {
method: "POST",
headers,
body: JSON.stringify({ code: String(codigo) }),
});
const { valid, verification, attempts_left } = await r.json();
if (!valid) {
if (verification !== "pending") envios.delete(telefone); // expirado, bloqueado ou já usado
return res.status(400).json({ ok: false, estado: verification, tentativas_restantes: attempts_left });
}
envios.delete(telefone);
// aqui: marcar telefone como verificado e emitir o token de sessão do app
res.json({ ok: true });
});
A resposta do verify é o lugar certo para emitir o token de sessão do app: a verificação do telefone e o login acontecem na mesma chamada, e o app só precisa guardar o token no armazenamento seguro. Note que um HTTP 200 da API não significa sucesso; o único sinal de confirmação é valid: true.
A plataforma escolhe o canal entre os permitidos na aplicação (WhatsApp antes de SMS, por custo) e o seu código não muda. Quem quer entender a entrega pelo WhatsApp em si, inclusive quando o envio é uma mensagem comum e não um OTP, encontra o passo a passo em enviar WhatsApp com Node.js. A versão completa do backend com cooldown e limites por telefone está em verificar número de telefone no cadastro com Node.js.
Tela de telefone: máscara e formato internacional
A tela de telefone tem um único objetivo: produzir um número no formato internacional (+5531987654321) sem que a pessoa precise saber o que é isso. Três decisões resolvem a maior parte dos problemas:
- Máscara visual, valor limpo. Mostre
(31) 98765-4321no campo, mas guarde só os dígitos. A máscara é apresentação; o backend recebe+55seguido de DDD e número. - Seletor de país com
+55como padrão. A maioria dos usuários nunca vai tocar nele, mas quem tem número estrangeiro precisa dele. - Teclado numérico e validação antes de enviar. No Brasil, celular tem DDD de dois dígitos e nove dígitos começando em 9; recuse o resto antes de gastar um envio.
Evite pedir o número duas vezes "para confirmar": é o código que confirma. E não bloqueie colar no campo; muita gente copia o número do próprio perfil do WhatsApp.
Perto do botão, diga por qual canal o código vai chegar ("Vamos enviar um código pelo WhatsApp"). Isso prepara a pessoa para olhar o aplicativo certo e reduz o reenvio por impaciência, que é o maior gerador de custo desnecessário nesse fluxo.
Tela de código: auto-preenchimento e reenvio
É aqui que a conversão da verificação de telefone em app mobile se decide. A tela de código precisa fazer cinco coisas bem, e o detalhe de cada uma está no guia de UX da tela de código de verificação:
- Dizer para onde o código foi ("enviado para (31) 98765-4321") com um link para corrigir o número.
- Abrir já com o teclado numérico e o foco no campo. Nenhum toque extra.
- Preencher sozinho quando der. Com SMS, iOS sugere o código acima do teclado e Android pode preencher via autofill. Com WhatsApp, a pessoa copia e cola: aceite colagem e limpe espaços e caracteres que não sejam dígitos.
- Validar ao completar os 6 dígitos, sem botão "confirmar". Mostrar "código incorreto, restam 3 tentativas" quando errar.
- Reenviar com contador regressivo de 60 segundos, respeitando o
Retry-Afterse o backend devolver 429.
Um detalhe muitas vezes esquecido: o contador de expiração. O backend devolve expires_at; use esse valor para desabilitar o campo e pedir um novo código quando o prazo acabar, em vez de deixar a pessoa digitar um código que o servidor vai recusar.
Exemplo em React Native
As chamadas HTTP ficam em um módulo próprio, com fetch, que já vem no React Native. Quem quiser revisar o básico de requisições, cabeçalhos e tratamento de resposta pode ver como consumir uma API REST com JavaScript; o padrão é idêntico dentro do app.
// api.js — chamadas ao SEU backend, nunca à API de OTP
const BASE = "https://api.minhaloja.com.br";
async function post(rota, corpo) {
const r = await fetch(`${BASE}${rota}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corpo),
});
const dados = await r.json().catch(() => ({}));
return { status: r.status, retryAfter: r.headers.get("Retry-After"), ...dados };
}
export const pedirCodigo = (telefone) => post("/otp/send", { telefone });
export const confirmarCodigo = (telefone, codigo) => post("/otp/verify", { telefone, codigo });
A tela de código usa um único TextInput, com as duas propriedades que ativam o auto-preenchimento de SMS:
// TelaCodigo.jsx
import { useEffect, useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { confirmarCodigo, pedirCodigo } from "./api";
export function TelaCodigo({ telefone, aoConfirmar }) {
const [codigo, setCodigo] = useState("");
const [segundos, setSegundos] = useState(60);
const [erro, setErro] = useState(null);
useEffect(() => {
if (segundos === 0) return;
const t = setTimeout(() => setSegundos((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [segundos]);
async function aoDigitar(valor) {
const limpo = valor.replace(/\D/g, "").slice(0, 6); // aceita colar "482 913"
setCodigo(limpo);
if (limpo.length < 6) return;
const r = await confirmarCodigo(telefone, limpo);
if (r.ok) return aoConfirmar();
setCodigo("");
setErro(r.estado === "pending"
? `Código incorreto. Restam ${r.tentativas_restantes} tentativas.`
: "Código expirado ou bloqueado. Peça um novo.");
}
async function reenviar() {
const r = await pedirCodigo(telefone);
setSegundos(r.status === 429 ? Number(r.retryAfter ?? 60) : 60);
setErro(null);
}
return (
<View>
<Text>Digite o código enviado para {telefone}</Text>
<TextInput
value={codigo}
onChangeText={aoDigitar}
keyboardType="number-pad"
textContentType="oneTimeCode" // iOS: sugere o código recebido por SMS
autoComplete="sms-otp" // Android: preenchimento via SMS
maxLength={6}
autoFocus
/>
{erro && <Text>{erro}</Text>}
<Pressable disabled={segundos > 0} onPress={reenviar}>
<Text>{segundos > 0 ? `Reenviar em ${segundos}s` : "Reenviar código"}</Text>
</Pressable>
</View>
);
}
No Android, o preenchimento por autoComplete="sms-otp" depende do serviço de autofill do sistema; para uma experiência sem toque, a alternativa é a SMS Retriever API, que exige um hash do app no final da mensagem e uma biblioteca nativa. Para WhatsApp, o caminho é copiar e colar, e o replace(/\D/g, "") acima garante que um código colado com espaços entre os dígitos seja aceito.
Repare também no useEffect: ele reagenda o próximo tique a cada mudança de segundos e limpa o timer ao desmontar, o que evita o erro clássico de atualizar estado em tela fechada. O botão de reenvio fica desabilitado enquanto o contador corre, e um 429 do backend reinicia o contador com o prazo do Retry-After.
Exemplo em Flutter
Em Flutter, o pacote http faz as chamadas e o TextField recebe o AutofillHints.oneTimeCode, que no iOS ativa a sugestão do código do SMS. A estrutura espelha a versão React Native:
// tela_codigo.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
const base = 'https://api.minhaloja.com.br';
Future<Map<String, dynamic>> post(String rota, Map<String, String> corpo) async {
final r = await http.post(Uri.parse('$base$rota'),
headers: {'Content-Type': 'application/json'}, body: jsonEncode(corpo));
final dados = r.body.isEmpty ? <String, dynamic>{} : jsonDecode(r.body) as Map<String, dynamic>;
return {'status': r.statusCode, 'retryAfter': r.headers['retry-after'], ...dados};
}
class TelaCodigo extends StatefulWidget {
final String telefone;
final VoidCallback aoConfirmar;
const TelaCodigo({super.key, required this.telefone, required this.aoConfirmar});
@override
State<TelaCodigo> createState() => _TelaCodigoState();
}
class _TelaCodigoState extends State<TelaCodigo> {
final _campo = TextEditingController();
Timer? _timer;
int _segundos = 60;
String? _erro;
@override
void initState() {
super.initState();
_contar(60);
}
void _contar(int inicio) {
_timer?.cancel();
setState(() => _segundos = inicio);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_segundos == 0) { t.cancel(); return; }
setState(() => _segundos--);
});
}
Future<void> _aoDigitar(String valor) async {
final limpo = valor.replaceAll(RegExp(r'\D'), ''); // aceita colar com espaços
if (limpo != valor) _campo.text = limpo;
if (limpo.length < 6) return;
final r = await post('/otp/verify', {'telefone': widget.telefone, 'codigo': limpo});
if (r['ok'] == true) return widget.aoConfirmar();
_campo.clear();
setState(() => _erro = r['estado'] == 'pending'
? 'Código incorreto. Restam ${r['tentativas_restantes']} tentativas.'
: 'Código expirado ou bloqueado. Peça um novo.');
}
Future<void> _reenviar() async {
final r = await post('/otp/send', {'telefone': widget.telefone});
_contar(r['status'] == 429 ? int.tryParse('${r['retryAfter']}') ?? 60 : 60);
setState(() => _erro = null);
}
@override
Widget build(BuildContext context) {
return Column(children: [
Text('Digite o código enviado para ${widget.telefone}'),
TextField(
controller: _campo,
keyboardType: TextInputType.number,
maxLength: 6,
autofocus: true,
autofillHints: const [AutofillHints.oneTimeCode], // iOS: sugere o código do SMS
onChanged: _aoDigitar,
),
if (_erro != null) Text(_erro!),
TextButton(
onPressed: _segundos > 0 ? null : _reenviar,
child: Text(_segundos > 0 ? 'Reenviar em ${_segundos}s' : 'Reenviar código'),
),
]);
}
@override
void dispose() {
_timer?.cancel();
_campo.dispose();
super.dispose();
}
}
No Android, o Flutter não lê SMS sozinho; pacotes como sms_autofill encapsulam a SMS Retriever API e preenchem o campo quando a mensagem chega com o hash do app. Avalie se vale a dependência: com WhatsApp como canal principal, a maioria dos códigos chega por lá, e o que importa é colar funcionar bem.
Perguntas frequentes
O código chega por WhatsApp ou por SMS?
Depende dos canais habilitados na aplicação. Com os dois ativos, a plataforma tenta o WhatsApp primeiro, por ser mais barato, e cai para SMS se o número não tiver conta ou a entrega falhar. O app recebe só expires_at e não precisa saber o canal.
Como o auto-preenchimento funciona com WhatsApp?
Não há API de sistema para ler mensagens do WhatsApp, então o preenchimento automático só existe para SMS. Com WhatsApp, a pessoa copia o código e cola; por isso o campo deve aceitar colagem e remover espaços antes de validar.
Preciso de SMS Retriever no Android?
Só se quiser preenchimento sem nenhum toque com SMS. O autoComplete="sms-otp" do React Native e o AutofillHints.oneTimeCode do Flutter cobrem o iOS e, no Android, dependem do serviço de autofill do aparelho. Para a maioria dos apps, o campo com teclado numérico e colagem já resolve.
Onde o app guarda a sessão depois da verificação?
No armazenamento seguro do sistema: Keychain no iOS e Keystore no Android, por meio de bibliotecas como expo-secure-store, react-native-keychain ou flutter_secure_storage. Nunca em AsyncStorage ou SharedPreferences sem criptografia.
Como testar o fluxo no simulador sem gastar envios?
Use uma chave de teste (ak_test_) no backend: a API responde com o mesmo contrato, nenhuma mensagem é enviada e o código é sempre 000000. O app inteiro pode ser testado, incluindo erros e bloqueio por tentativas, antes de trocar para a chave de produção.
Próximos passos
Com o backend no meio, a tela de telefone produzindo o formato internacional e a tela de código aceitando preenchimento automático e colagem, a verificação de telefone em app mobile fica rápida nos dois sistemas e segura contra extração de chave e validação forjada.
Para ver o fluxo rodando, crie uma aplicação de teste na aut2fa, aponte o backend para a chave ak_test_ e digite 000000 no simulador: quando a tela de código passar, basta trocar a chave para colocar o app na loja.