Générer et stocker un token de magic link en Go
Ou : Comment fabriquer une clé, la poster, et s’arranger pour ne jamais pouvoir la reproduire
Un token de magic link est un objet étrange. Tu le fabriques à partir de rien, tu l’envoies aussitôt par courrier électronique à quelqu’un, et tu ne veux surtout pas en garder de copie. Ta base de données doit pouvoir le reconnaître quand il revient, sans jamais être capable de le recréer. C’est une chaîne aléatoire, une empreinte SHA-256, une durée de vie de quinze minutes et une ligne en base qui disparaît au premier usage. Tout le reste de la sécurité du système repose sur ces quatre détails, et chacun a une façon discrète de mal tourner.
Le premier article de la série sur le magic link a posé le décor : trois interfaces, TokenStore, Mailer et Clock, et la promesse qu’elles ne bougeraient plus. Celui-ci remplit la première. On fabrique le secret, on le hache, on le range, on le fait expirer, et on vérifie tout ça avec des tests qui n’attendent pas quinze minutes.
Générer un token sécurisé en Go : aléa, hachage et durée de vie
Le hasard qui ne l’est pas
Commençons par l’erreur la plus répandue, parce qu’elle compile, qu’elle passe les tests et qu’elle a l’air parfaitement raisonnable.
| |
math/rand est un générateur pseudo-aléatoire. Son travail est de produire des nombres qui ont l’air répartis au hasard, pour une simulation, un test de charge ou un jeu. Son travail n’est pas d’être imprévisible pour quelqu’un qui connaît son état. La documentation de math/rand/v2 le dit sans détour : ses sorties « peuvent être facilement prévisibles, quelle que soit la façon dont il est initialisé », et il ne doit pas servir à un usage sensible1.
Ce n’est donc pas une approximation d’un bon générateur. C’est un autre outil, qui répond à une autre question. Utiliser math/rand pour un token, c’est choisir un cadenas à combinaison pour sa robustesse sans se demander qui a imprimé la combinaison au dos.
La bonne version tient en quatre lignes.
| |
crypto/rand puise dans le générateur du système d’exploitation. Depuis Go 1.24, sorti en février 2025, rand.Read ne renvoie plus jamais d’erreur : il remplit le tampon en entier ou fait planter le programme, parce qu’un serveur qui continue de tourner sans source d’aléa est pire qu’un serveur arrêté. Trente-deux octets donnent 256 bits d’entropie, le double des 128 bits que la bibliothèque standard considère elle-même comme suffisants contre la force brute2. L’encodage base64.RawURLEncoding produit 43 caractères qui passent dans une URL sans échappement ni signe = final.
Remarque au passage la signature : NewToken rend deux valeurs, et elles partent dans deux directions opposées. La première va au Mailer. La seconde va au TokenStore. Aucune ne croise l’autre.
Ne garde jamais ce que tu envoies
C’est l’idée centrale de l’article, et DevOps Dave l’avait ratée dans le premier. On ne stocke pas le token. On stocke son empreinte.
La raison est simple quand on imagine le pire. Un jour, un dump de ta base traîne quelque part : une sauvegarde mal rangée, une injection SQL en lecture seule, un disque revendu sans avoir été effacé. Si la table contient les tokens en clair, chaque ligne encore valide est une session offerte. Il suffit de construire l’URL et de cliquer. Si la table ne contient que des hash SHA-256, le dump ne donne rien d’utilisable : retrouver 32 octets aléatoires à partir de leur empreinte demande de parcourir un espace de 2256 possibilités, ce qui prendrait plus longtemps que l’existence du Soleil.
Le premier article l’expliquait déjà en note : SHA-256 suffit ici, alors qu’un mot de passe humain exige un algorithme lent comme bcrypt ou Argon2. Un mot de passe a peu d’entropie, et la lenteur de l’algorithme compense. Un token de 256 bits n’a rien à compenser.
Le schéma Postgres découle directement de la signature Save(ctx, hash, userID, expiresAt) fixée dans le premier article.
| |
Deux choix méritent une phrase. La contrainte CHECK refuse toute valeur qui ne fait pas exactement 32 octets : si un jour quelqu’un branche le mauvais argument et insère le token brut à la place du hash, Postgres refuse l’insertion au lieu de l’accepter en silence. Et il n’y a pas de colonne consumed_at. Un token consommé n’a aucune raison de survivre, et la ligne est supprimée au moment même où elle est lue. Une ligne qui n’existe plus ne peut pas être rejouée.
L’implémentation pgx de Save est volontairement ennuyeuse.
| |
Et Consume, que le quatrième article détaillera, fait la lecture et la suppression dans une seule requête : DELETE ... WHERE token_hash = $1 AND expires_at > $2 RETURNING user_id. Deux clics simultanés sur le même lien, une seule ligne à supprimer, un seul gagnant.
Quinze minutes, mesurées par une horloge qui obéit
Un lien de connexion doit expirer vite. Il dort dans une boîte mail, il est parfois transféré, il est parfois ouvert sur un ordinateur partagé. L’OWASP ne fixe pas de durée précise et demande seulement que ces tokens soient à usage unique et expirent « après une période appropriée »3. Quinze minutes laissent le temps de passer du téléphone à l’ordinateur sans laisser traîner une clé valide jusqu’au lendemain.
Le service calcule l’échéance à partir de l’interface Clock, jamais de time.Now() directement.
| |
C’est ici que le découpage en interfaces rapporte immédiatement. Pour tester l’expiration, pas besoin de time.Sleep(15 * time.Minute), ni d’une base de données : une fausse horloge qu’on avance à la main, et un TokenStore en mémoire. C’est exactement la frontière testable par les interfaces dont je parlais dans la série sur les tests en Go.
| |
Le test tourne en trois millisecondes. Il teste quinze minutes et une seconde.
Le moment où Dave découvre l’horloge
DevOps Dave : J’ai corrigé mon générateur de tokens. Avant, j’avais oublié d’initialiser math/rand. Maintenant je fais rand.Seed(time.Now().UnixNano()) au démarrage. C’est aléatoire.
Security Sarah : C’est aléatoire au moment où ton serveur démarre, à la nanoseconde près.
DevOps Dave : Exactement. Personne ne connaît la nanoseconde.
Security Sarah : Ton endpoint /health renvoie l’uptime. Ça me donne l’heure de démarrage à la seconde. Il reste un milliard de graines possibles. Ma carte graphique en teste quelques millions par seconde.
DevOps Dave : …
Security Sarah : Et ensuite, je n’ai même plus besoin de deviner les tokens. Je les calcule. Ceux d’hier, et ceux de demain.
Dave n’a pas écrit de bug au sens habituel. Il a demandé à un outil de faire quelque chose qu’il n’a jamais promis de faire4.
Le chemin du retour, là où les tokens se déguisent
Un token part en toute sécurité. Il revient dans une URL, c’est-à-dire sous la forme d’une chaîne que n’importe qui peut fabriquer. La fonction qui l’accueille doit donc supposer que ce qu’elle reçoit est hostile.
| |
Le mot important est Strict(), dans la déclaration de encoding. Il y a un piège dans l’arithmétique de base64. 32 octets font 256 bits, mais 43 caractères base64 en portent 258. Les deux bits en trop du dernier caractère sont ignorés par le décodeur par défaut, et quatre écritures différentes décodent vers le même token. En mode strict, le décodeur exige que ces bits soient nuls, comme le prévoit la section 3.5 de la RFC 4648. Une seule écriture est acceptée par token.
La documentation ajoute une nuance que j’aurais ratée sans la lire : même en mode strict, le décodeur ignore les retours à la ligne. C’est la double vérification de longueur qui ferme cette porte. Une chaîne de 43 caractères qui contient un \n ne décode que 42 caractères utiles et produit moins de 32 octets, ce que la seconde condition rejette.
Ce genre de cas limite, on le liste dans des tests pilotés par une table, et on laisse le fuzzer chercher ceux qu’on n’a pas imaginés.
| |
J’ai fait l’expérience de retirer Strict(). Le fuzzing natif de Go a trouvé le contre-exemple en moins d’un dixième de seconde : quarante-deux zéros suivis d’un 1, une chaîne acceptée alors que son écriture canonique se termine par un 0. Avec Strict(), trente secondes et plusieurs millions d’entrées plus tard, rien.
Est-ce grave, quatre écritures pour un même token ? Pas directement, puisque toutes mènent au même hash et donc à la même ligne. Mais c’est le genre de tolérance qui finit par compter le jour où un cache, un journal ou une liste de révocation compare les chaînes plutôt que les empreintes. Une seule écriture par secret, c’est une question de moins à se poser.
Et la comparaison en temps constant, dans tout ça
La recommandation revient dans tous les guides : comparer les secrets avec crypto/subtle.ConstantTimeCompare, pour qu’un attaquant ne déduise rien du temps de réponse. Une comparaison naïve s’arrête au premier octet différent. En mesurant les durées, on devine le secret octet par octet.
Sauf qu’ici, on ne compare rien nous-mêmes. On cherche le hash dans un index Postgres. Et c’est le hachage qui neutralise l’attaque par chronométrage : l’attaquant choisit le token qu’il envoie, mais il ne contrôle pas le hash qui en sort. Même si le temps de recherche lui révélait quelques octets du hash d’une ligne existante, il ne pourrait pas fabriquer un token dont l’empreinte commence par ces octets. Le hachage rend l’information inexploitable.
ConstantTimeCompare reste nécessaire dès qu’un secret est comparé tel quel. Ce sera le cas dans le quatrième article, avec le nonce lié au navigateur. C’est aussi le cas dans le schéma « sélecteur et vérificateur » décrit par Paragon Initiative : la recherche se fait sur une moitié publique du token, et l’autre moitié est comparée en temps constant5. Pour une recherche par hash indexé, la bonne réponse est moins spectaculaire : le hachage fait déjà le travail.
Le ménage, ou la table qui ne grossit pas
Reste un problème terre à terre. Chaque demande crée une ligne. Les liens cliqués disparaissent grâce à Consume. Les autres, ceux qu’on n’a jamais ouverts, restent là indéfiniment, expirés mais encore présents.
Deux stratégies se valent. La première est un DELETE opportuniste à chaque insertion, qui ne demande aucune infrastructure mais ajoute une requête sur le chemin critique. La seconde est une tâche périodique, et c’est celle que je retiens.
| |
Un time.Ticker d’une heure suffit. L’index sur expires_at évite de parcourir toute la table, et la requête reçoit l’heure de Clock plutôt que now() pour rester testable. Ce nettoyage n’a aucun rôle de sécurité : un token expiré est déjà refusé par Consume. Il sert seulement à garder une table dont la taille reflète l’activité réelle plutôt que l’historique complet des distractions de tes utilisateurs.
Ce qui sort de la forge
À la fin, le compte est court. Trente-deux octets de crypto/rand. Un encodage base64url strict. Un hash SHA-256 dans une table dont la contrainte refuse tout le reste. Une échéance calculée par une horloge qu’on peut avancer à la main. Une ligne supprimée au premier usage, et une tâche qui ramasse les autres. Aucune dépendance en dehors de pgx, et tout le code de cet article a été compilé, testé et soumis au fuzzing sous Go 1.27.
Le token est prêt. Il ne reste qu’à l’envoyer, et c’est là que les ennuis commencent vraiment : l’endpoint qui reçoit une adresse mail doit répondre sans jamais révéler si cette adresse correspond à un compte, et sans devenir une machine à spammer les boîtes des autres.
La base de données, elle, a atteint l’état idéal pour une base qui garde des secrets : elle ne sait rien d’utile. Si on lui vole tout, le voleur repart avec une liste d’empreintes de clés qu’il ne peut pas refaire. Il est rare qu’un système soit aussi fier de son ignorance.
Pendant longtemps, c’était encore plus simple que ça. Jusqu’à Go 1.20, sorti en février 2023, le générateur global de
math/randse comportait par défaut comme si on avait appeléSeed(1)au démarrage, à l’image de la bibliothèque standard du C. Un programme qui oubliait l’initialisation produisait la même suite de « hasard » à chaque lancement, sur toutes les machines du monde. Go 1.20 a introduit l’initialisation aléatoire automatique, etmath/rand/v2(Go 1.22) utilise désormais par défaut ChaCha8, un générateur résistant à la prédiction. L’article du blog Go consacré à math/rand/v2 le résume bien : utilisermath/rand/v2par accident est « moins catastrophique » qu’avant, maiscrypto/randreste le seul outil prévu pour les secrets. ↩︎Go 1.24 a aussi ajouté
rand.Text(), qui renvoie directement une chaîne aléatoire en base32 contenant « au moins 128 bits » d’aléa, soit 26 caractères aujourd’hui, et la documentation de crypto/rand précise qu’une version future pourra l’allonger. C’est la solution idéale pour un code à recopier ou un mot de passe temporaire. Je ne l’utilise pas ici pour deux raisons : le parseur a besoin d’une longueur fixe et connue d’avance, et je veux hacher les octets, pas leur représentation textuelle. Mais si tu n’as besoin que d’une chaîne aléatoire, c’est la ligne à écrire. ↩︎La fiche OWASP sur la réinitialisation de mot de passe traite des tokens envoyés par lien, qui sont exactement la même mécanique qu’un magic link : générés avec un algorithme cryptographiquement sûr, assez longs pour résister à la force brute, stockés de manière sécurisée, à usage unique et expirant après une période appropriée. Le texte ne donne pas de durée chiffrée, ce qui est honnête : la bonne durée dépend de la vitesse de livraison de tes mails, et ça, l’OWASP ne peut pas le savoir. ↩︎
Deux détails de la documentation de math/rand aggravent le cas de Dave. D’abord, les graines qui ont le même reste dans la division par 231 - 1 produisent la même suite : quelle que soit la précision de l’horloge, il n’existe qu’un peu plus de deux milliards de suites possibles, ce qui borne la recherche de Sarah. Ensuite,
rand.Seedest obsolète depuis Go 1.20 et ne fait plus rien du tout depuis Go 1.24. Sur un Go récent, la ligne de Dave est donc sans effet, son générateur est initialisé au hasard au démarrage, et il reste exactement aussi inadapté à un secret qu’avant. La ligne survit néanmoins dans des milliers de dépôts, recopiée de tutoriels écrits à une époque où elle était la moins mauvaise option. Le code, comme les proverbes, continue de circuler longtemps après que son contexte a disparu. ↩︎Le billet de Paragon Initiative, « Split Tokens: Token-Based Authentication Protocols without Side-Channels » (février 2017), propose de couper un token de 32 octets en deux moitiés de 16 : le sélecteur sert à la recherche en base, le vérificateur est haché et comparé en temps constant. Même si un attaquant arrive à trouver un sélecteur valide en chronométrant les réponses, le vérificateur ne fuit jamais. C’est plus robuste qu’une simple recherche par hash quand la base n’est pas indexée sur l’empreinte, au prix d’un format de token un peu moins simple. ↩︎