Bots now generate more HTML requests than humans do. The tooling most teams use to tell them apart — user-agent strings and IP allowlists — was never designed to resist an adversary, and it does not.
Web Bot Auth replaces that guesswork with cryptography. An agent operator signs every outbound request with an Ed25519 private key and publishes the matching public key at a well-known URL. Your origin verifies the signature. A scraper claiming to be GPTBot either holds OpenAI's private key or it does not, and no amount of header forgery changes that.
This tutorial builds the whole loop in Next.js: key generation, directory hosting, request signing, and a hardened origin-side verifier wired into middleware with replay protection.
Prerequisites
- Node.js 20+ — Ed25519 in WebCrypto is required
- Next.js 15.5 or 16 with the App Router
- Working TypeScript, and comfort with
async/await - Familiarity with HTTP headers and public-key cryptography at a conceptual level
- Optional: an Upstash Redis instance for the replay-protection step
You do not need a Cloudflare account. Everything here runs on plain Node.
What You'll Build
Two halves of one protocol:
- The signer — an outbound HTTP client that signs requests, plus a
/.well-known/http-message-signatures-directoryroute publishing your public key. This is what you build if you operate an agent. - The verifier — Next.js middleware that validates inbound signatures against a cached, remotely-fetched key directory, rejects replays, and tags each request with a verified operator identity for downstream routing.
By the end, a request that survives your middleware carries a cryptographic guarantee about who sent it.
How the Protocol Actually Works
Web Bot Auth is a thin profile over RFC 9421 (HTTP Message Signatures), a ratified IETF Proposed Standard. The profile is defined in draft-meunier-webbotauth-httpsig-protocol.
A signed request carries three headers:
GET /en/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...:Three things deserve attention.
The covered components are minimal. The list ("@authority" "signature-agent") means only the target authority and the Signature-Agent header are signed. Not the path, not the method, not the body. That is deliberate — it keeps signatures stable across proxies and redirects — but it has a security consequence you must design around, covered in the replay-protection step.
keyid is a thumbprint, not a name. It is the RFC 7638 JWK thumbprint of the public key, base64url-encoded. You do not look up keys by a human label; you look them up by a hash of the key material itself. This is why key rotation is safe: a new key produces a new thumbprint and cannot be confused with the old one.
tag="web-bot-auth" scopes the signature. RFC 9421 is a general-purpose mechanism. The tag stops a signature minted for some other purpose from being replayed as a bot-identity claim. Verifying the tag is your job, and the library will not do it for you.
Here is a real directory, live today at 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"
}It is a JSON Web Key Set with two extra fields: signature_agent (which must match the header) and purpose (a hint about what the traffic is for — ai, rag, and similar).
Step 1: Project Setup
Install the Cloudflare-maintained reference packages:
npm install web-bot-auth joseweb-bot-auth version 0.1.3 pulls in http-message-sig and jsonwebkey-thumbprint transitively. jose is only needed for key generation in the next step.
The package exposes two entry points:
// Protocol logic, constants, verification
import { verify, signatureHeaders, REQUEST_COMPONENTS } from "web-bot-auth";
// Crypto adapters over WebCrypto
import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto";The published README on npm shows a
recommendedComponents("sig1")helper. That export does not exist in 0.1.3 — the README is ahead of the release. Use theREQUEST_COMPONENTSconstant instead, which is the exported equivalent. This trips up nearly everyone on first install.
Step 2: Generate an Ed25519 Keypair
Create scripts/generate-key.ts. This runs once, offline, and its output is the root of your whole identity.
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" };
// RFC 7638 thumbprint — this becomes the `keyid` in every signature you emit
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("Key ID (thumbprint):", kid);
}
main();Run it:
mkdir -p keys && npx tsx scripts/generate-key.tsThe thumbprint computation matters. Note the argument order: jwkToKeyID takes the JWK, a hash function, and a decoder. The helpers object supplies WebCrypto-backed implementations of both, so you never hand-roll the RFC 7638 canonicalisation.
Add keys/private.jwk.json to .gitignore immediately, then move it into your secret manager. In production, load it from an environment variable:
const privateJWK = JSON.parse(process.env.WEB_BOT_AUTH_PRIVATE_JWK!);Never commit the private JWK. An Ed25519 private key that leaks lets anyone impersonate your agent against every origin that trusts you, and your only remedy is publishing a new key and waiting out every verifier's cache TTL.
Step 3: Publish Your Signature Directory
If you operate an agent, origins need a way to fetch your public key. Create 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",
},
});
}The content type is application/http-message-signatures-directory+json, and the library gives you it as a constant so you cannot typo it.
Two details that cause real interoperability failures:
signature_agentmust exactly match the value your signer puts in theSignature-Agentheader, including scheme and excluding any trailing slash. Strict verifiers reject a mismatch.- Keep old keys in the array during rotation. Publish the new key alongside the old one for at least as long as your longest advertised cache TTL, then remove the old one. Removing it the moment you rotate breaks every verifier holding a cached directory.
Verify it serves correctly:
curl -s http://localhost:3000/.well-known/http-message-signatures-directory | jqStep 4: Sign Outbound Requests
Now the signer. Create 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;
// Cache the signer — importing a CryptoKey on every request is wasteful
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 = {}) {
// The Signature-Agent value is a quoted string
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"],
},
});
}A few decisions encoded here:
Always send a nonce. It is optional in the spec but it is what makes replay detection possible at the origin. generateNonce() produces 64 random bytes, which is ample.
Keep the validity window short. Five minutes bounds how long a captured signature stays useful. Signing with a 24-hour expiry — as some published examples do — hands an attacker a full day of replay window.
Sign with REQUEST_COMPONENTS. This includes signature-agent in the covered components. If you omit the header from the request, the library silently falls back to REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT, and verifiers expecting the agent binding will reject you.
Test against Cloudflare Research's public debug endpoint, which validates your implementation and reports what it saw:
const res = await signedFetch(
"https://http-message-signatures-example.research.cloudflare.com/debug"
);
console.log(await res.json());Step 5: Resolve Public Keys at the Origin
Switching sides. Before verifying anything, your origin needs the signer's public keys — fetched from the Signature-Agent origin and cached aggressively, because a network round trip per inbound request is not viable.
Create 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 hour
const FETCH_TIMEOUT_MS = 2000;
// Only these operators are trusted. An open-ended fetch of any origin a
// request names is a server-side request forgery vector.
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;
// The value is a quoted string, optionally with structured-field parameters
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(`untrusted signature agent: ${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) {
// Serve stale rather than failing open or failing closed on a blip
if (cached) return cached.keys;
throw new Error(`directory fetch failed: ${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;
// Operators publish nbf/exp inconsistently — some in seconds, some in
// milliseconds. Normalise before comparing.
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;
}Three hard-won points here.
The allowlist is not optional. Without it, an attacker sends Signature-Agent: "https://internal.your-vpc.local" and your server dutifully makes an outbound request to whatever they name. That is textbook SSRF. Trust is an explicit decision, one operator at a time.
Serve stale on fetch failure. If an operator's directory has a bad minute, you do not want every one of their agents to start failing verification. Falling back to the cached copy is the right trade.
Normalise nbf and exp. Cloudflare Research publishes nbf as 1743465600000 — milliseconds. OpenAI publishes 1735689600 — seconds. Both are live right now. Comparing raw values against Date.now() silently rejects one of them.
Step 6: Write the Verifier
Create 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;
// Compose our own checks around the library's signature verification
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(`unexpected tag: ${params.tag}`);
}
const jwk = index.get(params.keyid);
if (!jwk) throw new Error(`unknown keyid: ${params.keyid}`);
if (params.created.getTime() - Date.now() > MAX_CLOCK_SKEW_MS) {
throw new Error("signature created in the future");
}
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("verification produced no result");
return result;
}The composition pattern is the important idea. verify() parses the headers, rebuilds the signature base, and hands your callback the raw data plus the parsed parameters. That callback is where you enforce everything the library does not: tag scoping, key selection by thumbprint, and clock-skew bounds.
What the library does handle for you: signature-base reconstruction, Ed25519 verification, and expiry. A request past its expires throws Signature expired before your callback's result is returned.
What it does not handle: the tag, key resolution, clock skew, and replay. Those are the four lines of defence you just wrote — plus one more.
Step 7: Replay Protection
Recall that the signature covers only @authority and signature-agent. It does not cover the path, the method, or the body. A signature captured from a request to /en/pricing is byte-for-byte valid on a POST to /api/admin/delete at the same host, until it expires.
This is the single most important thing to understand about Web Bot Auth: it authenticates the sender, not the request. Treat it as identity, never as authorisation.
Within its validity window the only defence is nonce tracking. Create lib/web-bot-auth/replay.ts:
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
/**
* Records a nonce and reports whether it was already seen.
* The key expires when the signature does, so storage stays bounded.
*/
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; // already expired, treat as replay
const key = `wba:nonce:${keyid}:${nonce}`;
// SET NX returns null when the key already exists
const stored = await redis.set(key, "1", { nx: true, ex: ttlSeconds });
return stored === null;
}Because the TTL is tied to the signature's own expiry, the store never grows beyond one window's worth of traffic. Short validity windows are therefore not just a security property — they are what keeps this affordable.
If a signature arrives without a nonce, you have a choice. Rejecting it is the safer default for anything that mutates state; accepting it is defensible for read-only content routes. Make that decision explicitly rather than by omission.
Step 8: Wire It Into Next.js Middleware
Create middleware.ts at the project root:
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 a network fetch — the Node runtime is required
export const config = {
runtime: "nodejs",
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
export async function middleware(request: NextRequest) {
const headers = new Headers(request.headers);
// Strip any client-supplied value so downstream code cannot be fooled
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("Replayed signature", { status: 401 });
}
headers.set("x-verified-agent", verified.agentOrigin);
headers.set("x-agent-keyid", verified.keyid);
} catch (error) {
// A failed signature is a stronger signal than no signature at all:
// someone tried and got it wrong. Log it, do not silently ignore it.
console.warn("web-bot-auth verification failed", {
agentOrigin,
reason: error instanceof Error ? error.message : "unknown",
});
return new NextResponse("Invalid signature", { status: 401 });
}
}
return NextResponse.next({ request: { headers } });
}Two things here are load-bearing.
Deleting the headers before setting them. If you only ever set x-verified-agent on success, a client can send that header itself and every downstream handler will believe it. Stripping inbound copies first closes that hole.
Rejecting rather than downgrading on failure. An unsigned request is anonymous, which is fine. A request with a broken signature is an active attempt at something. Returning 401 is the correct response, and logging it gives you the signal that someone is probing.
export const config = { runtime: "nodejs" } is stable in current Next.js and needs no experimental flag. It is required here: the Edge runtime's Ed25519 support varies by platform, and you need fetch with a timeout for directory resolution.
Step 9: Act On the Verdict
Verification is only worth the effort if something downstream changes. In a Server Component or Route Handler:
import { headers } from "next/headers";
export default async function ServicesPage() {
const agent = (await headers()).get("x-verified-agent");
if (agent) {
// A verified operator — serve a lean, structured representation.
// Skipping the marketing shell cuts payload and improves parse accuracy.
return <ServicesStructuredView operator={agent} />;
}
return <ServicesMarketingPage />;
}The natural policy tiers, from most to least trusted:
| Traffic class | Treatment |
|---|---|
| Verified, user-triggered agent | Serve fully, generous rate limit, track as a channel |
| Verified search crawler | Serve, standard crawl budget |
| Verified training crawler | Business decision — allow, meter, or require a licence |
| Unsigned, self-declared bot | Rate limit, enforce robots.txt |
| Broken signature | Reject with 401 and alert |
Note that "block everything" is rarely the right lever. Blocking user-triggered agent traffic in 2026 works out roughly the way blocking mobile browsers did in 2010.
Testing Your Implementation
RFC 9421 Appendix B.1.4 publishes a test keypair, which lets you write deterministic tests with no key management. Create 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";
// RFC 9421 Appendix B.1.4 test key — public knowledge, never use in 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", // thumbprint of the above
};
const AGENT = "https://signer.example.com";
async function makeSignedRequest(url = "https://noqta.tn/en/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("accepts a valid signature", async () => {
const request = await makeSignedRequest();
const result = await verifySignedRequest(request, AGENT, [PUBLIC_JWK]);
expect(result.keyid).toBe(PUBLIC_JWK.kid);
});
it("rejects a signature bound to a different authority", async () => {
const signed = await makeSignedRequest();
// Same headers, different host — @authority no longer matches
const moved = new Request("https://evil.example/en/services", {
headers: signed.headers,
});
await expect(verifySignedRequest(moved, AGENT, [PUBLIC_JWK])).rejects.toThrow();
});
it("rejects an unknown key id", async () => {
const request = await makeSignedRequest();
const wrongKey = { ...PUBLIC_JWK, kid: "not-the-right-thumbprint" };
await expect(verifySignedRequest(request, AGENT, [wrongKey])).rejects.toThrow(/unknown keyid/);
});
it("rejects an expired signature", async () => {
const request = await makeSignedRequest("https://noqta.tn/en/services", -7_200_000);
await expect(verifySignedRequest(request, AGENT, [PUBLIC_JWK])).rejects.toThrow(/expired/i);
});
});The second test is the one that proves the mechanism works. Moving the signed headers to a different host invalidates the signature because @authority is a covered component — exactly the property that makes header forgery useless.
Run with npx vitest run.
Troubleshooting
recommendedComponents is not exported — the npm README documents an unreleased API. Use REQUEST_COMPONENTS from web-bot-auth.
unknown keyid against a real operator — you are almost certainly comparing against the JWK's kid field while the signature carries the RFC 7638 thumbprint. In practice most operators set kid to the thumbprint so they match, but do not assume it. Compute the thumbprint yourself with jwkToKeyID and compare against that.
Signature verifies locally but fails behind a proxy — @authority is derived from the request URL. If a load balancer rewrites the Host header, the reconstructed authority differs from the signed one. Ensure the original host reaches your verifier, typically via X-Forwarded-Host handling.
Signature expired on every request — check clock sync on the verifying host. With five-minute windows, a couple of minutes of drift is enough to reject everything. NTP is not optional here.
Directory fetch returns 200 but no usable keys — your nbf/exp normalisation is likely dropping them. Log the raw values; if they are 13 digits, they are milliseconds.
Verification passes but the middleware never runs — check the matcher. The default config skips _next/static, and it is easy to accidentally exclude the routes you care about.
Next Steps
- Add per-operator rate limits keyed on
x-verified-agentrather than IP, building on the approach in our Upstash Redis rate limiting tutorial - Emit structured content to verified agents using the patterns in Bots Are 57% of Web Traffic
- If you are exposing actions rather than content, pair identity with scoped authorisation — see WebMCP browser agent tools
- Harden agent-facing endpoints against injection with AI agent guardrails
- Track the specification at
draft-meunier-webbotauth-httpsig-protocol— the IETF working group chartered in 2026 and header details may still shift
Conclusion
You now have a complete Web Bot Auth implementation: Ed25519 keys with RFC 7638 thumbprints, a published signature directory, a signing client, and an origin verifier that checks the tag, resolves keys against an allowlist, bounds clock skew, and rejects replays.
The part worth carrying forward is the boundary. Web Bot Auth tells you who sent a request with cryptographic certainty, and it says nothing about what that request is allowed to do. The covered components are just the authority and the agent header — so a valid signature is an identity claim, not a capability. Build authorisation on top of it, never instead of it.
Get that boundary right and you gain something genuinely new: the ability to treat AI agent traffic as a known, tiered, measurable channel rather than a category of abuse to be blocked on sight.
Building for the agentic web? Noqta helps teams across Tunisia and Saudi Arabia design agent-ready infrastructure — from bot identity and edge policy to MCP integration. Talk to us.