Skip to content

Commit 351ae4e

Browse files
committed
feat: Web Bot Auth cryptographic verification (0.14.0)
Cloudflare's post introducing this mechanism is titled 'Forget IPs: using cryptography to verify bot and agent traffic'. The published-IP-range check shipped in 0.11 is precisely the method it retires, so the library should implement the successor rather than defend the predecessor. Web Bot Auth is an RFC 9421 HTTP Message Signatures profile — Ed25519 per request, a Signature-Agent header naming the key directory, keys at /.well-known/http-message-signatures-directory. Backed by Cloudflare, Amazon, Akamai and OpenAI, IETF working group chartered 2026. It dominates ranges on every axis that bit us: any agent that signs rather than four vendors, no freshness problem, no false 'spoofed' from a stale list, and it covers agents running on a user's own machine — the case ranges structurally cannot. Adds combinedVerifier(), which prefers a signature and falls back to ranges, so adoption shifts the mix with no change at the call site. Two deliberate rules: - A present-but-invalid signature returns 'spoofed' even when the client IP sits in a published range. Otherwise a forged signature could be laundered into 'verified' by the weaker check. - Unsigned traffic is 'unverifiable', never 'spoofed'. Most agents do not sign yet; treating silence as forgery would mislabel nearly all real traffic. Unsigned requests return before any I/O — the common path today costs nothing. Signed ones fetch the signer's directory once per origin and cache it, keyed by origin, for an hour. Structured-field parsing is scoped to the shapes this profile emits rather than a full RFC 8941 implementation, and returns null rather than guessing: a malformed header must never read as a valid signature. Tests 246 -> 262. The suite generates a real Ed25519 keypair and signs the actual signature base, then checks replay onto another path, a tampered signature, a key absent from the directory, expiry both by `expires` and by maxAge, non-https signers, an unreachable directory, allowedSigners, that unsigned traffic triggers no fetch, and that the directory is cached. Fixture signatures would only have encoded whatever the parser happens to do.
1 parent 483706b commit 351ae4e

7 files changed

Lines changed: 688 additions & 3 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ Now you can build:
8282

8383
---
8484

85+
## Cryptographic verification (Web Bot Auth)
86+
87+
Published IP ranges were always the weak form of identity. [Web Bot
88+
Auth](https://blog.cloudflare.com/web-bot-auth/) is the strong one: an RFC 9421
89+
HTTP Message Signatures profile where an agent signs each request with Ed25519
90+
and publishes its keys at a well-known directory. Backed by Cloudflare, Amazon,
91+
Akamai and OpenAI, with an IETF working group chartered in 2026.
92+
93+
```ts
94+
import { combinedVerifier } from '@apideck/agent-analytics/verify'
95+
96+
void trackVisit(req, { analytics, verify: combinedVerifier() })
97+
```
98+
99+
`combinedVerifier` prefers the signature and falls back to ranges:
100+
101+
| | published IP ranges | Web Bot Auth |
102+
| --- | --- | --- |
103+
| Coverage | 4 vendors | any agent that signs |
104+
| Freshness | rots; needs weekly refresh | none needed |
105+
| False `spoofed` | stale list accuses real crawlers | impossible |
106+
| Agents on a user's machine | unverifiable | signable |
107+
108+
A present-but-invalid signature is decisive: it returns `spoofed` even if the
109+
client IP happens to sit in a published range, so a forged signature cannot be
110+
laundered by the weaker check. Unsigned traffic is `unverifiable`, never
111+
`spoofed` — most agents do not sign yet, and treating silence as forgery would
112+
mislabel nearly all real traffic.
113+
114+
Unsigned requests cost nothing: the check returns before any I/O. Signed ones
115+
fetch the signer's key directory once per origin and cache it for an hour.
116+
85117
## Upgrading to 0.12
86118

87119
Four breaking changes, all deliberate. Each one existed because the previous

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@apideck/agent-analytics",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.",
55
"keywords": [
66
"ai",

src/track.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ export async function trackVisit(req: Request, opts: TrackVisitOptions): Promise
8080
// Verification is injected rather than imported, so the published IP range
8181
// tables only reach bundles that actually use them. Import `verifyRequest`
8282
// from `@apideck/agent-analytics/verify` and pass it as `verify`.
83-
const verification = opts.verify ? opts.verify(req) : null
83+
// May be async: Web Bot Auth fetches a signer's key directory on first
84+
// sight of that origin, then serves from cache.
85+
const verification = opts.verify ? await opts.verify(req) : null
8486

8587
// Headless scoring only discriminates for browser-shaped UAs. On a declared
8688
// crawler or an HTTP client it fires on nearly everything — measured true on

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface TrackVisitOptions {
4141
* Injected rather than imported so the published IP range tables — the
4242
* largest thing in the package — only reach bundles that use them.
4343
*/
44-
verify?: (req: Request) => BotVerificationLike
44+
verify?: (req: Request) => BotVerificationLike | Promise<BotVerificationLike>
4545
/**
4646
* Label describing how the request arrived (e.g. `'page-view'`, `'md-suffix'`,
4747
* `'ua-rewrite'`). Emitted as a `source` property on the captured event so

src/verify.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,50 @@ export function clientIpFromRequest(req: Request): string {
159159
export function verifyRequest(req: Request): BotVerification {
160160
return verifyBotIdentity(req.headers.get('user-agent'), clientIpFromRequest(req))
161161
}
162+
163+
export {
164+
clearKeyCache,
165+
jwkThumbprint,
166+
verifyWebBotAuth,
167+
webBotAuthVerifier
168+
} from './webbotauth.js'
169+
export type { WebBotAuthOptions, WebBotAuthResult, WebBotAuthVerdict } from './webbotauth.js'
170+
171+
import { verifyWebBotAuth, type WebBotAuthOptions } from './webbotauth.js'
172+
import type { BotVerificationLike } from './types.js'
173+
174+
/**
175+
* Verifier that prefers a cryptographic signature and falls back to published
176+
* IP ranges.
177+
*
178+
* Ordering matters. Web Bot Auth proves control of a signing key, works for
179+
* any agent that adopts it, and cannot go stale. IP ranges cover four vendors,
180+
* rot between refreshes, and cannot see an agent running on a user's own
181+
* machine. So a signature — valid or invalid — is always the answer when one
182+
* is present; ranges only speak when the request is unsigned.
183+
*
184+
* As signing adoption grows this quietly shifts from mostly-ranges to
185+
* mostly-signatures with no change at the call site.
186+
*/
187+
export function combinedVerifier(
188+
opts: WebBotAuthOptions = {}
189+
): (req: Request) => Promise<BotVerificationLike> {
190+
return async (req: Request): Promise<BotVerificationLike> => {
191+
const signed = await verifyWebBotAuth(req, opts)
192+
193+
if (signed.verdict === 'verified') {
194+
return { verdict: 'verified', verified: true, reason: 'web-bot-auth' }
195+
}
196+
// A signature that is present and fails is decisive — do not let a lucky
197+
// IP-range hit launder a forged signature into 'verified'.
198+
if (signed.verdict === 'invalid-signature' || signed.verdict === 'expired') {
199+
return { verdict: 'spoofed', verified: false, reason: `web-bot-auth-${signed.verdict}` }
200+
}
201+
202+
const byRange = verifyRequest(req)
203+
if (byRange.verdict !== 'not-claimed' && byRange.verdict !== 'unverifiable') {
204+
return { ...byRange, reason: byRange.reason ?? 'published-ip-range' }
205+
}
206+
return byRange
207+
}
208+
}

0 commit comments

Comments
 (0)