Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ Now you can build:

---

## Cryptographic verification (Web Bot Auth)

Published IP ranges were always the weak form of identity. [Web Bot
Auth](https://blog.cloudflare.com/web-bot-auth/) is the strong one: an RFC 9421
HTTP Message Signatures profile where an agent signs each request with Ed25519
and publishes its keys at a well-known directory. Backed by Cloudflare, Amazon,
Akamai and OpenAI, with an IETF working group chartered in 2026.

```ts
import { combinedVerifier } from '@apideck/agent-analytics/verify'

void trackVisit(req, { analytics, verify: combinedVerifier() })
```

`combinedVerifier` prefers the signature and falls back to ranges:

| | published IP ranges | Web Bot Auth |
| --- | --- | --- |
| Coverage | 4 vendors | any agent that signs |
| Freshness | rots; needs weekly refresh | none needed |
| False `spoofed` | stale list accuses real crawlers | impossible |
| Agents on a user's machine | unverifiable | signable |

A present-but-invalid signature is decisive: it returns `spoofed` even if the
client IP happens to sit in a published range, so a forged signature cannot be
laundered by the weaker check. Unsigned traffic is `unverifiable`, never
`spoofed` — most agents do not sign yet, and treating silence as forgery would
mislabel nearly all real traffic.

Unsigned requests cost nothing: the check returns before any I/O. Signed ones
fetch the signer's key directory once per origin and cache it for an hour.

## Upgrading to 0.12

Four breaking changes, all deliberate. Each one existed because the previous
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apideck/agent-analytics",
"version": "0.13.0",
"version": "0.14.0",
"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.",
"keywords": [
"ai",
Expand Down
4 changes: 3 additions & 1 deletion src/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export async function trackVisit(req: Request, opts: TrackVisitOptions): Promise
// Verification is injected rather than imported, so the published IP range
// tables only reach bundles that actually use them. Import `verifyRequest`
// from `@apideck/agent-analytics/verify` and pass it as `verify`.
const verification = opts.verify ? opts.verify(req) : null
// May be async: Web Bot Auth fetches a signer's key directory on first
// sight of that origin, then serves from cache.
const verification = opts.verify ? await opts.verify(req) : null

// Headless scoring only discriminates for browser-shaped UAs. On a declared
// crawler or an HTTP client it fires on nearly everything — measured true on
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface TrackVisitOptions {
* Injected rather than imported so the published IP range tables — the
* largest thing in the package — only reach bundles that use them.
*/
verify?: (req: Request) => BotVerificationLike
verify?: (req: Request) => BotVerificationLike | Promise<BotVerificationLike>
/**
* Label describing how the request arrived (e.g. `'page-view'`, `'md-suffix'`,
* `'ua-rewrite'`). Emitted as a `source` property on the captured event so
Expand Down
47 changes: 47 additions & 0 deletions src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,50 @@ export function clientIpFromRequest(req: Request): string {
export function verifyRequest(req: Request): BotVerification {
return verifyBotIdentity(req.headers.get('user-agent'), clientIpFromRequest(req))
}

export {
clearKeyCache,
jwkThumbprint,
verifyWebBotAuth,
webBotAuthVerifier
} from './webbotauth.js'
export type { WebBotAuthOptions, WebBotAuthResult, WebBotAuthVerdict } from './webbotauth.js'

import { verifyWebBotAuth, type WebBotAuthOptions } from './webbotauth.js'
import type { BotVerificationLike } from './types.js'

/**
* Verifier that prefers a cryptographic signature and falls back to published
* IP ranges.
*
* Ordering matters. Web Bot Auth proves control of a signing key, works for
* any agent that adopts it, and cannot go stale. IP ranges cover four vendors,
* rot between refreshes, and cannot see an agent running on a user's own
* machine. So a signature — valid or invalid — is always the answer when one
* is present; ranges only speak when the request is unsigned.
*
* As signing adoption grows this quietly shifts from mostly-ranges to
* mostly-signatures with no change at the call site.
*/
export function combinedVerifier(
opts: WebBotAuthOptions = {}
): (req: Request) => Promise<BotVerificationLike> {
return async (req: Request): Promise<BotVerificationLike> => {
const signed = await verifyWebBotAuth(req, opts)

if (signed.verdict === 'verified') {
return { verdict: 'verified', verified: true, reason: 'web-bot-auth' }
}
// A signature that is present and fails is decisive — do not let a lucky
// IP-range hit launder a forged signature into 'verified'.
if (signed.verdict === 'invalid-signature' || signed.verdict === 'expired') {
return { verdict: 'spoofed', verified: false, reason: `web-bot-auth-${signed.verdict}` }
}

const byRange = verifyRequest(req)
if (byRange.verdict !== 'not-claimed' && byRange.verdict !== 'unverifiable') {
return { ...byRange, reason: byRange.reason ?? 'published-ip-range' }
}
return byRange
}
}
Loading
Loading