écrits/tutorial/2026/08
Tutorial2 août 2026·32 min

Vérifier le trafic des agents IA dans Next.js avec Web Bot Auth et RFC 9421

Construisez un vérificateur Web Bot Auth de production dans Next.js. Générez des clés Ed25519, publiez un annuaire de signatures, signez vos requêtes sortantes et vérifiez cryptographiquement le trafic entrant des agents IA avec les signatures de messages HTTP RFC 9421.

Les bots génèrent désormais plus de requêtes HTML que les humains. Or les outils que la plupart des équipes utilisent pour les distinguer — les chaînes user-agent et les listes d'adresses IP autorisées — n'ont jamais été conçus pour résister à un adversaire. Et ils n'y résistent pas.

Web Bot Auth remplace cette approximation par de la cryptographie. L'opérateur d'un agent signe chaque requête sortante avec une clé privée Ed25519 et publie la clé publique correspondante à une URL bien connue. Votre origine vérifie la signature. Un scraper qui prétend être GPTBot possède la clé privée d'OpenAI ou ne la possède pas, et aucune falsification d'en-tête n'y changera quoi que ce soit.

Ce tutoriel construit la boucle complète dans Next.js : génération de clés, hébergement de l'annuaire, signature des requêtes et vérificateur d'origine durci, branché sur le middleware avec protection contre le rejeu.

Prérequis

  • Node.js 20+ — la prise en charge d'Ed25519 dans WebCrypto est indispensable
  • Next.js 15.5 ou 16 avec l'App Router
  • Une bonne pratique de TypeScript et de async/await
  • Une familiarité conceptuelle avec les en-têtes HTTP et la cryptographie à clé publique
  • Optionnel : une instance Upstash Redis pour l'étape de protection contre le rejeu

Aucun compte Cloudflare n'est requis. Tout ce qui suit tourne sur Node.

Ce que vous allez construire

Les deux moitiés d'un même protocole :

  1. Le signataire — un client HTTP sortant qui signe ses requêtes, plus une route /.well-known/http-message-signatures-directory publiant votre clé publique. C'est ce que vous construisez si vous exploitez un agent.
  2. Le vérificateur — un middleware Next.js qui valide les signatures entrantes contre un annuaire de clés récupéré à distance et mis en cache, rejette les rejeux et étiquette chaque requête avec une identité d'opérateur vérifiée pour le routage en aval.

À la fin, une requête qui franchit votre middleware porte une garantie cryptographique sur son émetteur.

Comment fonctionne réellement le protocole

Web Bot Auth est un profil léger au-dessus de RFC 9421 (HTTP Message Signatures), une norme proposée ratifiée par l'IETF. Le profil est défini dans draft-meunier-webbotauth-httpsig-protocol.

Une requête signée transporte trois en-têtes :

GET /fr/services HTTP/1.1
Host: noqta.tn
Signature-Agent: "https://signer.example.com"
Signature-Input: sig1=("@authority" "signature-agent");created=1785667520;
                 keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";
                 alg="ed25519";expires=1785667820;nonce="Rifzo0j0...";tag="web-bot-auth"
Signature: sig1=:mMElbdKxLiXjcOJ7161NxIUscYhA/HMyThrbs7KQLvhm...:

Trois points méritent votre attention.

Les composants couverts sont minimaux. La liste ("@authority" "signature-agent") signifie que seuls l'autorité cible et l'en-tête Signature-Agent sont signés. Ni le chemin, ni la méthode, ni le corps. C'est délibéré — cela garde les signatures stables à travers les proxys et les redirections — mais cela emporte une conséquence de sécurité que vous devez traiter, abordée à l'étape de protection contre le rejeu.

keyid est une empreinte, pas un nom. Il s'agit de l'empreinte JWK RFC 7638 de la clé publique, encodée en base64url. Vous ne cherchez pas une clé par une étiquette lisible, mais par un hachage du matériel cryptographique lui-même. C'est ce qui rend la rotation de clés sûre : une nouvelle clé produit une nouvelle empreinte et ne peut être confondue avec l'ancienne.

tag="web-bot-auth" délimite la portée de la signature. RFC 9421 est un mécanisme générique. Le tag empêche qu'une signature émise dans un autre but soit rejouée comme une revendication d'identité de bot. Vérifier ce tag vous incombe : la bibliothèque ne le fera pas à votre place.

Voici un annuaire réel, en ligne aujourd'hui à https://chatgpt.com/.well-known/http-message-signatures-directory :

{
  "keys": [
    {
      "crv": "Ed25519",
      "kty": "OKP",
      "x": "7F_3jDlxaquwh291MiACkcS3Opq88NksyHiakzS-Y1g",
      "kid": "otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg",
      "use": "sig",
      "nbf": 1735689600,
      "exp": 1786272375
    }
  ],
  "signature_agent": "https://chatgpt.com",
  "purpose": "ai"
}

C'est un JSON Web Key Set assorti de deux champs supplémentaires : signature_agent (qui doit correspondre à l'en-tête) et purpose (une indication sur la finalité du trafic — ai, rag, et assimilés).

Étape 1 : Mise en place du projet

Installez les paquets de référence maintenus par Cloudflare :

npm install web-bot-auth jose

web-bot-auth version 0.1.3 tire http-message-sig et jsonwebkey-thumbprint en dépendances transitives. jose ne sert qu'à la génération de clés à l'étape suivante.

Le paquet expose deux points d'entrée :

// Logique du protocole, constantes, vérification
import { verify, signatureHeaders, REQUEST_COMPONENTS } from "web-bot-auth";
 
// Adaptateurs cryptographiques au-dessus de WebCrypto
import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto";

Le README publié sur npm montre un helper recommendedComponents("sig1"). Cet export n'existe pas en 0.1.3 — le README est en avance sur la release. Utilisez plutôt la constante REQUEST_COMPONENTS, qui en est l'équivalent exporté. C'est le piège qui bloque presque tout le monde à la première installation.

Étape 2 : Générer une paire de clés Ed25519

Créez scripts/generate-key.ts. Ce script s'exécute une seule fois, hors ligne, et sa sortie constitue la racine de toute votre identité.

import { generateKeyPair, exportJWK } from "jose";
import { jwkToKeyID, helpers } from "web-bot-auth";
import { writeFileSync } from "node:fs";
 
async function main() {
  const { publicKey, privateKey } = await generateKeyPair("Ed25519", {
    extractable: true,
  });
 
  const privateJWK = { ...(await exportJWK(privateKey)), kty: "OKP", crv: "Ed25519" };
  const publicJWK = { ...(await exportJWK(publicKey)), kty: "OKP", crv: "Ed25519" };
 
  // Empreinte RFC 7638 — elle devient le `keyid` de chaque signature émise
  const kid = await jwkToKeyID(
    publicJWK,
    helpers.WEBCRYPTO_SHA256,
    helpers.BASE64URL_DECODE
  );
 
  publicJWK.kid = kid;
  privateJWK.kid = kid;
 
  writeFileSync("keys/public.jwk.json", JSON.stringify({ ...publicJWK, use: "sig" }, null, 2));
  writeFileSync("keys/private.jwk.json", JSON.stringify(privateJWK, null, 2));
 
  console.log("Identifiant de clé (empreinte) :", kid);
}
 
main();

Exécutez-le :

mkdir -p keys && npx tsx scripts/generate-key.ts

Le calcul de l'empreinte mérite attention. Notez l'ordre des arguments : jwkToKeyID reçoit le JWK, une fonction de hachage et un décodeur. L'objet helpers fournit des implémentations adossées à WebCrypto pour les deux, ce qui vous évite d'écrire à la main la canonicalisation RFC 7638.

Ajoutez immédiatement keys/private.jwk.json à votre .gitignore, puis déplacez le fichier dans votre gestionnaire de secrets. En production, chargez-le depuis une variable d'environnement :

const privateJWK = JSON.parse(process.env.WEB_BOT_AUTH_PRIVATE_JWK!);

Ne committez jamais la clé privée JWK. Une clé Ed25519 divulguée permet à n'importe qui d'usurper votre agent auprès de toutes les origines qui vous font confiance, et votre seul recours consiste à publier une nouvelle clé puis à attendre l'expiration du cache de chaque vérificateur.

Étape 3 : Publier votre annuaire de signatures

Si vous exploitez un agent, les origines ont besoin d'un moyen de récupérer votre clé publique. Créez app/.well-known/http-message-signatures-directory/route.ts :

import { MediaType } from "web-bot-auth";
import publicJWK from "@/keys/public.jwk.json";
 
export const runtime = "nodejs";
export const dynamic = "force-static";
 
export function GET() {
  const directory = {
    keys: [publicJWK],
    signature_agent: process.env.SIGNATURE_AGENT_ORIGIN ?? "https://agent.example.com",
    purpose: "ai",
  };
 
  return new Response(JSON.stringify(directory), {
    headers: {
      "Content-Type": MediaType.HTTP_MESSAGE_SIGNATURES_DIRECTORY,
      "Cache-Control": "public, max-age=86400",
    },
  });
}

Le type de contenu est application/http-message-signatures-directory+json, et la bibliothèque vous le fournit sous forme de constante pour éviter toute faute de frappe.

Deux détails provoquent de véritables échecs d'interopérabilité :

  • signature_agent doit correspondre exactement à la valeur que votre signataire place dans l'en-tête Signature-Agent, schéma inclus et barre oblique finale exclue. Les vérificateurs stricts rejettent toute divergence.
  • Conservez les anciennes clés dans le tableau pendant la rotation. Publiez la nouvelle clé aux côtés de l'ancienne pendant au moins la durée de votre TTL de cache le plus long, puis retirez l'ancienne. La supprimer dès la rotation casse tous les vérificateurs détenant un annuaire en cache.

Vérifiez que la route répond correctement :

curl -s http://localhost:3000/.well-known/http-message-signatures-directory | jq

Étape 4 : Signer les requêtes sortantes

Passons au signataire. Créez lib/web-bot-auth/sign.ts :

import { signatureHeaders, REQUEST_COMPONENTS, generateNonce } from "web-bot-auth";
import { signerFromJWK } from "web-bot-auth/crypto";
 
const SIGNATURE_AGENT = process.env.SIGNATURE_AGENT_ORIGIN!;
const VALIDITY_SECONDS = 300;
 
// Mettre le signataire en cache — importer une CryptoKey à chaque requête est coûteux
let signerPromise: ReturnType<typeof signerFromJWK> | null = null;
 
function getSigner() {
  if (!signerPromise) {
    signerPromise = signerFromJWK(JSON.parse(process.env.WEB_BOT_AUTH_PRIVATE_JWK!));
  }
  return signerPromise;
}
 
export async function signedFetch(url: string, init: RequestInit = {}) {
  // La valeur de Signature-Agent est une chaîne entre guillemets
  const signatureAgent = `"${SIGNATURE_AGENT}"`;
 
  const request = new Request(url, {
    ...init,
    headers: { ...init.headers, "Signature-Agent": signatureAgent },
  });
 
  const created = new Date();
  const headers = await signatureHeaders(request, await getSigner(), {
    created,
    expires: new Date(created.getTime() + VALIDITY_SECONDS * 1000),
    nonce: generateNonce(),
    components: REQUEST_COMPONENTS, // ["@authority", "signature-agent"]
    key: "sig1",
  });
 
  return fetch(url, {
    ...init,
    headers: {
      ...init.headers,
      "Signature-Agent": signatureAgent,
      Signature: headers["Signature"],
      "Signature-Input": headers["Signature-Input"],
    },
  });
}

Plusieurs décisions sont encodées ici :

Envoyez toujours un nonce. Il est optionnel dans la spécification, mais c'est lui qui rend la détection de rejeu possible côté origine. generateNonce() produit 64 octets aléatoires, ce qui est amplement suffisant.

Gardez une fenêtre de validité courte. Cinq minutes bornent la durée pendant laquelle une signature capturée reste exploitable. Signer avec une expiration à 24 heures — comme le font certains exemples publiés — offre à un attaquant une journée entière de fenêtre de rejeu.

Signez avec REQUEST_COMPONENTS. Cette constante inclut signature-agent dans les composants couverts. Si vous omettez l'en-tête de la requête, la bibliothèque bascule silencieusement sur REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT, et les vérificateurs qui attendent le lien avec l'agent vous rejetteront.

Testez contre le point de terminaison de débogage public de Cloudflare Research, qui valide votre implémentation et vous rapporte ce qu'il a observé :

const res = await signedFetch(
  "https://http-message-signatures-example.research.cloudflare.com/debug"
);
console.log(await res.json());

Étape 5 : Résoudre les clés publiques côté origine

Changeons de camp. Avant toute vérification, votre origine a besoin des clés publiques du signataire — récupérées depuis l'origine Signature-Agent et mises en cache agressivement, car un aller-retour réseau par requête entrante n'est pas viable.

Créez lib/web-bot-auth/directory.ts :

import { HTTP_MESSAGE_SIGNATURES_DIRECTORY } from "web-bot-auth";
 
interface Directory {
  keys: JsonWebKey[];
  signature_agent?: string;
  purpose?: string;
}
 
interface CacheEntry {
  keys: JsonWebKey[];
  expiresAt: number;
}
 
const cache = new Map<string, CacheEntry>();
const TTL_MS = 60 * 60 * 1000; // 1 heure
const FETCH_TIMEOUT_MS = 2000;
 
// Seuls ces opérateurs sont dignes de confiance. Récupérer sans restriction
// n'importe quelle origine nommée dans une requête est un vecteur de SSRF.
const ALLOWED_AGENTS = new Set([
  "https://chatgpt.com",
  "https://anthropic.com",
  "https://http-message-signatures-example.research.cloudflare.com",
]);
 
export function parseSignatureAgent(header: string | null): string | null {
  if (!header) return null;
  // La valeur est une chaîne entre guillemets, éventuellement suivie de paramètres
  const match = header.match(/"([^"]+)"/);
  if (!match) return null;
  try {
    const url = new URL(match[1]);
    if (url.protocol !== "https:") return null;
    return url.origin;
  } catch {
    return null;
  }
}
 
export async function resolveKeys(agentOrigin: string): Promise<JsonWebKey[]> {
  if (!ALLOWED_AGENTS.has(agentOrigin)) {
    throw new Error(`agent de signature non approuvé : ${agentOrigin}`);
  }
 
  const cached = cache.get(agentOrigin);
  if (cached && cached.expiresAt > Date.now()) return cached.keys;
 
  const res = await fetch(agentOrigin + HTTP_MESSAGE_SIGNATURES_DIRECTORY, {
    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
    headers: { Accept: "application/http-message-signatures-directory+json" },
  });
 
  if (!res.ok) {
    // Servir une version périmée plutôt que d'échouer sur un incident passager
    if (cached) return cached.keys;
    throw new Error(`échec de récupération de l'annuaire : ${res.status}`);
  }
 
  const directory = (await res.json()) as Directory;
  const keys = (directory.keys ?? []).filter(isUsableKey);
 
  cache.set(agentOrigin, { keys, expiresAt: Date.now() + TTL_MS });
  return keys;
}
 
function isUsableKey(key: JsonWebKey & { nbf?: number; exp?: number }): boolean {
  if (key.kty !== "OKP" || key.crv !== "Ed25519" || !key.kid) return false;
 
  // Les opérateurs publient nbf/exp de façon incohérente — certains en secondes,
  // d'autres en millisecondes. Normaliser avant toute comparaison.
  const now = Date.now();
  const toMs = (t: number) => (t > 1e11 ? t : t * 1000);
 
  if (key.nbf !== undefined && toMs(key.nbf) > now) return false;
  if (key.exp !== undefined && toMs(key.exp) < now) return false;
  return true;
}

Trois points durement acquis.

La liste d'autorisation n'est pas optionnelle. Sans elle, un attaquant envoie Signature-Agent: "https://internal.votre-vpc.local" et votre serveur émet docilement une requête sortante vers ce qu'il aura nommé. C'est un SSRF de manuel. La confiance est une décision explicite, opérateur par opérateur.

Servez le cache périmé en cas d'échec. Si l'annuaire d'un opérateur connaît une mauvaise minute, vous ne voulez pas que tous ses agents se mettent à échouer la vérification. Le repli sur la copie en cache est le bon arbitrage.

Normalisez nbf et exp. Cloudflare Research publie nbf sous la forme 1743465600000 — des millisecondes. OpenAI publie 1735689600 — des secondes. Les deux sont en ligne à l'heure actuelle. Comparer les valeurs brutes à Date.now() rejette silencieusement l'une des deux.

Étape 6 : Écrire le vérificateur

Créez lib/web-bot-auth/verify.ts :

import { verify, HTTP_MESSAGE_SIGNATURE_TAG } from "web-bot-auth";
import { verifierFromJWK } from "web-bot-auth/crypto";
 
export interface VerifiedAgent {
  agentOrigin: string;
  keyid: string;
  nonce?: string;
  expires: Date;
}
 
const MAX_CLOCK_SKEW_MS = 60_000;
 
export async function verifySignedRequest(
  request: Request,
  agentOrigin: string,
  keys: JsonWebKey[]
): Promise<VerifiedAgent> {
  const index = new Map(keys.map((k) => [(k as { kid: string }).kid, k]));
  let result: VerifiedAgent | null = null;
 
  // Composer nos propres contrôles autour de la vérification de la bibliothèque
  const verifier = async (
    data: string,
    signature: Uint8Array,
    params: { keyid: string; created: Date; expires: Date; tag: string; nonce?: string }
  ) => {
    if (params.tag !== HTTP_MESSAGE_SIGNATURE_TAG) {
      throw new Error(`tag inattendu : ${params.tag}`);
    }
 
    const jwk = index.get(params.keyid);
    if (!jwk) throw new Error(`keyid inconnu : ${params.keyid}`);
 
    if (params.created.getTime() - Date.now() > MAX_CLOCK_SKEW_MS) {
      throw new Error("signature créée dans le futur");
    }
 
    result = {
      agentOrigin,
      keyid: params.keyid,
      nonce: params.nonce,
      expires: params.expires,
    };
 
    const inner = await verifierFromJWK(jwk);
    return inner(data, signature, params);
  };
 
  await verify(request, verifier);
 
  if (!result) throw new Error("la vérification n'a produit aucun résultat");
  return result;
}

Le motif de composition est l'idée importante. verify() analyse les en-têtes, reconstruit la base de signature et transmet à votre callback les données brutes ainsi que les paramètres analysés. Ce callback est l'endroit où vous imposez tout ce que la bibliothèque ne fait pas : la délimitation par tag, la sélection de clé par empreinte et les bornes de dérive d'horloge.

Ce que la bibliothèque prend en charge : la reconstruction de la base de signature, la vérification Ed25519 et l'expiration. Une requête au-delà de son expires lève Signature expired avant que le résultat de votre callback ne soit renvoyé.

Ce qu'elle ne prend pas en charge : le tag, la résolution des clés, la dérive d'horloge et le rejeu. Voilà les quatre lignes de défense que vous venez d'écrire — plus une dernière.

Étape 7 : Protection contre le rejeu

Rappelez-vous que la signature ne couvre que @authority et signature-agent. Ni le chemin, ni la méthode, ni le corps. Une signature capturée sur une requête vers /fr/tarifs est valide octet pour octet sur un POST vers /api/admin/delete du même hôte, et ce jusqu'à son expiration.

C'est le point le plus important à comprendre à propos de Web Bot Auth : il authentifie l'émetteur, pas la requête. Traitez-le comme une identité, jamais comme une autorisation.

À l'intérieur de sa fenêtre de validité, la seule défense est le suivi des nonces. Créez lib/web-bot-auth/replay.ts :

import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
 
/**
 * Enregistre un nonce et indique s'il avait déjà été vu.
 * La clé expire en même temps que la signature : le stockage reste borné.
 */
export async function isReplay(keyid: string, nonce: string, expires: Date): Promise<boolean> {
  const ttlSeconds = Math.ceil((expires.getTime() - Date.now()) / 1000);
  if (ttlSeconds <= 0) return true; // déjà expirée, traitée comme un rejeu
 
  const key = `wba:nonce:${keyid}:${nonce}`;
  // SET NX renvoie null lorsque la clé existe déjà
  const stored = await redis.set(key, "1", { nx: true, ex: ttlSeconds });
  return stored === null;
}

Comme le TTL est calé sur l'expiration de la signature elle-même, le magasin ne dépasse jamais le volume d'une fenêtre de trafic. Les fenêtres de validité courtes ne sont donc pas seulement une propriété de sécurité : ce sont elles qui rendent ce dispositif économiquement viable.

Si une signature arrive sans nonce, vous avez un choix à faire. La rejeter est le comportement le plus sûr pour tout ce qui modifie l'état ; l'accepter se défend pour des routes de contenu en lecture seule. Prenez cette décision explicitement plutôt que par omission.

Étape 8 : Brancher le tout sur le middleware Next.js

Créez middleware.ts à la racine du projet :

import { NextRequest, NextResponse } from "next/server";
import { parseSignatureAgent, resolveKeys } from "@/lib/web-bot-auth/directory";
import { verifySignedRequest } from "@/lib/web-bot-auth/verify";
import { isReplay } from "@/lib/web-bot-auth/replay";
 
// Ed25519 via WebCrypto plus un fetch réseau — le runtime Node est requis
export const config = {
  runtime: "nodejs",
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
 
export async function middleware(request: NextRequest) {
  const headers = new Headers(request.headers);
 
  // Supprimer toute valeur fournie par le client pour que le code en aval
  // ne puisse pas être trompé
  headers.delete("x-verified-agent");
  headers.delete("x-agent-keyid");
 
  const agentOrigin = parseSignatureAgent(request.headers.get("signature-agent"));
 
  if (agentOrigin && request.headers.get("signature")) {
    try {
      const keys = await resolveKeys(agentOrigin);
      const verified = await verifySignedRequest(request, agentOrigin, keys);
 
      if (verified.nonce && (await isReplay(verified.keyid, verified.nonce, verified.expires))) {
        return new NextResponse("Signature rejouée", { status: 401 });
      }
 
      headers.set("x-verified-agent", verified.agentOrigin);
      headers.set("x-agent-keyid", verified.keyid);
    } catch (error) {
      // Une signature invalide est un signal plus fort qu'une absence de signature :
      // quelqu'un a essayé et s'est trompé. Journalisez-le, ne l'ignorez pas.
      console.warn("échec de vérification web-bot-auth", {
        agentOrigin,
        reason: error instanceof Error ? error.message : "inconnu",
      });
      return new NextResponse("Signature invalide", { status: 401 });
    }
  }
 
  return NextResponse.next({ request: { headers } });
}

Deux éléments sont ici porteurs.

La suppression des en-têtes avant de les définir. Si vous vous contentez de poser x-verified-agent en cas de succès, un client peut envoyer cet en-tête lui-même et tous les gestionnaires en aval le croiront. Purger les copies entrantes d'abord referme cette brèche.

Le rejet plutôt que la dégradation en cas d'échec. Une requête non signée est anonyme, ce qui est acceptable. Une requête à la signature cassée est une tentative active. Répondre 401 est la bonne réaction, et la journaliser vous donne le signal que quelqu'un sonde vos défenses.

export const config = { runtime: "nodejs" } est stable dans les versions actuelles de Next.js et ne requiert aucun drapeau expérimental. Il est indispensable ici : la prise en charge d'Ed25519 par le runtime Edge varie selon la plateforme, et vous avez besoin d'un fetch avec timeout pour résoudre l'annuaire.

Étape 9 : Agir sur le verdict

La vérification ne vaut l'effort que si quelque chose change en aval. Dans un Server Component ou un Route Handler :

import { headers } from "next/headers";
 
export default async function ServicesPage() {
  const agent = (await headers()).get("x-verified-agent");
 
  if (agent) {
    // Opérateur vérifié — servir une représentation structurée et légère.
    // Éviter l'habillage marketing réduit la charge utile et améliore l'analyse.
    return <ServicesStructuredView operator={agent} />;
  }
 
  return <ServicesMarketingPage />;
}

Les paliers de politique naturels, du plus au moins fiable :

Classe de traficTraitement
Agent vérifié, déclenché par un utilisateurServir intégralement, limite de débit généreuse, suivre comme un canal
Robot d'indexation vérifiéServir, budget de crawl standard
Robot d'entraînement vérifiéDécision métier — autoriser, mesurer ou exiger une licence
Bot non signé, auto-déclaréLimiter le débit, appliquer robots.txt
Signature casséeRejeter avec 401 et alerter

Notez que « tout bloquer » est rarement le bon levier. Bloquer le trafic d'agents déclenché par des utilisateurs en 2026 produit à peu près le même effet que bloquer les navigateurs mobiles en 2010.

Tester votre implémentation

L'annexe B.1.4 de la RFC 9421 publie une paire de clés de test, ce qui permet d'écrire des tests déterministes sans aucune gestion de clés. Créez lib/web-bot-auth/verify.test.ts :

import { describe, it, expect } from "vitest";
import { signatureHeaders, REQUEST_COMPONENTS, generateNonce } from "web-bot-auth";
import { signerFromJWK } from "web-bot-auth/crypto";
import { verifySignedRequest } from "./verify";
 
// Clé de test RFC 9421 annexe B.1.4 — publique, à ne jamais utiliser en production
const PRIVATE_JWK = {
  kty: "OKP",
  crv: "Ed25519",
  d: "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
  x: "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs",
};
 
const PUBLIC_JWK = {
  kty: "OKP",
  crv: "Ed25519",
  x: PRIVATE_JWK.x,
  kid: "poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U", // empreinte de la clé ci-dessus
};
 
const AGENT = "https://signer.example.com";
 
async function makeSignedRequest(url = "https://noqta.tn/fr/services", offsetMs = 0) {
  const signatureAgent = `"${AGENT}"`;
  const base = new Request(url, { headers: { "Signature-Agent": signatureAgent } });
  const created = new Date(Date.now() + offsetMs);
 
  const headers = await signatureHeaders(base, await signerFromJWK(PRIVATE_JWK), {
    created,
    expires: new Date(created.getTime() + 300_000),
    nonce: generateNonce(),
    components: REQUEST_COMPONENTS,
    key: "sig1",
  });
 
  return new Request(url, {
    headers: {
      "Signature-Agent": signatureAgent,
      Signature: headers["Signature"],
      "Signature-Input": headers["Signature-Input"],
    },
  });
}
 
describe("verifySignedRequest", () => {
  it("accepte une signature valide", async () => {
    const request = await makeSignedRequest();
    const result = await verifySignedRequest(request, AGENT, [PUBLIC_JWK]);
    expect(result.keyid).toBe(PUBLIC_JWK.kid);
  });
 
  it("rejette une signature liée à une autre autorité", async () => {
    const signed = await makeSignedRequest();
    // Mêmes en-têtes, hôte différent — @authority ne correspond plus
    const moved = new Request("https://evil.example/fr/services", {
      headers: signed.headers,
    });
    await expect(verifySignedRequest(moved, AGENT, [PUBLIC_JWK])).rejects.toThrow();
  });
 
  it("rejette un identifiant de clé inconnu", async () => {
    const request = await makeSignedRequest();
    const wrongKey = { ...PUBLIC_JWK, kid: "pas-la-bonne-empreinte" };
    await expect(verifySignedRequest(request, AGENT, [wrongKey])).rejects.toThrow(/keyid inconnu/);
  });
 
  it("rejette une signature expirée", async () => {
    const request = await makeSignedRequest("https://noqta.tn/fr/services", -7_200_000);
    await expect(verifySignedRequest(request, AGENT, [PUBLIC_JWK])).rejects.toThrow(/expired/i);
  });
});

Le deuxième test est celui qui prouve que le mécanisme fonctionne. Déplacer les en-têtes signés vers un autre hôte invalide la signature parce que @authority est un composant couvert — précisément la propriété qui rend toute falsification d'en-tête inutile.

Lancez les tests avec npx vitest run.

Dépannage

recommendedComponents is not exported — le README npm documente une API non publiée. Utilisez REQUEST_COMPONENTS depuis web-bot-auth.

keyid inconnu face à un opérateur réel — vous comparez presque certainement contre le champ kid du JWK alors que la signature porte l'empreinte RFC 7638. En pratique la plupart des opérateurs alignent kid sur l'empreinte, mais ne le présumez pas. Calculez l'empreinte vous-même avec jwkToKeyID et comparez contre elle.

La signature est valide en local mais échoue derrière un proxy@authority est dérivée de l'URL de la requête. Si un répartiteur de charge réécrit l'en-tête Host, l'autorité reconstruite diffère de celle qui a été signée. Assurez-vous que l'hôte d'origine parvient jusqu'à votre vérificateur, typiquement via la gestion de X-Forwarded-Host.

Signature expired sur toutes les requêtes — vérifiez la synchronisation de l'horloge de l'hôte vérificateur. Avec des fenêtres de cinq minutes, quelques minutes de dérive suffisent à tout rejeter. NTP n'est pas optionnel ici.

L'annuaire répond 200 mais aucune clé utilisable — votre normalisation de nbf/exp les élimine probablement. Journalisez les valeurs brutes ; si elles comptent 13 chiffres, ce sont des millisecondes.

La vérification passe mais le middleware ne s'exécute jamais — contrôlez le matcher. La configuration par défaut ignore _next/static, et il est facile d'exclure par accident les routes qui vous intéressent.

Étapes suivantes

Conclusion

Vous disposez maintenant d'une implémentation Web Bot Auth complète : des clés Ed25519 avec empreintes RFC 7638, un annuaire de signatures publié, un client signataire et un vérificateur d'origine qui contrôle le tag, résout les clés contre une liste d'autorisation, borne la dérive d'horloge et rejette les rejeux.

Ce qu'il faut retenir, c'est la frontière. Web Bot Auth vous dit qui a envoyé une requête avec une certitude cryptographique, et ne dit rien de ce que cette requête est autorisée à faire. Les composants couverts se limitent à l'autorité et à l'en-tête d'agent : une signature valide est donc une revendication d'identité, pas une capacité. Construisez l'autorisation par-dessus, jamais à sa place.

Posez cette frontière correctement et vous gagnez quelque chose de réellement nouveau : la capacité de traiter le trafic des agents IA comme un canal connu, hiérarchisé et mesurable, au lieu d'une catégorie d'abus à bloquer par réflexe.


Vous construisez pour le web agentique ? Noqta accompagne les équipes en Tunisie et en Arabie saoudite dans la conception d'infrastructures prêtes pour les agents — de l'identité des bots aux politiques de périphérie et à l'intégration MCP. Parlons-en.