Come ho implementato il login OTP via email e SMS in React Native

otp-login

Introduzione: dal perché al come

Nel precedente articolo, Login passwordless con OTP: il problema della sicurezza e dell’usabilità,
ho analizzato perché un accesso senza password permanente può ridurre la frizione in un’app HR, e quali controlli non vanno mai lasciati al solo client.

Qui faccio il passo successivo: prendo quei requisiti e li trasformo in un flusso concreto di React Native. L’obiettivo non è soltanto mostrare sei caselle per un codice numerico — quello, da solo, sarebbe abbastanza semplice — ma coordinare identificativo, invio, verifica, reinvio, errori e sessione applicativa senza far diventare la schermata di login un monolite che nessuno vuole più toccare.

Nel progetto myGenius il flusso supporta due canali. Con l’email, la richiesta del codice e la sua verifica avvengono direttamente presso l’API applicativa. Con l’SMS, invece, la verifica del codice passa da Firebase, e solo dopo il risultato viene validato dal backend. È una distinzione che sembra piccola sulla carta, ma cambia parecchio nell’implementazione: il componente UI resta quasi identico nei due casi, mentre cambiano il servizio che verifica l’OTP e il percorso con cui si costruisce la sessione finale.


Il flusso completo di autenticazione

La schermata IdentifierScreen gestisce due step principali: l’inserimento dell’email o del numero di telefono, e l’inserimento del codice OTP ricevuto subito dopo.

Per l’email, il percorso è piuttosto lineare: l’utente inserisce l’indirizzo, il client ne controlla il formato localmente, viene chiamato check-identity tramite requestEmailToken, il backend invia il codice via email, l’utente lo digita, emailLogin invia email e codice all’API, e il backend restituisce i dati necessari alla sessione applicativa.

Per l’SMS il percorso ha un passaggio in più. L’utente inserisce il numero, il client lo normalizza in formato internazionale, checkPhone chiede al backend di verificare l’identificativo prima di procedere, Firebase invia il codice tramite signInWithPhoneNumber, Firebase stesso verifica l’OTP inserito, l’app recupera un ID token Firebase, phoneLogin invia token e numero verificato al backend, e infine il backend completa il login restituendo la sessione applicativa.

La differenza architetturale, in sintesi, è netta: per l’email il codice viene verificato dall’API del progetto; per l’SMS è Firebase a verificare il codice, mentre il backend resta comunque responsabile dell’account, delle autorizzazioni e della sessione effettivamente usata dall’app.


Separare UI, orchestrazione e servizi

La schermata non dovrebbe sapere quale endpoint chiamare, come viene conservato un risultato Firebase, o quale token va passato al backend. Il suo compito è renderizzare lo stato e inoltrare le azioni dell’utente — nient’altro.

Nel progetto ho quindi distribuito le responsabilità su più file: src/screens/IdentifierScreen/IdentifierScreen.js contiene il rendering vero e proprio, src/features/identifier/useIdentifier.js orchestra gli step e le operazioni asincrone, src/components/auth/OtpInput.js gestisce l’inserimento visuale del codice, src/components/auth/TimerButton.js mostra il pulsante con il countdown, src/hooks/useResendTimer.js calcola i secondi rimanenti, src/services/ApiInstance.js espone le chiamate HTTP, src/services/firebase.js incapsula la Phone Authentication e la cache temporanea, e src/utils/validators.js raccoglie i controlli locali di formato.

Grazie a questa suddivisione, la schermata riceve dall’hook uno stato già pronto per il rendering:

const {
  value,
  setValue,
  step,
  code,
  setCode,
  busy,
  otpBusy,
  method,
  isCoolingDown,
  remainingSeconds,
  onSubmit,
  onResend,
} = useIdentifier(navigation);

In questo modo il componente non decide mai quale servizio usare, e non interpreta direttamente gli errori provenienti da Axios o da Firebase — quel lavoro resta un livello più sotto.


Validare prima della rete

La validazione locale non sostituisce quella del server, e non deve mai illudersi di farlo. Serve piuttosto a bloccare input palesemente errati prima di partire con una richiesta di rete, e a dare un feedback immediato all’utente senza fargli attendere un giro completo di chiamate per un errore di battitura.

Nel progetto ci sono tre funzioni essenziali per questo scopo:

export function isEmail(value) {
  if (!value) return false;
  const re = /^[^s@]+@[^s@]+.[^s@]{2,}$/i;
  return re.test(String(value).trim());
}

export function isItalianPhone(raw) {
  if (!raw) return false;
  const value = String(raw).replace(/s+/g, '');
  const re = /^(?:+?39)?0?d{6,12}$/;
  return re.test(value);
}

export function isSixDigitCode(code) {
  return /^[0-9]{6}$/.test(String(code || ''));
}

Il primo step prova entrambe le possibilità in un colpo solo, senza chiedere all’utente di specificare in anticipo cosa sta inserendo:

const trimmed = value.trim();
const isMail = isEmail(trimmed);
const isPhone = isItalianPhone(trimmed);

if (!isMail &​amp;&​amp; !isPhone) {
  setCurrentError({
    message: "Inserisci un'email valida o un numero di telefono.",
  });
  return;
}

Questi controlli rispondono solo alla domanda “il formato è plausibile?”. Non devono mai rivelare se un account esiste davvero, e non possono in alcun modo decidere se l’utente sia autorizzato ad accedere — quella è, ancora una volta, una responsabilità del server.


Un componente OTP pensato per il mobile

Un singolo TextInput sarebbe sufficiente dal punto di vista puramente tecnico, ma sei caselle rendono più chiaro il numero di cifre richieste e permettono di guidare il focus in modo naturale. Il componente OtpInput espone un’interfaccia molto semplice da usare:

&​lt;OtpInput
  value={code}
  onChange={setCode}
  length={6}
/&​gt;

Quando l’utente inserisce una cifra, il focus passa automaticamente alla casella successiva:

const chars = value.split('');
chars[idx] = clean[0];
onChange(chars.join(''));

if (idx &​lt; length - 1) {
  refs.current[idx + 1]?.current?.focus();
}

Se invece viene premuto Backspace su una casella già vuota, il focus torna indietro:

if (e.nativeEvent.key === 'Backspace' &​amp;&​amp; !value[idx] &​amp;&​amp; idx &​gt; 0) {
  refs.current[idx - 1]?.current?.focus();
}

Ogni input viene ripulito prima di aggiornare lo stato:

const clean = text.replace(/D/g, '');

Gestire il paste

Il paste è un caso a cui tengo particolarmente: un codice copiato da un’email o da un SMS può arrivare tutto insieme in un unico evento. Invece di usare soltanto il primo carattere e scartare il resto, il componente distribuisce le cifre nelle caselle disponibili:

if (clean.length &​gt; 1) {
  const digits = clean.slice(0, length).split('');
  const nextValue = [];

  for (let i = 0; i &​lt; length; i += 1) {
    nextValue[i] = digits[i] || '';
  }

  onChange(nextValue.join(''));
  return;
}

Il valore viene comunque sempre limitato alla lunghezza prevista: le cifre in eccesso non devono entrare nello stato, né tantomeno essere inviate al backend.

Su iOS, in più, ho impostato textContentType a oneTimeCode:

&​lt;TextInput
  keyboardType="number-pad"
  textContentType={Platform.OS === 'ios' ? 'oneTimeCode' : 'none'}
/&​gt;

Il sistema può così proporre l’autocompletamento quando il contenuto del messaggio è compatibile con questo meccanismo. Non è però una funzione garantita in ogni caso: l’inserimento manuale deve restare sempre possibile, senza dipendere da quella scorciatoia.


Verificare automaticamente al completamento

Non ho reso obbligatorio un secondo tap su un pulsante di conferma — mi sembrava un attrito evitabile. Quando il valore raggiunge sei cifre, è l’hook stesso ad avviare la verifica:

useEffect(() =&​gt; {
  if (
    step === 'otp' &​amp;&​amp;
    code.length === 6 &​amp;&​amp;
    isSixDigitCode(code) &​amp;&​amp;
    !otpBusy
  ) {
    onSubmitOtp();
  }
}, [code, step]);

Il controllo !otpBusy evita richieste duplicate mentre la verifica precedente è ancora in corso — senza, il rischio di doppie chiamate sarebbe concreto. In caso di errore, il codice viene svuotato e l’utente può riprovare, senza che il nuovo tentativo si confonda in alcun modo con quello precedente.


Richiedere il codice via email

Per la richiesta email, requestEmailToken recupera un fingerprint locale del dispositivo. Prima prova a leggere device_fp da AsyncStorage; se non lo trova, usa l’identificativo fornito da react-native-device-info, e come ultima risorsa genera un UUID locale:

export async function requestEmailToken(identifier) {
  let device_fp = await AsyncStorage.getItem('device_fp');

  if (!device_fp) {
    device_fp = await DeviceInfo.getUniqueId();
  }

  if (!device_fp) {
    device_fp = generateLocalUUID();
  }

  await AsyncStorage.setItem('device_fp', device_fp);

  const response = await api.post('/check-identity', {
    identifier,
    device_fp,
  });

  return response.data;
}

Il fingerprint non è un’autenticazione, e non prova affatto l’identità dell’utente — resta solo un’informazione contestuale che il backend può usare per i propri controlli di sicurezza e antifrode.

Dopo una risposta positiva, l’hook conserva metodo e identificativo nello store Redux e passa allo step OTP:

const { ok, cooldownSeconds } = await requestEmailToken(trimmed);

if (!ok) {
  throw new Error('Errore durante la richiesta del codice di accesso');
}

dispatch(setMethod('email'));
dispatch(setIdentifier(trimmed));
dispatch(startCooldown({ seconds: cooldownSeconds || 300 }));
setStep('otp');

Richiedere il codice SMS con Firebase

Per il telefono, il primo controllo passa sempre dal backend. Solo dopo una risposta positiva l’app normalizza il numero e avvia Firebase:

const normalized = normalizeItalianPhone(trimmed);
const { ok, cooldownSeconds, message } = await checkPhone(normalized);

if (!ok) {
  throw new Error(message);
}

const confirmationResult = await signInWithPhoneNumber(normalized);
const verificationId =
  confirmationResult._verificationId || confirmationResult.verificationId;

confirmationResult contiene funzioni e stato interno di Firebase, e per questo non è affatto adatto a essere inserito in Redux, che dovrebbe contenere solo dati serializzabili. Nel progetto il risultato completo resta in una cache in memoria, indicizzata dal proprio ID:

const confirmationResultCache = new Map();

export function getConfirmationResult(verificationId) {
  if (!verificationId) return null;
  return confirmationResultCache.get(verificationId) || null;
}

In Redux salvo soltanto l’identificativo:

dispatch(setVerificationId(verificationId));

Quando viene richiesto un nuovo codice, il nuovo ID sostituisce semplicemente quello precedente nello stato. Dopo una verifica riuscita, il risultato viene rimosso dalla cache e l’ID viene azzerato — non deve restare lì a occupare memoria senza motivo.


Verificare il codice email

Nel ramo email, l’OTP viene inviato direttamente all’API:

const data = await emailLogin(identifier, code);

if (!data?.ok) {
  throw new Error(
    data?.message || data?.error || 'Errore durante il login. Riprova.'
  );
}

La richiesta include anche il fingerprint e alcune informazioni tecniche del dispositivo e dell’applicazione:

const response = await api.post('/email-login', {
  email,
  code,
  device_fp,
  platform,
  app_version,
  device_model,
  device_brand,
});

Un codice HTTP positivo, da solo, non basta a considerare il login riuscito: il client deve interpretare il contenuto effettivo della risposta, e il backend deve restituire soltanto una sessione valida secondo le proprie regole interne.

Nel progetto, emailLogin gestisce anche il cookie payauth necessario alla WebView, e persiste gli eventuali cookie ricevuti nella risposta. Vale la pena notare che il cookie è un dettaglio di integrazione della sessione web, non una sostituzione della verifica OTP.


Verificare il codice SMS

Nel ramo SMS, il codice viene prima consegnato a Firebase, non al backend applicativo:

const confirmationResult = getConfirmationResult(verificationId);

if (!confirmationResult) {
  throw new Error('Sessione di verifica non trovata. Riprova.');
}

const { idToken } = await confirmCode(confirmationResult, code);

Il servizio Firebase conferma il codice e recupera un ID token aggiornato:

export async function confirmCode(confirmationResult, code) {
  await confirmationResult.confirm(code);

  const currentUser = getAuth().currentUser;
  const token = currentUser
    ? await currentUser.getIdToken(true)
    : null;

  return {
    idToken: token,
    platform: Platform.OS,
  };
}

L’ID token Firebase, però, non è ancora la sessione finale di myGenius: viene inviato al backend insieme al numero verificato, ed è solo lì che prende forma la sessione vera e propria:

const response = await phoneLogin(idToken, identifier);

if (!response?.refresh_jwt || !response?.payauth) {
  throw new Error('Risposta del server non valida. Riprova.');
}

Il backend mantiene così il pieno controllo sull’account, sui permessi e sulla sessione applicativa. La presenza di un token Firebase valido, da sola, non deve mai dare accesso alla WebView senza questa ulteriore validazione lato server.

Dopo il passaggio, la cache viene ripulita:

clearConfirmationResult(verificationId);
dispatch(setVerificationId(null));

Questa pulizia limita la permanenza in memoria di un oggetto Firebase che non serve più a nulla.


Cooldown e reinvio del codice

Il pulsante di reinvio svolge due funzioni diverse allo stesso tempo: comunica il tempo rimanente all’utente, e riduce le richieste ripetute lato client. Il controllo davvero decisivo, però, deve comunque essere applicato dal backend.

Nel progetto lo stato Redux conserva un timestamp, e useResendTimer calcola i secondi residui a partire da quello:

export function useResendTimer() {
  const cooldownUntil = useSelector(
    (state) =&​gt; state.authModule?.resendTimer?.cooldownUntil
  );
  const [remaining, setRemaining] = useState(0);

  useEffect(() =&​gt; {
    function tick() {
      if (!cooldownUntil) {
        setRemaining(0);
        return;
      }

      const difference =
        new Date(cooldownUntil).getTime() - Date.now();

      setRemaining(Math.max(0, Math.ceil(difference / 1000)));
    }

    tick();
    const timer = setInterval(tick, 1000);

    return () =&​gt; clearInterval(timer);
  }, [cooldownUntil]);

  return {
    isCoolingDown: remaining &​gt; 0,
    remainingSeconds: remaining,
  };
}

Il componente visualizza il countdown e si disabilita durante l’attesa:

&​lt;TimerButton
  label="Reinvia"
  disabled={otpBusy || isCoolingDown}
  secondsLeft={remainingSeconds}
  onPress={onResend}
/&​gt;

Anche onResend controlla comunque il cooldown nella logica, non solo nell’interfaccia:

if (!method || !identifier || isCoolingDown) return;

Disabilitare il pulsante è utile per l’esperienza d’uso, ma non è affatto una misura di sicurezza sufficiente da sola: una richiesta può sempre essere generata senza passare dall’interfaccia grafica.

Una discrepanza da correggere

Durante la revisione del codice ho trovato un disallineamento tra il valore passato dall’hook e quello effettivamente usato dal reducer. L’hook invia il numero suggerito dal backend:

dispatch(startCooldown({ seconds: cooldownSeconds || 300 }));

Il reducer, però, oggi usa sempre 60 secondi a prescindere:

startCooldown(state, action) {
  const seconds = 60;
  const until = new Date(Date.now() + seconds * 1000).toISOString();
  state.cooldownUntil = until;
}

La conseguenza pratica è che cooldownSeconds non viene mai davvero applicato al timestamp mostrato all’utente. Una correzione ragionevole è leggere il payload con un fallback esplicito, così da non perdere il valore comunicato dal server:

startCooldown(state, action) {
  const seconds = action.payload?.seconds ?? 60;
  const until = new Date(Date.now() + seconds * 1000).toISOString();
  state.cooldownUntil = until;
}

Questa modifica non fa parte del flusso descritto finora — è una nota emersa dalla revisione, e andrà testata separatamente prima di essere rilasciata. In produzione il backend deve comunque restare la fonte di verità per il rate limiting, mentre l’app deve limitarsi a rappresentare il valore concordato, senza introdurre aspettative diverse da quelle reali.


Separare gli stati intermedi

Un login OTP attraversa molti più stati di un form tradizionale: c’è la richiesta iniziale in corso, il codice inviato, la verifica dell’OTP in corso, il cooldown attivo, il codice errato o scaduto, la sessione Firebase non disponibile, la risposta backend incompleta, e infine la sessione applicativa pronta.

Nell’hook ho tenuto separati i due indicatori principali:

const [busy, setBusy] = useState(false);
const [otpBusy, setOtpBusy] = useState(false);

busy riguarda la richiesta iniziale e il reinvio, mentre otpBusy riguarda specificamente la verifica del codice. Questa distinzione evita di bloccare o mostrare un loader su elementi che, in realtà, non dipendono affatto dalla stessa operazione.

Gli errori, dal canto loro, vengono trasformati in messaggi utente tramite toUserMessage. La schermata non deve conoscere la struttura interna di ogni errore Axios, Firebase o backend, e soprattutto non deve mai mostrare token, codici o dettagli tecnici che non riguardano l’utente.


Collegare il risultato alla sessione esistente

La verifica del codice non è affatto il punto finale del percorso. Dopo un login riuscito, il risultato deve entrare nel sistema di autenticazione già presente nell’app, non restare isolato a sé stante.

Nel progetto il backend restituisce refresh_jwt e, quando previsto, il cookie payauth. I servizi HTTP gestiscono il cookie necessario alla WebView, mentre l’hook decide come proseguire con l’eventuale opt-in biometrico.

La navigazione finale resta comunque centralizzata in un unico punto:

const navigateToWebView = () =&​gt; {
  dispatch(setSession({ wpCookies: ['set'] }));
  navigation.replace('WebView', {
    url: 'https://my-genius.it/',
  });
};

Se l’utente sceglie di abilitare la biometria, il JWT viene salvato tramite il package di autenticazione e il Keychain. Se invece rifiuta, il login prosegue comunque, semplicemente senza attivare il flag biometrico.

Sono, in fondo, due decisioni diverse tra loro: l’autenticazione OTP è riuscita, da un lato, e l’utente vuole usare la biometria per gli accessi successivi, dall’altro. La seconda non deve mai compromettere la prima.


Cosa non deve fare il client

Il client può controllare il formato e migliorare la UX quanto vuole, ma non deve mai diventare l’autorità che decide se un OTP sia valido o meno.

In concreto, questo significa non considerare valido un codice solo perché contiene sei cifre nel formato corretto, non salvare mai l’OTP in chiaro nello storage locale, non scrivere OTP, ID token o JWT nei log in nessuna circostanza, non saltare la verifica del backend dopo che Firebase ha fatto il suo lavoro, non conservare in memoria risultati Firebase che non servono più, non affidarsi alla sola disabilitazione visuale del reinvio come unica protezione, non inserire token o credenziali nei messaggi mostrati all’utente, e non confondere mai autenticazione con autorizzazione — sono due cose diverse, anche quando sembrano avvenire nello stesso istante.

La regex locale risponde soltanto a “il valore ha il formato atteso?”. La validità reale, la scadenza e il numero di tentativi spettano esclusivamente al servizio che gestisce l’autenticazione.


Test e miglioramenti successivi

Prima di considerare definitivo il flusso, aggiungerei test unitari e di integrazione su diversi fronti: i validatori di email, telefono e OTP; la digitazione, la cancellazione e il paste dentro OtpInput; l’auto-submit e la prevenzione delle doppie richieste; il comportamento del timer quando l’app va in background e poi torna in foreground; il cooldown effettivamente restituito dal backend; i casi di codice errato, scaduto e il reinvio successivo; la sessione Firebase mancante; una risposta backend incompleta; la mancata attivazione della biometria; e infine i messaggi di errore, verificando che non contengano mai dati sensibili.

Renderei inoltre esplicita la durata del codice quando il backend la restituisce, pulirei i dati di verifica nel momento in cui l’utente cambia metodo di accesso, e uniformerei i messaggi che oggi arrivano da API, Firebase e app con toni leggermente diversi tra loro. La gestione dei tentativi, in ogni caso, va definita lato server — non può essere aggiunta soltanto nella UI come pezza successiva.


Conclusione

Implementare un login OTP via email e SMS in React Native significa coordinare esperienza utente, validazione, servizi esterni e sessione applicativa allo stesso tempo. Il campo a sei cifre, in tutto questo, è solo la parte visibile.

Nel progetto myGenius la soluzione si basa su alcuni principi piuttosto chiari: IdentifierScreen si occupa del rendering, useIdentifier coordina l’intero flusso, OtpInput gestisce focus, paste e autocompletamento, email e SMS hanno percorsi di verifica distinti tra loro, Firebase verifica il codice SMS ma il backend decide comunque la sessione applicativa, useResendTimer rende visibile il cooldown mentre il rate limiting reale resta lato server, cookie, JWT e biometria vengono collegati solo dopo la verifica, e gli oggetti non serializzabili di Firebase restano sempre fuori da Redux.

La lezione principale, in definitiva, è che la qualità di un’implementazione si misura nei casi intermedi: retry, scadenze, errori, rate limiting e passaggio sicuro alla sessione finale. Sono aspetti che l’utente vede poco o nulla, ma che determinano quanto l’esperienza regga davvero alla prova dei fatti.

Per il quadro di sicurezza e privacy che guida queste scelte, torna all’articolo

Login passwordless con OTP: il problema della sicurezza e dell’usabilità


Domande frequenti

È meglio usare un campo unico o sei caselle?

Dipende dal contesto specifico. Un campo unico è più semplice da implementare; sei caselle rendono evidente la lunghezza del codice e permettono una gestione più guidata del focus. In entrambi i casi resta importante supportare paste e autocompletamento, quando disponibili sulla piattaforma.

Il client può verificare l’OTP?

Può verificarne soltanto il formato. Validità, scadenza, uso singolo e numero di tentativi devono essere gestiti dal backend o dal provider di autenticazione, mai localmente.

Perché usare Firebase per gli SMS e il backend per il login?

Perché hanno responsabilità diverse: Firebase gestisce la verifica tecnica del codice telefonico, mentre il backend conserva il controllo sull’account, sui permessi e sulla sessione applicativa vera e propria.

Perché confirmationResult non viene salvato in Redux?

Perché contiene funzioni e stato interno non serializzabile. Redux conserva soltanto l’ID di verifica; il risultato completo resta in una cache temporanea in memoria.

Il timer di reinvio è sufficiente per prevenire abusi?

No. Il timer migliora la UX e limita il comportamento normale dell’app, ma il backend deve comunque applicare rate limiting e controlli indipendenti dal client.

Cosa succede se il codice SMS scade?

Firebase può restituire un errore di codice o sessione scaduta. L’app deve mostrare un messaggio comprensibile, consentire un nuovo invio quando il cooldown termina, e associare correttamente il nuovo codice al nuovo ID di verifica generato.

 

Quando si usa esattamente autenticazione biometrica?
Scoprilo nel prossimo articolo!