Architecture OTP : sécuriser l’envoi SMS et e-mail
Concevez une architecture OTP fiable : séparez émission et envoi, gérez le repli SMS/e-mail et tracez les événements pour éviter les relances inutiles.

OTP par SMS et e-mail : concevoir une architecture fiable et auditable
À 3 h 07, une alerte signale une hausse des envois de secours par e-mail. Le tableau de bord du prestataire indique que les requêtes ont été acceptées, mais une question reste sans réponse : les SMS ont-ils vraiment échoué, ou leur confirmation est-elle juste en retard ? Et pourquoi des messages sont-ils encore envoyés à des coordonnées déjà déclarées invalides ?
Ce scénario pointe un problème classique avec l'authentification par code à usage unique (OTP). Le défi n'est pas seulement d'envoyer un SMS, puis un e-mail si nécessaire : c'est d'expliquer chaque étape. Quel canal a été tenté ? Sur quelle preuve s'appuie le repli ? Et pourquoi telle destination a été exclue (ou réutilisée) ? On peut répondre à ces questions avec une architecture simple, à condition de distinguer clairement l'émission du code, les tentatives de livraison, les événements fournis par le prestataire et les règles de suppression.
Pourquoi séparer le code de sa livraison
Un OTP est une preuve temporaire destinée à vérifier une étape d'authentification. Le fait de l'envoyer par SMS ou par e-mail sert de transport, et ce transport dépend de délais, d'incidents et de règles propres à chaque opérateur. Si l'application mélange « le code » et « sa livraison », elle perd vite la capacité de dire ce qui s'est réellement passé.
Quand l'API d'un prestataire répond « OK », cela signifie généralement qu'elle a accepté la demande. Ça ne prouve pas que le message est arrivé sur le téléphone, qu'il a été remis à la boîte de réception, ni que l'utilisateur l'a reçu à temps. Les événements suivants - livraison, rejet, rebond ou expiration - racontent une autre histoire. Le système doit garder cette séparation.
Un challenge, plusieurs tentatives possibles
Le modèle mental le plus simple : un challenge associé à une ou plusieurs tentatives de livraison. Le challenge décrit le cycle de vie de la vérification : création, durée de validité, consommation et politique applicable. Une tentative correspond à une décision d'envoi sur un canal, vers une destination donnée.
Par exemple : un challenge peut déclencher une tentative SMS. Si cette tentative se termine dans un état défini par la politique, ou si un délai est dépassé, le système peut alors évaluer une tentative e-mail - avec les mêmes règles, et en gardant une justification propre au repli. Dans ce modèle, le repli n'est pas une redirection automatique : c'est une nouvelle décision.
Pour une première version, quatre tables suffisent généralement :
| Enregistrement | Rôle | Informations à préserver |
|---|---|---|
| Challenge | Décrire le cycle de vie du code | Heure de création et version de la politique |
| Tentative | Décrire une décision d'envoi | Canal, région, empreinte du destinataire et motif |
| Événement | Conserver les preuves reçues | Identifiant fournisseur, statut brut et heure observée |
| Suppression | Bloquer une destination pour une cause précise | Événement déclencheur et date de décision |
Un curseur durable complète le tout pour ingérer les événements au bon endroit. Pas besoin de microservices « par table » : l'essentiel est la séparation des responsabilités et la conservation de l'historique utile.
Conserver l'historique au lieu d'écraser un statut
Mettre une seule colonne et la faire passer de en_attente à échec, puis à envoyé, paraît efficace. En pratique, ça efface les étapes précédentes. Si des notifications arrivent dans le désordre, ou si un événement est rejoué, vous finissez par perdre la trace de ce qui a déclenché le repli.
Gardez plutôt les événements en append-only : un événement s'ajoute, il ne remplace pas. L'état actuel d'une tentative se calcule ensuite à partir de l'historique. En cas de divergence entre ce que votre application affiche et ce que le fournisseur indique, vous disposez des identifiants et des horodatages nécessaires pour enquêter.
Et attention à un point souvent négligé : cette trace ne doit pas devenir un second stockage de secrets. N'enregistrez pas le code OTP dans le journal d'événements. Limitez sa durée de vie, contrôlez l'accès et séparez ce qui sert à vérifier les preuves de livraison de ce qui relève de l'authentification. Pour aller plus loin sur les contrôles liés aux mécanismes d'authentification, voyez le guide OWASP sur l'authentification multifacteur.
Comment ingérer les événements de livraison de façon fiable
Pour démarrer, un collecteur par interrogation périodique peut suffire. À chaque cycle, il lit les événements à partir du dernier curseur validé, les normalise, puis enregistre les événements et met à jour le curseur dans une transaction. La règle simple : ne jamais avancer le curseur avant d'avoir durablement enregistré la page correspondante.
Si le processus s'arrête après l'enregistrement mais avant d'avoir confirmé la progression, il relira la même page. Une contrainte d'unicité sur le couple source, identifiant_evenement_fournisseur rend ce rejeu sans effet secondaire. Et non, vous n'avez pas besoin de promettre un « exactement une fois » de bout en bout : c'est rarement tenable. Le but, c'est un traitement rejouable et sûr.
Le contrat applicatif peut rester léger : le collecteur récupère une page d'événements et un curseur suivant ; le stockage garantit l'insertion idempotente et la mise à jour atomique du curseur. En cas d'échec d'enregistrement, le curseur ne bouge pas. En cas de rejeu de la page, les doublons sont reconnus via leurs identifiants.
Normaliser sans effacer la preuve d'origine
La normalisation convertit les statuts fournisseurs vers les catégories comprises par votre politique, mais elle doit garder la valeur d'origine. Vous aurez besoin de cette preuve si vos règles évoluent.
Les statuts inconnus, eux, doivent être conservés et signalés pour examen. Ne les classez pas automatiquement comme « échec ». C'est précisément le genre de raccourci qui fait déclencher des décisions de repli quand ce n'était pas le bon diagnostic.
Stockez aussi l'heure fournie par la source et l'heure de réception par votre application. Ce sont deux questions différentes : quand le fournisseur dit que l'événement s'est produit, et quand votre système l'a réellement observé. Cet écart aide à mesurer le retard d'ingestion et à expliquer pourquoi une décision a été prise avant d'avoir vu la confirmation.
Choisir entre interrogation périodique et notifications poussées
L'interrogation périodique limite le nombre de composants et rend le rejeu plus lisible. Son coût, c'est une latence : un événement peut rester inconnu jusqu'au prochain cycle. Choisissez la fréquence en fonction des limites du fournisseur, du volume et du délai acceptable pour décider d'un repli - pas en partant du principe que tout arrive « tout de suite ».
Si vous avez besoin d'une décision plus rapide que la fréquence d'interrogation, ajoutez des notifications poussées (authentifiées, avec des protections contre les rejeux, la disponibilité et la reprise). Dans ce cas, une approche pragmatique consiste à recevoir les notifications pour accélérer le traitement, puis garder l'interrogation par curseur comme mécanisme de réconciliation. Ce n'est pas obligatoire partout : augmentez la complexité quand le besoin de latence est réel, mesuré.
Le curseur lui-même doit être durable et surveillé. Une stagnation prolongée peut vouloir dire que le collecteur est arrêté, qu'une page échoue en boucle ou que l'API fournisseur a changé. Surveillez l'âge du dernier événement traité et le temps écoulé depuis la dernière progression, pas seulement un état « actif ».
Quels indicateurs suivre et comment déployer progressivement
Les tableaux de bord du fournisseur servent pendant un incident, mais ils ne vous disent pas si votre application a vérifié la suppression avant le routage. Les alertes prioritaires doivent donc porter sur vos décisions et sur les invariants de votre système.
Commencez par suivre :
- les tentatives autorisées pour une destination déjà supprimée (alerte rapide : c'est une violation de règle) ;
- le taux de repli par challenge, canal et région ;
- les événements classés comme destinataire définitivement invalide ;
- le retard d'ingestion et la stagnation du curseur ;
- les statuts inconnus et les tentatives présentant des preuves terminales contradictoires ;
- le nombre de challenges expirés, consommés ou bloqués par leurs limites de tentatives.
Préférez des dimensions à faible cardinalité : canal, région, motif normalisé. Évitez d'utiliser des adresses, des numéros ou des empreintes individuelles comme étiquettes de métriques : c'est un risque de confidentialité et ça fait exploser les séries temporelles. Gardez ces détails dans un stockage d'audit, avec des accès contrôlés.
Alerter sans transformer chaque variation en incident
Alerter sur chaque OTP échoué crée du bruit. À l'inverse, attendre que « le volume double » peut faire passer longtemps des tentatives vers des destinations invalides. Séparez donc les seuils par gravité : alerte immédiate en cas de contournement d'une suppression, puis alerte de tendance sur la hausse des replis, des rejets permanents ou du retard d'ingestion.
Ajustez les seuils à partir de volumes réels. Trop strict, et vous finissez par ignorer l'alerte. Trop permissif, et vous détectez tard. Chaque changement de seuil doit être consigné avec sa justification : sinon, vous aurez du mal à comprendre pourquoi l'équipe n'alerte plus... ou pourquoi elle alerte soudainement tout le temps.
Testez avec des événements synthétiques : doublon, ordre inversé, rejeu d'un curseur, statut inconnu, expiration du SMS principal, suppression du canal de repli et exécution simultanée de plusieurs travailleurs. Ce ne sont pas des cas théoriques : ils valident l'historique, l'idempotence et la robustesse des décisions dans des conditions « normales » en production.
Préparer les messages et le déploiement
Le contenu joue aussi dans la livraison. Pour les SMS, l'encodage influence le découpage en segments : certains caractères basculent dans des alphabets différents. Gardez le texte court, testez l'encodage réel du modèle et versionnez l'artefact envoyé (le modèle/templating), plutôt que d'enregistrer le corps rendu contenant le code. Un caractère inattendu peut augmenter le nombre de segments, donc le coût et le délai.
Pour les e-mails, la configuration d'expédition compte. SPF, DKIM et DMARC contribuent à l'authentification et à la réputation du domaine, mais leur configuration dépend notamment du volume et des exigences du fournisseur. Et surtout : un e-mail « accepté » par l'API n'est pas une preuve de réception. Les rebonds ensuite doivent alimenter la classification et, si nécessaire, déclencher une suppression de la destination.
Déployez progressivement : commencez par un seul fournisseur et un seul canal principal, puis ajoutez le repli avec ses règles séparées. Vérifiez les événements de bout en bout en environnement de test, puis comparez les décisions de l'application aux preuves du prestataire. Le bon critère n'est pas le nombre de composants déployés : c'est votre capacité à répondre vite à la question « Pourquoi ce message est-il parti ? ».
Conclusion : rendre chaque décision explicable
Une architecture OTP fiable ne dépend pas d'un tableau de bord plus sophistiqué. Elle tient sur des frontières claires : un challenge séparé de ses tentatives, une vérification de suppression avant chaque envoi, des événements conservés sans écrasement et un curseur qui ne progresse qu'après une validation durable.
Commencez par ces invariants, puis instrumentez le taux de repli, les destinations invalides et le retard d'ingestion. Testez les rejeux et les événements désordonnés avant de complexifier le transport. Et si, pendant un incident, vous devez « reconstruire l'histoire » à partir d'une cellule de statut mutable ou d'un écran du prestataire, l'auditabilité n'est pas encore suffisante.
Prochaine étape : vérifiez votre flux actuel de bout en bout et repérez la première décision qui n'est pas justifiée par une preuve conservée. C'est presque toujours là que le chantier doit commencer.
Comment décider d'un repli sans renvoyer vers une destination invalide
Un compte peut rester autorisé à se connecter alors qu'une de ses coordonnées ne doit plus recevoir de messages. C'est un point important : invalider un numéro ne devrait pas, à lui seul, désactiver le compte. De la même manière, l'échec d'un SMS ne doit pas automatiquement déclencher un e-mail, sans vérifier que cette autre destination est bien autorisée par la politique du challenge.
Avant chaque tentative, appliquez un ordre de décision explicite :
Le point 6 est celui qui casse le plus souvent l'auditabilité. Pourtant, refuser une tentative est aussi une décision à tracer. Ça permet de répondre sans deviner : le système n'a-t-il rien envoyé parce que la destination était supprimée ? Parce que le challenge a expiré ? Ou parce que le nombre d'essais était atteint ?
Distinguer une erreur temporaire d'une destination invalide
Tous les échecs ne justifient pas une suppression. Un délai peut venir d'un incident temporaire, d'une congestion opérateur ou du fait qu'un événement de livraison n'a pas encore été reçu. À l'inverse, une preuve qu'une adresse n'existe pas (ou qu'un numéro est définitivement invalide) justifie souvent une suppression durable ou prolongée, selon la politique que vous définissez.
La transition doit aussi être idempotente : traiter deux fois le même événement ne doit ni créer deux suppressions contradictoires, ni réactiver une destination par accident. Conservez l'identifiant de l'événement à l'origine de la décision, la date et la règle appliquée. Cette trace rend la suppression explicable et facilite la révision si l'utilisateur met à jour ses coordonnées.
Côté implémentation, vous pouvez normaliser les motifs internes, par exemple :
livré,échec_temporaire,destinataire_invalide,inconnu. Conservez aussi le statut brut du fournisseur. Un statut reçu « demain » ne doit pas être transformé silencieusement en échec permanent : une mauvaise classification entraîne soit un repli injustifié, soit l'empêchement d'un envoi légitime.Éviter de divulguer les coordonnées dans les métriques
Pour suivre les tentatives sans exposer les coordonnées, utilisez une empreinte stable générée avec une méthode adaptée à votre modèle de menace. Une empreinte trop simple peut être devinée par dictionnaire, surtout pour des e-mails. L'accès aux données permettant de retrouver une destination doit rester limité et justifié.
En France et dans l'Union européenne, la minimisation, la durée de conservation et les droits d'accès ne devraient pas être ajoutés « après coup ». Le RGPD ne se résume pas à chiffrer une table : il faut savoir pourquoi une donnée est conservée, qui peut la consulter et quand elle est supprimée. Les décisions juridiques et de conformité fixent la politique ; l'architecture fournit les moyens de la respecter et de la vérifier.