Protéger un formulaire Next.js contre les robots, sans énigme
Posez la balise dans le layout, laissez un formulaire POST envoyer le champ caché antisy-token à une route de votre application, et vérifiez-le côté serveur avec POST /v1/siteverify : la réponse est allow ou deny. Une fonction sans dépendance, un secret qui reste sur le serveur.
État au .
Ce qu’il vous faut
- Un site déclaré au tableau de bord d’Antisy, avec les noms d’hôte de vos pages.
- La clé de site (32 caractères) : elle est publique et va dans la page ; le secret de vérification (64 caractères) reste sur votre serveur et n’est montré qu’une fois, à sa création.
- Des pages servies en HTTPS.
- Une application Next.js avec l’App Router, dont les routes
route.tsreçoivent le formulaire. - Node 22 ou plus : c’est ce que demande la fonction de la documentation, qui n’a aucune dépendance. Son import suppose l’option
allowJsdutsconfig.json, que les applications créées avec create-next-app activent.
Est-ce disponible aujourd’hui ?
L’accès se fait sur liste d’attente. L’API et le tableau de bord sont en service depuis le 27 octobre 2026, hébergés chez Infomaniak à Genève ; aucun client n’est encore en production.
Les exemples de ce guide suivent la version 1 de l’API, telle que la documentation la décrit. Pour les essayer sans protéger un vrai formulaire, la documentation décrit des clés de test ; une clé de test ne va jamais en production.
Voir : Documentation : clés de test · Le pilote · Rejoindre la liste d’attente
1. Les deux valeurs
La clé de site est publique, le secret ne quitte jamais le serveur. Aucun des deux ne porte le préfixe NEXT_PUBLIC_ : Next.js recopie ces variables dans le code envoyé au navigateur, ce qui est exactement ce qu’on veut éviter pour le secret.
# .env.local : jamais dans le dépôt, jamais préfixé par NEXT_PUBLIC_.
ANTISY_SITE_KEY=VOTRE_CLE_DE_SITE
ANTISY_SECRET=VOTRE_SECRET_DE_VERIFICATION2. La balise dans le layout
La balise est écrite par un composant serveur, avec defer, et lit la clé de site dans l’environnement du serveur.
// app/layout.tsx
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="fr">
<head>
{/* La clé de site est publique : elle va dans la page. Le secret, jamais. */}
<script src="https://api.antisy.com/v1/i/l.js" data-site={process.env.ANTISY_SITE_KEY} defer />
</head>
<body>{children}</body>
</html>
);
}C’est la forme qu’utilise antisy.com pour ses propres formulaires. Seules les pages qui portent un formulaire protégé ont besoin de la balise : vous pouvez la poser dans le layout du segment concerné plutôt que dans le layout racine.
3. Le formulaire
Un formulaire HTML ordinaire en POST vers une route de votre application, sans état et sans JavaScript de votre côté :
// app/contact/page.tsx : un formulaire HTML ordinaire, sans état ni JavaScript de votre côté.
export default function ContactPage() {
return (
<form method="post" action="/api/contact">
<label>
Nom <input name="name" autoComplete="name" />
</label>
<label>
E-mail <input name="email" type="email" autoComplete="email" />
</label>
<label>
Message <textarea name="message" />
</label>
<button>Envoyer</button>
</form>
);
}À l’envoi, le script retient le formulaire le temps d’obtenir un jeton, crée le champ caché antisy-token, le remplit et relance l’envoi (6 secondes au plus). Pour un envoi par fetch ou une application à page unique, la page sur le script décrit antisy.token() : ce guide ne les traite pas. Il n’a pas été essayé avec une Server Action.
4. La vérification dans une route
Enregistrez la fonction Node de la documentation dans lib/antisy.mjs, telle quelle : elle lit le secret dans process.env.ANTISY_SECRET et rend allow ou deny.
// antisy.mjs : Node 22 ou plus, sans dépendance.
// Rend "allow" ou "deny".
// Antisy ne répond pas (réseau, délai, 5xx) : "allow". Tout le reste d'incertain : "deny".
// Le 429 est une décision qui vous appartient. "allow" par défaut, comme pour une panne ;
// pour un formulaire sensible, mettez "deny" (voir la page sur la vérification).
const ON_RATE_LIMIT = "allow";
export async function antisyVerdict(token) {
// 1. Un jeton présent doit avoir la forme d'un jeton : 1 à 2048 caractères ASCII imprimables.
// Sinon, on n'appelle pas Antisy et on ne laisse pas passer.
// Un champ vide ou absent (script bloqué) part comme une chaîne vide : Antisy le lit comme un jeton absent.
if (token && !/^[\x20-\x7e]{1,2048}$/.test(token)) {
console.warn("Jeton mal formé, envoi non vérifié");
return "deny";
}
let response;
try {
response = await fetch("https://api.antisy.com/v1/siteverify", {
method: "POST",
// fetch suit les redirections par défaut : une 3xx rejouerait le corps, secret compris, ailleurs.
// Ici elle est une erreur de votre appel (refusée plus bas).
redirect: "manual",
// Un corps URLSearchParams part en application/x-www-form-urlencoded.
body: new URLSearchParams({
secret: process.env.ANTISY_SECRET, // votre secret, jamais dans la page
response: token ?? "", // le champ antisy-token, tel que reçu
}),
signal: AbortSignal.timeout(3000), // au-delà de 3 s, Antisy ne répond pas
});
} catch (error) {
console.warn("Antisy injoignable, envoi laissé passer :", error.name);
return "allow";
}
if (response.status >= 500) {
console.warn("Antisy a répondu " + response.status + ", envoi laissé passer");
return "allow";
}
if (response.status === 429) {
console.warn("Antisy a répondu 429, décision : " + ON_RATE_LIMIT);
return ON_RATE_LIMIT;
}
if (!response.ok) {
// 400, 401, 413, 415... : le défaut est dans votre appel. À corriger, jamais un laissez-passer.
console.error("Antisy a refusé la requête (" + response.status + "), envoi non vérifié");
return "deny";
}
const result = await response.json().catch(() => null);
if (typeof result?.success !== "boolean") {
console.error("Réponse d'Antisy illisible, envoi non vérifié");
return "deny";
}
// Un secret absent ou refusé n'est pas une erreur HTTP : la réponse est un refus, à 200.
const codes = result["error-codes"] ?? [];
if (codes.includes("missing-input-secret") || codes.includes("invalid-input-secret")) {
console.error("Antisy refuse le secret : " + codes.join(", "));
}
// success est la seule acceptation : il vaut true si et seulement si verdict vaut "allow".
return result.success ? "allow" : "deny";
}// app/api/contact/route.ts
import { antisyVerdict } from "../../../lib/antisy.mjs";
export async function POST(request: Request) {
const form = await request.formData();
const token = form.get("antisy-token"); // une chaîne, un fichier ou rien
// Champ absent (script bloqué) : chaîne vide, Antisy décide. Un fichier n'est pas un jeton : refus.
const verdict = token === null || typeof token === "string" ? await antisyVerdict(token ?? "") : "deny";
if (verdict === "deny") {
return new Response("Envoi refusé.", { status: 403 }); // refus générique, sans dire pourquoi
}
// … enregistrer ou envoyer le message
return new Response(null, { status: 303, headers: { Location: "/merci" } });
}request.formData()lit un formulaire envoyé enapplication/x-www-form-urlencodedcomme enmultipart/form-data.- Le champ est lu avec une valeur vide par défaut : si le script est bloqué, il est absent, et Antisy lit un jeton vide comme un jeton absent.
- Le refus est une réponse générique
403, sans la cause.
Ne mettez jamais en cache une fonction qui appelle la vérification (un mémo maison, unstable_cache, la directive "use cache") : un verdict vaut pour un jeton, et pour lui seul.
5. Avec une politique CSP
Si votre application envoie une politique Content-Security-Policy, ajoutez https://api.antisy.com à script-src et à connect-src, et 'wasm-unsafe-eval' à script-src. Avec un nonce par réponse, la balise le porte :
// app/layout.tsx : avec une politique CSP à nonce posée par proxy.ts (middleware.ts avant Next.js 16)
import { headers } from "next/headers";
import type { ReactNode } from "react";
export default async function RootLayout({ children }: { children: ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="fr">
<head>
<script src="https://api.antisy.com/v1/i/l.js" data-site={process.env.ANTISY_SITE_KEY} defer nonce={nonce} />
</head>
<body>{children}</body>
</html>
);
}Next.js demande un rendu dynamique pour un nonce : lire headers() rend le layout dynamique. Les trois politiques à nonce qui fonctionnent, sans 'unsafe-inline', sont sur la page du script.
Que répond la vérification ?
La réponse est un objet JSON. success vaut true si et seulement si verdict vaut allow. Il n’y a que ces deux issues, et la réponse n’en donne jamais la raison : le refus que vous montrez au visiteur est générique lui aussi.
Un jeton est valable 60 secondes et ne se vérifie qu’une fois. Un second appel avec le même jeton est refusé.
{
"success": true,
"verdict": "allow",
"flags": [],
"error-codes": [],
"challenge_ts": "2026-09-24T10:00:00Z",
"hostname": "shop.example",
"action": "contact",
"event_id": "evt_01J7X4M2Q9K8ZP3R5T6V7W8Y9A"
}Voir : Documentation : la requête, la réponse et les codes d’erreur
Et si Antisy ne répond pas ?
La fonction laisse passer l’envoi dans un seul cas : Antisy ne répond pas (réseau coupé, délai de 3 secondes dépassé, erreur 5xx). Une panne d’Antisy ne doit pas fermer votre formulaire.
Toute autre erreur HTTP (400, 401, 413, 415) vient de votre appel : la fonction refuse l’envoi et le journalise, jamais un laissez-passer. Un 429 est une décision qui vous appartient : la constante ON_RATE_LIMIT vaut allow par défaut ; mettez deny pour un formulaire sensible.
Voir : Documentation : la requête, la réponse et les codes d’erreur
Que se passe-t-il si le script est bloqué ?
Une politique CSP qui refuse le script, un bloqueur de publicité, une page en HTTP ou un visiteur sans JavaScript empêchent le script de tourner. Le formulaire part alors quand même, sans le champ antisy-token : lisez-le avec une valeur vide par défaut.
Ce qui suit dépend du réglage « Collecte du navigateur » du site. Collecte allumée, un envoi sans jeton ne passe pas ; collecte éteinte, l’absence de jeton n’est pas reprochée. Un site neuf naît avec la collecte éteinte.
Ce qu’un contrôle anti-bot ne remplace pas
Aucun contrôle anti-bot n’est parfait, et Antisy n’annonce aucun taux de détection. Il s’ajoute aux autres défenses d’un formulaire, comme la limitation du nombre d’envois ; il ne les remplace pas.
Questions fréquentes
Le secret d’Antisy peut-il aller dans une variable NEXT_PUBLIC_ ?
Non. Une variable NEXT_PUBLIC_ est recopiée dans le code envoyé au navigateur : le secret y serait lisible par tout visiteur. Seule la clé de site est publique ; le secret reste dans une variable du serveur, lue par la route.
Y a-t-il un paquet Antisy pour Next.js ?
Le kit serveur Node (Express, Fastify, Next.js) sera publié sur npm ; il ne l’est pas encore. Pour un formulaire simple, la fonction de la documentation suffit : un fetch vers /v1/siteverify, sans dépendance. Le kit sert d’abord la connexion et l’inscription.
Peut-on vérifier le jeton dans une Server Action ?
La fonction de vérification ne dépend pas d’une route : elle prend un jeton et rend allow ou deny, où qu’on l’appelle. Ce qui n’a pas été essayé, c’est la façon dont le script prend en charge un formulaire envoyé par une Server Action. Dans le doute, demandez le jeton vous-même avec antisy.token().
Voir aussi : Protéger un formulaire PHP contre les robots, sans énigme · Protéger un formulaire WordPress contre les robots, sans extension · Tous les guides · Documentation : guide pas à pas