Un anti-spam sans service tiers : la preuve de travail dans le navigateurAn anti-spam with no third-party service: proof of work in the browser
Refuser un captcha tiers oblige à construire autre chose. Le mécanisme retenu sur djplays, ce qu'il coûte au visiteur, ce qu'il coûte à un robot, et ce qu'il ne règle pas.Refusing a third-party captcha forces you to build something else. The mechanism chosen for djplays, what it costs the visitor, what it costs a robot, and what it does not solve.

djplays expose cinq formulaires publics : la page d’accueil, la candidature DJ, le contact, la page labels et la lettre d’information. Cinq portes ouvertes à qui veut les pousser, et les robots les poussent avant les humains. La question n’était donc pas de savoir s’il fallait filtrer, mais avec quoi.djplays exposes five public forms: the home page, the DJ application, contact, the labels page and the newsletter. Five doors open to anyone who wants to push them, and robots push them before humans do. The question was therefore not whether to filter, but with what.
La réponse habituelle tient en quelques lignes de code : on pose un captcha tiers et on passe à la suite. C’est rapide, c’est efficace, et c’est exactement ce que je ne voulais pas faire ici. Expliquer pourquoi demande de regarder ce qu’un captcha tiers coûte réellement, au-delà du temps d’intégration qui est effectivement court.The usual answer fits into a few lines of code: you drop in a third-party captcha and move on. It is quick, it is effective, and it is exactly what I did not want to do here. Explaining why means looking at what a third-party captcha really costs, beyond the integration time, which is genuinely short.
Ce qu'un captcha tiers coûte vraimentWhat a third-party captcha really costs
Le premier coût est technique. Chaque page qui porte un formulaire charge un script hébergé ailleurs, donc dépend d’un domaine que je ne contrôle pas, d’une disponibilité que je ne mesure pas et d’un calendrier de versions que je ne décide pas. Le jour où ce service ralentit, ce sont mes formulaires qui ralentissent, et je n’ai aucun levier.The first cost is technical. Every page carrying a form loads a script hosted elsewhere, so it depends on a domain I do not control, on availability I do not measure and on a release schedule I do not decide. The day that service slows down, it is my forms that slow down, and I have no lever at all.
Le deuxième coût est pour le visiteur. Il y a quelque chose à cocher, parfois quelque chose à reconnaître, et dans tous les cas un geste supplémentaire demandé à une personne qui n’avait rien à prouver. Le temps perdu est faible, mais il est prélevé sur celui qui remplit honnêtement le formulaire, pas sur celui qui l’attaque.The second cost falls on the visitor. There is something to tick, sometimes something to recognise, and in every case one more gesture demanded of a person who had nothing to prove. The time lost is small, but it is taken from whoever fills the form honestly, not from whoever attacks it.
Le troisième coût est celui des données. Pour vérifier qu’un visiteur est humain, le service tiers doit observer ce visiteur, depuis une page où il n’est pas partie à l’échange. Cela fait une ligne de plus dans la page de confidentialité, un cookie de plus à justifier selon la configuration, et une entité de plus entre le producteur qui dépose un titre et la plateforme qui le reçoit.The third cost is the data one. To verify that a visitor is human, the third-party service has to observe that visitor, from a page where it is not party to the exchange. That means one more line in the privacy page, one more cookie to justify depending on the setup, and one more entity standing between the producer submitting a track and the platform receiving it.
Le principe retenu : faire payer le calcul, pas le visiteurThe principle chosen: make the computer pay, not the visitor
Le module est bâti sur le principe d’ALTCHA : au lieu de demander à un humain de prouver qu’il est humain, on demande à son navigateur un petit travail de calcul. Le visiteur ne voit rien, ne coche rien, ne reconnaît aucune image. Sa machine, elle, dépense un peu de temps processeur, et ce temps est la seule chose exigée.The module is built on the ALTCHA principle: instead of asking a human to prove they are human, you ask their browser for a small piece of computation. The visitor sees nothing, ticks nothing, recognises no image. Their machine, on the other hand, spends a little processor time, and that time is the only thing demanded.
Tout part du serveur, qui fabrique un défi en cinq morceaux séparés par des points : un numéro de version, un sel aléatoire, un horodatage, le nombre de bits exigé, et une signature calculée sur le reste. Ce défi est posé dans la page, en clair. Il n’a pas besoin d’être secret, il a besoin d’être authentique.It all starts with the server, which builds a challenge in five parts separated by dots: a version number, a random salt, a timestamp, the number of bits required, and a signature computed over the rest. That challenge is placed in the page, in plain sight. It does not need to be secret, it needs to be authentic.
Le travail du navigateur consiste alors à chercher un nombre entier. Il prend le sel, lui accole un entier, calcule l’empreinte SHA-256 du tout, et regarde si cette empreinte commence par le nombre de bits à zéro demandé. Si non, il essaie l’entier suivant. Une empreinte SHA-256 n’est pas prévisible : il n’existe aucune méthode plus courte que d’essayer, ce qui est précisément ce qui rend le procédé utile.The browser then has one job: find an integer. It takes the salt, appends an integer, computes the SHA-256 hash of the whole thing, and checks whether that hash starts with the requested number of zero bits. If not, it tries the next integer. A SHA-256 hash is not predictable: there is no method shorter than trying, which is precisely what makes the scheme useful.
- Le serveur signe le défi et le dépose dans la page, sans rien conserver de son côté.The server signs the challenge and places it in the page, keeping nothing on its side.
- Le script antispam.js cherche l’entier qui donne une empreinte commençant par le bon nombre de bits à zéro.The antispam.js script looks for the integer that yields a hash starting with the right number of zero bits.
- Il renvoie le défi et l’entier trouvé dans un champ caché nommé antispam, avec le reste du formulaire.It returns the challenge and the integer it found in a hidden field named antispam, along with the rest of the form.
La signature est le détail qui rend le dispositif tenable. Le serveur ne garde aucune liste de défis émis : il reconnaît les siens parce qu’il sait recalculer leur signature. Sans elle, n’importe qui pourrait fabriquer un défi facile et le résoudre lui-même, ce qui ramènerait le contrôle à une formalité.The signature is the detail that makes the setup workable. The server keeps no list of issued challenges: it recognises its own because it can recompute their signature. Without it, anyone could craft an easy challenge and solve it themselves, which would reduce the check to a formality.
Les quatre contrôles au retourThe four checks on the way back
- La signature, qui dit que le défi vient bien de ce serveur et n’a pas été retouché.The signature, which says the challenge really comes from this server and has not been altered.
- L’âge du défi, qui doit être compris entre 3 secondes et 2 heures.The age of the challenge, which must be between 3 seconds and 2 hours.
- La preuve, qui doit être exacte : l’empreinte est recalculée côté serveur, pas crue sur parole.The proof, which must be exact: the hash is recomputed server-side, not taken on trust.
- L’unicité, un défi déjà présenté étant refusé définitivement.Uniqueness, a challenge already presented being refused for good.
Le plancher de 3 secondes mérite une explication, parce qu’il paraît contre-intuitif : on rejette ce qui arrive trop vite. Un formulaire rempli, lu et envoyé en moins de trois secondes n’a pas été rempli par une personne. Le calcul, lui, peut très bien être terminé avant : c’est le temps humain qui est mesuré là, pas le temps machine.The 3-second floor deserves an explanation, because it looks counter-intuitive: we reject what arrives too fast. A form filled in, read and sent in under three seconds was not filled in by a person. The computation itself may well be finished before that: what is measured there is human time, not machine time.
Le plafond de 2 heures règle le problème inverse. Un défi qui resterait valable indéfiniment serait une ressource à collecter : on en ramasse quelques milliers, on les résout tranquillement hors ligne, et on les dépense plus tard en rafale. Deux heures laissent largement le temps d’écrire un message long, de changer d’onglet, de revenir, et ne laissent pas le temps de constituer un stock.The 2-hour ceiling solves the opposite problem. A challenge that stayed valid indefinitely would be a resource to collect: you gather a few thousand, solve them quietly offline, then spend them later in a burst. Two hours leaves plenty of time to write a long message, switch tabs and come back, and does not leave time to build up a stockpile.
L’usage unique ferme la dernière porte, celle du rejeu. Une preuve valide reste valide : sans mémoire, on pourrait renvoyer cent fois le même couple défi et entier, et payer le calcul une seule fois pour cent envois. Chaque défi consommé laisse donc un fichier marqueur vide dans le dossier de données, purgé au fil de l’eau. Un fichier vide par défi, c’est grossier, et c’est exactement assez : la seule question posée est de savoir si ce nom existe déjà.Single use closes the last door, replay. A valid proof stays valid: with no memory, you could send the same challenge-and-integer pair a hundred times and pay for the computation once for a hundred submissions. Every consumed challenge therefore leaves an empty marker file in the data folder, purged as things go along. One empty file per challenge is crude, and it is exactly enough: the only question being asked is whether that name already exists.
La difficulté, et ce que chacun paieThe difficulty, and what each side pays
La difficulté retenue est de 15 bits, soit environ 33 000 empreintes en moyenne pour trouver l’entier. Côté visiteur, ce calcul se fait dans la page, sans rien afficher : sur n’importe quel appareil récent, c’est imperceptible, et sur un téléphone ancien ça reste du même ordre que l’affichage d’une image. Personne n’attend après une barre de progression.The difficulty chosen is 15 bits, meaning around 33,000 hashes on average to find the integer. On the visitor side, that computation happens in the page, displaying nothing: on any recent device it is imperceptible, and on an old phone it stays in the same range as rendering an image. Nobody waits on a progress bar.
Côté robot, le même chiffre se lit autrement. Un envoi en masse ne vaut que parce qu’il est quasiment gratuit à l’unité : le modèle économique du spam repose sur un coût marginal nul. Trente-trois mille empreintes pour un seul formulaire, multipliées par le volume qui rend l’opération rentable, transforment une dépense négligeable en dépense visible. C’est tout l’effet recherché : pas interdire, rendre cher.On the robot side, the same figure reads differently. Bulk sending only works because it is practically free per unit: the economics of spam rest on a marginal cost of zero. Thirty-three thousand hashes for one single form, multiplied by the volume that makes the operation worthwhile, turn a negligible expense into a visible one. That is the whole intended effect: not to forbid, but to make it expensive.
Une preuve de travail ne distingue pas un humain d’une machine, et elle ne le prétend pas. Elle rend simplement le millième envoi aussi coûteux que le premier, ce qu’aucune case à cocher ne sait faire.A proof of work does not tell a human from a machine, and it does not claim to. It simply makes the thousandth submission as expensive as the first, which no tick box knows how to do.
Ce que ça ne règle pasWhat it does not solve
Il faut être honnête sur la portée. Quelqu’un qui décide de viser ce site en particulier et qui accepte de payer le processeur passera : quinze bits ne sont pas un obstacle pour qui vise une cible unique et non un million de formulaires. Et une personne qui remplit le formulaire à la main passera toujours, puisque le mécanisme ne mesure rien d’autre que du calcul et du temps.I have to be honest about the scope. Someone who decides to target this site in particular and accepts the processor cost will get through: fifteen bits are not an obstacle for anyone aiming at a single target rather than a million forms. And a person filling the form by hand will always get through, since the mechanism measures nothing but computation and time.
La preuve de travail n’est donc pas la seule défense, c’est la couche qui coupe le bruit de fond. Ce qui reste derrière relève de la conception même de ces formulaires : aucun des cinq ne publie directement quoi que ce soit, ni ne déclenche seul une action visible. Confondre une couche avec un rempart est la meilleure façon de se retrouver surpris.Proof of work is therefore not the only defence, it is the layer that cuts out the background noise. What remains behind it belongs to the design of these forms themselves: not one of the five publishes anything directly, nor triggers a visible action on its own. Mistaking a layer for a wall is the surest way to end up surprised.
Pourquoi le repli sur Turnstile reste prévuWhy the Turnstile fallback is still in place
Le module n’est pas exclusif. Si Cloudflare Turnstile est configuré, c’est lui qui est exigé, et la preuve de travail maison ne sert que lorsqu’aucune clé n’est posée. Le choix se fait donc dans la configuration, pas dans le code des formulaires, et il peut changer sans toucher à une seule page.The module is not exclusive. If Cloudflare Turnstile is configured, Turnstile is what gets required, and the homemade proof of work only comes into play when no key is set. The choice is therefore made in configuration, not in the forms code, and it can change without touching a single page.
Garder ce repli est une décision d’exploitation, pas une hésitation. Si un jour du spam passe en volume, je veux pouvoir basculer en posant une clé, un soir, plutôt qu’en réécrivant cinq formulaires sous pression. Le coût de cette option est d’avoir écrit les deux chemins ; son intérêt est de ne pas avoir à choisir définitivement maintenant.Keeping that fallback is an operational decision, not hesitation. If bulk spam ever gets through, I want to be able to switch over by setting a key, one evening, rather than by rewriting five forms under pressure. The cost of that option is having written both paths; its value is not having to choose definitively now.
Le module a d’ailleurs été posé exactement là où Turnstile était déjà appelé : accueil, candidature DJ, contact, labels, lettre d’information. Reprendre les mêmes points d’ancrage évite la situation classique où une protection couvre quatre formulaires sur cinq, et où le cinquième est celui que personne ne regarde.The module was in fact placed exactly where Turnstile was already being called: home page, DJ application, contact, labels, newsletter. Reusing the same anchor points avoids the classic situation where a protection covers four forms out of five, and the fifth is the one nobody looks at.
Ce qu'on gagne à ne rien charger hors du domaineWhat you gain by loading nothing from outside the domain
Dans la configuration par défaut, rien n’est chargé hors de djplays.com. Il n’y a rien à cocher, aucun cookie supplémentaire, et aucune donnée de visiteur qui part ailleurs. Ce n’est pas un argument de conformité ajouté après coup : c’est la conséquence directe du fait que le calcul se passe entre le navigateur et le serveur, sans témoin.In the default setup, nothing is loaded from outside djplays.com. There is nothing to tick, no additional cookie, and no visitor data going elsewhere. This is not a compliance argument bolted on afterwards: it is the direct consequence of the computation happening between the browser and the server, with no witness.
Le bénéfice d’exploitation est du même ordre. Pas de compte à créer, pas de clé à renouveler, pas de quota à surveiller, pas de changement d’interface à suivre chez un fournisseur, et une panne de moins qui peut faire tomber un formulaire. Le prix à payer, lui, est réel : ce code est à moi, donc son entretien aussi, et la difficulté de quinze bits est un réglage que je dois réévaluer moi-même si le bruit change.The operational benefit is of the same order. No account to create, no key to renew, no quota to watch, no provider interface change to follow, and one fewer outage that can take a form down. The price, on the other hand, is real: this code is mine, so its upkeep is too, and the fifteen-bit difficulty is a setting I have to reassess myself if the noise changes.
C’est la forme de compromis que je cherche sur ce projet. Un dispositif modeste, compris de bout en bout, dont je connais la limite et le point de bascule, plutôt qu’une boîte noire efficace qui fait entrer un tiers dans chaque formulaire. La boîte noire reste à portée de main, et c’est bien pour ça qu’elle est restée dans le code.That is the kind of trade-off I look for on this project. A modest mechanism, understood end to end, whose limit and switching point I know, rather than an effective black box that brings a third party into every form. The black box is still within reach, and that is precisely why it stayed in the code.
Le projet derrière cette noteThe project behind this note
Tout est parti de djplays, la place de marché. La fiche détaille le contexte, ce qui a été construit et ce que le client en a tiré.It all started with djplays, la place de marché. The project page covers the context, what was built and what the client got out of it.