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.

Capa do artigo Verificação de telefone em app mobile: React Native e Flutter

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:

RotaEntradaO que fazResposta
POST /otp/sendtelefoneCheca cooldown e limites, chama POST /v1/send, guarda o id do envioexpires_at
POST /otp/verifytelefone, codigoRecupera o id, chama POST /v1/send/{id}/verify, cria a sessão se valid: trueok 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-4321 no campo, mas guarde só os dígitos. A máscara é apresentação; o backend recebe +55 seguido de DDD e número.
  • Seletor de país com +55 como 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.

Infográfico do fluxo mobile de verificação de telefone: o app chama só o seu backend, o backend chama a API de OTP, o código chega no WhatsApp ou SMS, o campo aceita auto-preenchimento e colagem, e o reenvio tem cooldown de 60 segundos

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:

  1. Dizer para onde o código foi ("enviado para (31) 98765-4321") com um link para corrigir o número.
  2. Abrir já com o teclado numérico e o foco no campo. Nenhum toque extra.
  3. 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.
  4. Validar ao completar os 6 dígitos, sem botão "confirmar". Mostrar "código incorreto, restam 3 tentativas" quando errar.
  5. Reenviar com contador regressivo de 60 segundos, respeitando o Retry-After se 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.

Continue lendo