# Vérifier un magic link : usage unique et session

> Quatrième article de la série Authentification Magic Link en Go. Le clic sur le lien est le seul moment où ton système et ton utilisateur tombent d'accord sur son identité. Ce moment tient en une requête SQL, et tout le reste sert à ce que personne ne le vive à sa place.


*Ou : Comment remettre une clé à son destinataire quand le facteur l'a essayée en route*

Dans toute la vie d'un magic link, il existe un seul instant où ton système et ton utilisateur tombent d'accord sur son identité. Ce n'est pas la demande, puisque l'endpoint répond la même chose à tout le monde. Ce n'est pas l'envoi, puisque le lien voyage dans un canal que tu ne contrôles pas. C'est le clic. Et côté serveur, ce clic tient en une requête SQL qui trouve une ligne, la supprime et rend un identifiant de compte. Tout ce qui l'entoure, la page intermédiaire, le cookie, la session, n'existe que pour garantir une chose : que cet instant arrive une fois, dans le bon navigateur, et à personne d'autre.

Le [troisième article, sur l'endpoint qui demande le lien](/blog/endpoint-magic-link-go-anti-enumeration-rate-limiting/), s'est arrêté au départ du mail. Le [deuxième, sur le token](/blog/token-magic-link-go-crypto-rand-hachage/), avait déjà écrit la moitié de ce qui suit : un `Consume` qui lit et supprime dans le même `DELETE ... RETURNING`. Celui-ci assemble le reste, dans l'ordre où les requêtes arrivent : un visiteur qui n'est pas ton utilisateur, un cookie qui dit d'où vient la demande, une requête qui ne désigne qu'un gagnant, puis une session.

## Vérifier un token à usage unique en Go : scanners de liens, cookie navigateur et session
{.subtitle}

### Le visiteur qui clique avant ton utilisateur

Le premier article l'annonçait en note : les passerelles de sécurité des messageries d'entreprise, Safe Links de Microsoft en tête, analysent les liens d'un message avant même de le livrer, et ouvrent au besoin ceux qu'elles ne connaissent pas[^1]. Pour ton serveur, rien ne distingue cette visite de celle de ton utilisateur. Si la vérification se fait sur la requête GET, le robot consomme le token, ton utilisateur arrive trente secondes plus tard sur un lien mort, et il écrit au support que ta connexion ne marche pas. Il a raison.

La règle qui règle le problème n'a rien de neuf. La RFC 9110 range GET parmi les méthodes dites sûres, dont la sémantique est « essentially read-only »[^2] : suivre un lien n'est pas censé changer quoi que ce soit sur le serveur. Consommer un token sur un GET, c'est rompre ce contrat, et la plupart des robots s'en tiennent au GET. Le GET affiche donc une page, et c'est un POST qui consomme.

```go
// Routes wires both methods on the same path (Go 1.22 patterns).
func (v *Verifier) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /auth/verify", v.Confirm)
	mux.HandleFunc("POST /auth/verify", v.Consume)
}

// Confirm answers GET: it shows a button, and changes nothing.
func (v *Verifier) Confirm(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Referrer-Policy", "no-referrer")
	w.Header().Set("Cache-Control", "no-store")
	_, sameBrowser := browserNonce(r)
	confirmPage.Execute(w, struct {
		Token       string
		SameBrowser bool
	}{r.URL.Query().Get("token"), sameBrowser})
}
```

La page contient un formulaire, le token en champ caché et un bouton « Me connecter ». [`Referrer-Policy: no-referrer`](https://developer.mozilla.org/fr/docs/Web/HTTP/Reference/Headers/Referrer-Policy) supprime l'en-tête `Referer` : les navigateurs récents n'envoient déjà que l'origine aux autres sites, mais l'URL complète, token compris, part vers ton propre serveur, et tes journaux d'accès la garderaient. L'OWASP recommande ce réglage sur toute page qui reçoit un token dans son URL. `Cache-Control: no-store` évite qu'un cache conserve une page qui contient un secret encore valide, et la page ne charge rien de l'extérieur.

Le prix, c'est un clic de plus. Il a un effet secondaire que j'apprécie : le bouton confirme une intention, et un lien ouvert par erreur ne connecte personne. Reste le robot plus malin, qui exécute la page et soumet le formulaire. C'est le travail du cookie.

### Le cookie que j'avais promis, et la faille que je n'avais pas vue

Deux articles plus tôt, j'annonçais que `ConstantTimeCompare` servirait ici, pour comparer « le nonce lié au navigateur ». Le plan était simple. Au moment de la demande, l'endpoint pose dans le navigateur un cookie qui contient un nonce aléatoire. Le lien envoyé par mail transporte l'empreinte de ce nonce dans un second paramètre. Au clic, le serveur hache le cookie et compare le résultat au paramètre, en temps constant. Un lien transféré, ouvert ailleurs ou visité par un robot échoue faute de cookie.

C'est en écrivant le test que j'ai vu le trou. Celui qui détient le lien n'a pas le cookie de ton utilisateur, c'est vrai. Mais il a son propre navigateur, où il pose le cookie qu'il veut, et il peut réécrire le second paramètre du lien avec l'empreinte de son propre nonce. La comparaison passe, en temps parfaitement constant. Rien ne reliait le paramètre au token : j'avais fabriqué une serrure livrée avec sa clé de rechange, scotchée sur la porte.

La correction supprime la comparaison au lieu de la protéger. Le nonce n'a pas à voyager dans le lien. Il entre dans l'empreinte stockée.

```go
// Bind derives the stored fingerprint from the token hash and the browser
// fingerprint. Both are 32 bytes, so the concatenation is unambiguous.
// The link alone is not enough, the cookie alone is not enough.
func Bind(tokenHash, browser []byte) []byte {
	h := sha256.New()
	h.Write(tokenHash)
	h.Write(browser)
	return h.Sum(nil)
}
```

Concrètement, le handler de l'article précédent pose le cookie et confie à la file l'adresse et l'empreinte SHA-256 du nonce. Le travailleur fabrique le token comme avant, mais range en base `Bind(hash, browser)` au lieu du hash seul. Le lien ne change pas : un seul paramètre, le token. Au clic, le serveur recalcule la même empreinte à partir du token reçu et du cookie présent. Si l'un des deux manque ou vient d'ailleurs, elle ne correspond à aucune ligne, le `DELETE` ne supprime rien, et le lien reste intact pour son destinataire.

C'est le même raisonnement que dans le deuxième article à propos du temps constant : quand la recherche se fait sur une empreinte, il ne reste rien à comparer soi-même. Les trois interfaces du premier article n'ont pas bougé, et la contrainte `CHECK` sur 32 octets non plus, puisque `Bind` rend 32 octets.

![Illustration de deux moitiés d'un médaillon gravé, l'une éclairée d'ambre, l'autre de bleu froid, dont les bords découpés s'emboîtent](/images/verifier-magic-link-go-usage-unique-session-jwt-liaison.original.webp "Le lien porte une moitié, le navigateur l'autre. Seule la paire ouvre.")

Le cookie lui-même a quatre attributs, et chacun a une raison.

```go
http.SetCookie(w, &http.Cookie{
	Name:     bindCookie, // "__Host-ml_bind"
	Value:    encoding.EncodeToString(nonce),
	Path:     "/",
	MaxAge:   int((15 * time.Minute).Seconds()),
	Secure:   true,
	HttpOnly: true,
	SameSite: http.SameSiteLaxMode,
})
```

Le préfixe `__Host-` oblige le navigateur à refuser ce cookie s'il n'est pas `Secure`, porte un `Domain` ou un autre chemin que `/`[^3] : un sous-domaine compromis ne peut pas en poser un faux. `HttpOnly` le cache au JavaScript de la page. La durée suit celle du lien, et le handler repose le cookie à chaque demande pour couvrir le lien le plus récent. Reste `SameSite`, où ma première version disait `Strict`, par réflexe de prudence. Or le clic dans un webmail est une navigation venue d'un autre site : un cookie `Strict` n'y est pas joint, et la page de confirmation croyait avoir affaire à un autre navigateur, y compris dans le bon. `Lax` accompagne ce GET de premier niveau, et le POST qui suit part de ta propre page, donc de ton propre site.

Soyons précis sur ce que ce cookie protège. Il bloque le lien transféré, le robot qui soumet les formulaires, et la connexion forcée, où un attaquant fait cliquer sa victime sur son propre lien pour l'enfermer dans son compte à lui. Il ne bloque pas celui qui contrôle la boîte mail et demande lui-même le lien depuis son navigateur : le premier article le disait, qui tient la boîte mail tient le compte. Et il a un coût : un lien demandé sur l'ordinateur et ouvert sur le téléphone échoue. C'est aussi la contrainte du flux PKCE de Supabase[^4], et je l'accepte. La page invite alors à redemander un lien depuis l'appareil où l'on se trouve.

### Une requête, un seul gagnant

Arrive le POST, avec le token dans le corps et le cookie dans les en-têtes.

```go
// Consume answers POST: one statement decides, everything else is plumbing.
func (v *Verifier) Consume(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	hash, err := HashToken(r.PostFormValue("token"))
	nonce, ok := browserNonce(r)
	if err != nil || !ok {
		v.fail(w)
		return
	}
	browser := sha256.Sum256(nonce)

	userID, err := v.store.Consume(r.Context(), Bind(hash, browser[:]))
	if errors.Is(err, ErrTokenNotFound) {
		v.fail(w)
		return
	}
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if err := v.start(r.Context(), w, userID); err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	http.SetCookie(w, &http.Cookie{Name: bindCookie, Path: "/", MaxAge: -1,
		Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
	http.Redirect(w, r, "/", http.StatusSeeOther)
}
```

Toute la décision tient dans `v.store.Consume`, c'est-à-dire, en production, dans la requête du deuxième article.

```sql
DELETE FROM magic_link_tokens
WHERE token_hash = $1 AND expires_at > $2
RETURNING user_id;
```

La tentation consiste à l'écrire en deux temps : un `SELECT` pour vérifier que la ligne existe et n'a pas expiré, puis un `DELETE`. C'est lisible, c'est ce qu'on écrirait pour expliquer l'algorithme à un collègue, et c'est faux. Entre les deux requêtes, un second clic lit la même ligne, toujours présente. Avec un seul `DELETE ... RETURNING`, c'est Postgres qui tranche : au niveau d'isolation READ COMMITTED, celui par défaut, la seconde transaction attend que la première se termine, puis ignore la ligne si la première l'a supprimée[^5]. Un seul `RETURNING` rend un identifiant.

Je n'ai pas voulu le croire sur parole. Le test lance vingt goroutines sur le même token, contre un vrai PostgreSQL 18, et recommence cent fois.

```go
func TestConsumeHasOneWinner(t *testing.T) {
	pool := pgPool(t)
	store := &PGStore{db: pool, clock: systemClock{}}
	for round := range 100 {
		consume := func(h []byte) (string, error) { return store.Consume(context.Background(), h) }
		if wins := race(t, store, consume); wins != 1 {
			t.Fatalf("round %d: %d winners, want 1", round, wins)
		}
	}
}
```

La fonction `race` enregistre un token, retient vingt goroutines derrière un canal qu'elle ferme d'un coup, et compte celles qui obtiennent un identifiant. Résultat : un gagnant par ronde, cent rondes sur cent. La même fonction appliquée à la version en deux requêtes donne vingt gagnants dans 96 à 99 rondes sur 100, selon les exécutions. Pas « parfois deux ». Vingt, presque à chaque fois : les vingt `SELECT` passent avant le premier `DELETE`.

![Illustration de plusieurs mains tendues vers une clé dorée posée sur un coussin de velours, sous un faisceau de lumière ambrée](/images/verifier-magic-link-go-usage-unique-session-jwt-un-gagnant.original.webp "Vingt clics simultanés, une seule ligne supprimée, un seul gagnant.")

Et voici le détail qui mérite un paragraphe à lui seul : `go test -race` est resté vert sur la version fausse. Le détecteur surveille la mémoire de ton programme, pas les lignes de ta base. C'est la distinction entre [data race et race condition](/blog/concurrence-race-conditions-go/) de la série sur les tests : aucun octet partagé sans verrou, et pourtant vingt sessions pour un seul lien. Ce qui attrape ce bogue, c'est un test qui compte les gagnants, ici contre un Postgres local, et [avec Testcontainers](/blog/tests-integration-testcontainers-go/) dans un conteneur jetable.

### Le moment où Dave clique vingt fois

**DevOps Dave :** J'ai écrit la vérification. Un `SELECT`, je contrôle `expires_at`, un `UPDATE` pour remplir `consumed_at`, et je crée la session. J'ai même lancé les tests avec `-race`. Tout est vert.

**Security Sarah :** Combien de sessions si je clique vingt fois en même temps ?

**DevOps Dave :** Une. Sinon, le détecteur l'aurait vu.

**Security Sarah :** Le détecteur regarde ta mémoire. Ta course se passe dans Postgres, entre ton `SELECT` et ton `UPDATE`.

**DevOps Dave :** Mais c'est le même processus, les mêmes goroutines...

**Security Sarah :** Et la même ligne, lue vingt fois avant que le premier `UPDATE` passe. Tu n'as pas écrit un lien à usage unique. Tu as écrit un lien à usage unique par goroutine.

Dave a fait ce que tout le monde fait : il a traduit l'algorithme en requêtes, une étape par requête. Mais la base n'exécute pas un algorithme. Elle exécute des instructions, et n'importe laquelle peut se glisser entre deux autres.

### Un seul message d'erreur, et c'est voulu

Quand `Consume` ne trouve rien, le serveur ne sait pas pourquoi : expiré, déjà utilisé, autre navigateur ou token tapé au hasard aboutissent à la même absence de ligne. La page d'erreur dit donc une seule chose : ce lien n'est plus valable, demandez-en un nouveau. Distinguer « expiré » de « déjà utilisé » demanderait de garder les lignes consommées, ce que le deuxième article a refusé. Le seul message qui serait une fuite parlerait du compte, et il ne peut pas apparaître ici : la vérification ne consulte jamais la table des comptes.

### Ouvrir la session : un cookie opaque ou un jeton signé

`Consume` a rendu un identifiant. Reste à transformer une preuve ponctuelle en état durable. Comme la recherche de compte dans l'article précédent, la session n'est pas un contrat du paquet `auth` : l'application la fournit, sous la forme d'une fonction.

```go
// StartSession is provided by the application, like LookupUser: the auth
// package proves who the user is, the application decides what a session is.
type StartSession func(ctx context.Context, w http.ResponseWriter, userID string) error
```

Mon implémentation par défaut reprend la recette du token : 32 octets de `crypto/rand` dans un cookie, leur empreinte dans une table `sessions`, et une durée de vie plus longue.

```go
func (s *PGSessions) Start(ctx context.Context, w http.ResponseWriter, userID string) error {
	raw, hash := NewToken() // same recipe as the magic link, longer life
	_, err := s.db.Exec(ctx,
		`INSERT INTO sessions (id_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
		hash, userID, s.clock.Now().Add(s.ttl))
	if err != nil {
		return err
	}
	http.SetCookie(w, &http.Cookie{
		Name:     "__Host-session",
		Value:    raw,
		Path:     "/",
		MaxAge:   int(s.ttl.Seconds()),
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
	return nil
}
```

Trois détails. La session est toujours neuve : aucun identifiant antérieur à la connexion n'est réutilisé, ce qui ferme la porte à la fixation de session, et ses 256 bits d'aléa quadruplent le plancher de 64 bits fixé par l'OWASP[^6]. Et `SameSite=Lax`, cette fois par ergonomie : en `Strict`, un utilisateur connecté qui arrive depuis un lien externe aurait l'air déconnecté sur la première page. Le handler répond ensuite par un `303 See Other` vers l'accueil, la redirection que la RFC 9110 prévoit après un POST.

L'autre voie est le jeton signé : un JWT qui porte l'identité et une échéance, vérifiable sans base de données. Le tableau résume le choix.

| | Session opaque en base | JWT signé |
|---|---|---|
| Vérifier une requête | Une lecture en base ou en cache | Une vérification de signature, sans base |
| Révoquer | Un `DELETE` | Impossible avant l'échéance sans liste de révocation[^7] |
| Taille transportée | 43 caractères | Plusieurs centaines d'octets |
| Plusieurs services | Tous lisent la même table | Chacun vérifie avec la clé publique (RS256) |
| Complexité | Faible | Rotation des clés, refresh token, horloges |

Pour un site qui tient sur un serveur et une base, la session opaque gagne : elle se révoque en une requête, et « déconnecter tous mes appareils » tient en un `DELETE ... WHERE user_id = $1`. Le JWT devient intéressant quand plusieurs services vérifient une identité sans partager de base. C'est le choix de [l'authentification de Presentation Control](/projets/presentation-control-epic1-authentification/) : un access token RS256 de quinze minutes gardé en mémoire, un refresh token opaque de sept jours en cookie HttpOnly, renouvelé dans une seule transaction. Même là, remarque-le, la partie révocable est une ligne en base.

### Cinq tests, et un clic de trop

Les tests de la vérification reprennent le harnais de l'article précédent, avec son faux mailer et sa fausse horloge, et [httptest pour appeler les handlers](/blog/httptest-go-tester-apis-sans-serveur/) sans lancer de serveur. Le plus parlant est celui du lien détourné.

```go
func TestAnotherBrowserCannotUseTheLink(t *testing.T) {
	hs := newHarness("alice@example.com", "mallory@example.com")
	_, sess, mux := newVerifier(hs)
	token, aliceBind := requestLink(t, hs, "alice@example.com")
	_, malloryBind := requestLink(t, hs, "mallory@example.com")

	// Mallory got Alice's link, and has a perfectly valid binding cookie of her own.
	if rec := verify(mux, token, malloryBind); rec.Code != http.StatusBadRequest {
		t.Fatalf("Mallory: got %d", rec.Code)
	}
	if rec := verify(mux, token, aliceBind); rec.Code != http.StatusSeeOther {
		t.Fatalf("Alice after Mallory: got %d", rec.Code)
	}
	if sess.started.Load() != 1 {
		t.Fatalf("sessions started: %d", sess.started.Load())
	}
}
```

La deuxième assertion compte autant que la première : la tentative ratée n'a rien brûlé. Les quatre autres tests suivent le même moule. Un robot sans cookie laisse le lien intact, un lien sert une fois puis échoue, un lien expiré est refusé, et vingt POST simultanés ouvrent exactement une session.

Tout le code de cet article a été compilé et testé sous Go 1.27, `go vet` et le détecteur de courses compris, et le test de concurrence a tourné contre PostgreSQL 18. Il ne dépend que de la bibliothèque standard, de `pgx` et de `golang.org/x/time/rate`.

Le système est complet : une demande qui répond pareil à tout le monde, un token dont la base ne garde que l'empreinte, un lien qui ne sert qu'une fois et dans un seul navigateur, une session qu'on révoque d'un `DELETE`. Reste à savoir si tout ça valait mieux qu'une passkey, un code par SMS ou un bouton « Se connecter avec Google ». C'est le sujet du dernier article.

Quant au robot du service informatique, celui qui clique sur tous les liens avant tout le monde, il repart avec une page, un formulaire, et un bouton sur lequel il peut appuyer autant qu'il veut. Pour la première fois de sa carrière, il ne casse rien.

---

[^1]: Microsoft l'écrit sans détour dans [la documentation de Safe Links](https://learn.microsoft.com/en-us/defender-office-365/safe-links-about) : tant que la protection est active, « URLs are scanned prior to message delivery », et celles qui n'ont pas de réputation établie sont « detonated asynchronously in the background ». [Stytch décrit la suite](https://stytch.com/docs/multi-tenant-auth/authentication/magic-links/overview) : face à un magic link, le scanner « will end up consuming the token before the email actually makes its way into the user's inbox ». Supabase documente le même symptôme, avec Safe Links comme exemple.

[^2]: La [section 9.2.1 de la RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1) explique même pourquoi la distinction existe : laisser les robots d'indexation et le préchargement travailler « without fear of causing harm ». Et elle place la responsabilité au bon endroit : si une ressource déclenche une action non sûre, son propriétaire « MUST disable or disallow that action when it is accessed using a safe request method ». Le robot qui brûle ton lien est dans son droit. Toi, non.

[^3]: [MDN résume la règle](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#cookie_prefixes) : un cookie `__Host-` doit être posé avec `Secure` depuis une page HTTPS, sans `Domain` et avec `Path=/`, ce qui garantit qu'il n'est envoyé « only to the host that set them, and not to any other host on the domain ». Le navigateur refuse tout simplement un cookie qui porte le préfixe sans respecter ces conditions.

[^4]: La [documentation du flux PKCE de Supabase](https://supabase.com/docs/guides/auth/sessions/pkce-flow) le dit en une phrase : le code verifier est stocké localement, donc « the code exchange must be initiated on the same browser and device where the flow was started ». Nuance honnête : la même documentation propose, pour ce flux, un lien qui porte un `token_hash` vérifié côté serveur et ne dépend plus du navigateur. La liaison au navigateur est donc un choix, pas une fatalité.

[^5]: La [documentation de PostgreSQL sur READ COMMITTED](https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED) décrit exactement ce cas : la transaction qui arrive en second « will wait for the first updating transaction to commit or roll back », puis « will ignore the row if the first updater deleted it ». Son `DELETE` supprime zéro ligne, ce qui n'est pas une erreur, et `RETURNING` ne renvoie rien. C'est ce silence que `pgx` traduit en `ErrNoRows`, et le store en `ErrTokenNotFound`.

[^6]: La [fiche OWASP sur la gestion des sessions](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) impose de renouveler l'identifiant « after any privilege level change », l'authentification en tête, et exige « at least 64 bits of entropy ». Elle préfère `SameSite=Strict` et accepte `Lax`. Son exemple de cookie, `__Host-SessionID` avec `Secure`, `HttpOnly` et `Path=/`, ressemble beaucoup au nôtre, ce qui est rassurant pour lui comme pour moi.

[^7]: La [fiche OWASP sur les JSON Web Tokens](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html) le formule sans détour : un JWT utilisé comme session exige une solution d'invalidation, en pratique une liste de jetons révoqués, et avec elle les sessions « won't be completely stateless anymore ». Autrement dit, on finit par réinventer une table de sessions, en plus compliqué.

<div class="next-article">
<span class="next-article__label">Article suivant de la série</span>
<p class="next-article__title">Magic link, passkeys, OTP, OAuth : lequel choisir</p>
<p class="next-article__desc">Toutes les méthodes sans mot de passe déplacent la confiance quelque part : une boîte mail, une puce, un opérateur téléphonique ou Google. Le comparatif, une fois le coût réel d'une implémentation propre sous les yeux.</p>
</div>



