02Architecture5 min

Pourquoi j'exige un login dans des applications qui n'en ont pas besoin

Sans identité, pas de quota, pas de plafond de dépense, aucun moyen de couper l'accès à qui abuse. La friction de l'inscription est bon marché comparée au coût de s'en passer.

Par Salva Castells

Image de couverture : Draceane, CC BY-SA 4.0.
Dans cet article

Chaque recherche sémantique de l'un de mes projets coûte de l'argent : un embedding, un appel de reranking et une synthèse de texte. Des centimes par requête. Multipliés par un bot qui tourne en boucle, ce ne sont plus des centimes. La première version ne demandait de compte pour rien, parce que demander un compte pour chercher des animes semblait absurde. Ça le semble toujours. Je le fais quand même.

La raison n'est ni le marketing ni la liste de diffusion. C'est que sans identité, il n'y a rien à compter.

Le coût de laisser entrer sans identifier

J'ai monté un harnais de validation fake-door avec cinq landing pages en parallèle pour mesurer l'intérêt avant de construire quoi que ce soit. La seule chose à protéger était le panneau d'admin, j'ai donc mis un bannissement d'IP après plusieurs échecs de login. Suffisant, pensais-je.

Ça ne l'était pas. L'IP du client venait de x-forwarded-for, et cet en-tête est une liste client, proxy1, proxy2… où chaque proxy ajoute à droite mais où personne ne nettoie ce qui était déjà écrit. Prendre la première valeur, c'est prendre ce que le client veut bien écrire. N'importe qui envoie x-forwarded-for: 1.2.3.4, esquive son propre bannissement, et peut au passage bannir l'IP de quelqu'un d'autre en écrivant la sienne.

Je l'ai corrigé en comptant les sauts plutôt qu'en faisant confiance à la gauche :

const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);

const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];

Avec un proxy devant, le vrai client est la dernière valeur : celle qu'écrit le proxy et à laquelle le client ne peut pas toucher. Ça marche. Mais regardez ce que je viens de faire : j'ai introduit une variable d'environnement qui dépend de la topologie réseau du déploiement pour pouvoir identifier quelqu'un de façon approximative. Si demain je mets un CDN devant, le nombre change et le système de bannissements se trompe en silence. Il n'échoue pas : il se trompe.

Pourquoi l'IP ne sert pas d'identité

Au-delà d'être falsifiable derrière le mauvais proxy, l'IP est une mauvaise clé pour des raisons sans remède. Quatre personnes du même bureau en partagent une. Une personne sur son mobile change d'IP en sortant de l'immeuble. Les réseaux mobiles font du NAT sur des blocs énormes : bannir une IP d'opérateur, c'est bannir tout un quartier.

Ce que vous attendez d'un identifiant, c'est qu'il soit stable pour la même personne et différent pour des personnes différentes. L'IP ne remplit aucune des deux conditions. Un user_id remplit les deux par construction.

Ce que vous ne pouvez faire que si vous savez qui est qui

Dans le moteur de recherche, j'ai fini avec trois niveaux et une limite finie sur les trois :

visitor:    { semanticSearchLimit: 10,  historyLimit: 0 }
registered: { semanticSearchLimit: 30,  historyLimit: 10 }
premium:    { semanticSearchLimit: 200, historyLimit: null }

Le commentaire que j'ai laissé sur le type résume tout le critère : toujours fini ; aucun niveau n'est exempté du quota de recherche. Le premium a le nombre le plus élevé, pas l'infini. historyLimit, lui, accepte null pour « illimité ». Celui de recherche non, parce que chaque recherche est une facture.

Le visiteur existe toujours, avec un cookie qui lui donne une clé anonyme. Mais la fenêtre de renouvellement diffère selon qui vous êtes : l'inscrit se renouvelle par mois calendaire en UTC, le premium se renouvelle à l'anniversaire de son abonnement. Cela impose de stocker un premiumAnchorAt, et stocker quoi que ce soit impose une ligne avec un propriétaire. Sans compte, pas d'anniversaire à ancrer.

Le cas des applications avec un coût par requête

Quand chaque action coûte de l'argent réel, le quota cesse d'être une défense et devient de la comptabilité. Et la comptabilité échoue de façons ennuyeuses.

Dans le moteur de recherche, deux requêtes identiques étaient comptées deux fois. La solution a été une réservation avec un hash du contenu de la requête, pas de son texte : la requête normalisée, la limite, le flag explicite et les filtres triés. Le commentaire du type dit pourquoi l'ordre compte — le même ensemble choisi dans un autre ordre est la même recherche — et pourquoi les filtres sont à l'intérieur : le même texte avec un autre genre est une autre requête, et les fusionner renverrait des résultats erronés. La réservation renvoie reserved, duplicate, in_progress ou exhausted. Les quatre états existent parce que les quatre se sont produits.

Dans une API de transcription, la facturation aux caractères a commencé par la transcription plus le texte traité divisé par dix. En mesurant le coût réel, la ligne est devenue ceci :

transcriptionText.length + Math.floor(processedText.length * 0.25)

De /10 à * 0.25 : deux fois et demie plus cher. Le prix était faux d'un facteur 2,5 et pendant des mois j'ai payé la différence sans le savoir, parce que je n'avais pas la mesure pour m'en rendre compte.

Dans un SaaS de génération d'images, le bug était l'inverse : les crédits quotidiens existaient mais la vérification préalable ne regardait que subCredits. Les utilisateurs disposant de crédit quotidien recevaient un 402 avec du crédit sur leur compte. Un dailyCredits + subCredits a réglé ça. Tout cela, ce sont des colonnes d'une ligne utilisateur. Sans ligne, il n'y a pas de bug à corriger parce qu'il n'y a rien à compter.

Quand l'identité ne suffit pas non plus

Avoir un compte, ce n'est pas avoir une identité valide. Dans une API à clés, la ligne de la clé survivait à l'état du compte de son propriétaire : on nettoyait l'enregistrement de facturation lors de l'offboarding sans que personne ne touche à l'isActive de la clé. La clé continuait d'authentifier. Le correctif a été de revalider dans authenticateApiKey lui-même que le propriétaire a toujours un enregistrement de facturation, au lieu de se fier au flag :

if (!ownerBilling) {
  return { authenticated: false, error: "API key owner is no longer active" };
}

Ça va là et pas dans chaque endpoint, précisément pour qu'aucun endpoint n'ait à s'en souvenir.

Quand je laisse quand même entrer sans compte

Quand l'action ne coûte rien et n'accumule pas d'état : lire une fiche, consulter un catalogue, charger une landing page. Et un niveau visiteur avec un quota court, pour que quelqu'un puisse essayer avant de décider. Dix recherches avant l'inscription, c'est une friction honnête ; zéro, c'est une facture ouverte.

La question n'est pas de savoir si le login dérange. Il dérange. La question est de savoir ce que vous allez écrire dans le WHERE quand quelqu'un dépassera les bornes.