npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agentproofid/check

v0.3.1

Published

Verify AI agent identity at the edge. RFC 9421 HTTP Message Signatures + DPoP. Zero dependencies, WebCrypto only.

Downloads

377

Readme

@agentproofid/check

Verify AI agent identity at the edge. Zero dependencies, WebCrypto only.

Implements RFC 9421 HTTP Message Signatures (web-bot-auth profile) and key-bound identity tokens (RFC 7800 cnf + RFC 9449 DPoP). Runs identically on Cloudflare Workers, Deno, Bun, Node 18+ and in the browser.

npm install @agentproofid/check

Interoperability

Verified against the public reference vector published by Cloudflare Research at http-message-signatures-example.research.cloudflare.com:

expected kid : poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U
computed     : poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U   ✓ RFC 7638

That's the Ed25519 test key from RFC 9421 Appendix B.1.4. This check runs in CI on every commit — if we ever stop being interoperable, the build breaks.

Usage

Cloudflare Workers

import { agentCheck, keyring } from '@agentproofid/check';

// Startup, once. Never on the request path.
keyring().addDirectory(
  'https://agent.bot.goog/.well-known/http-message-signatures-directory',
  'google.com'
);
await keyring().refresh();

export default {
  async fetch(request) {
    const auth = await agentCheck(request);
    if (!auth.verified) return auth.challengeResponse();

    return new Response(`Hello ${auth.agentId} from ${auth.ownerDomain}`);
  }
}

Express

const { expressAgentCheck } = require('@agentproofid/check');

app.use('/api', expressAgentCheck({ realm: 'shop.example' }));
app.get('/api/catalog', (req, res) => {
  res.json({ servedTo: req.agent.agentId });
});

Signing (agent side)

import { generateAgentKey, signRequest } from '@agentproofid/check/signing';

const key = await generateAgentKey('ed25519');
const { headers, signatureBase } = await signRequest(key, {
  method: 'GET',
  url: 'https://merchant.example/api/catalog',
  tag: 'web-bot-auth',
});

await fetch('https://merchant.example/api/catalog', { headers });

signatureBase is returned deliberately: it's the exact string that was signed, and it's the only practical way to debug a failing verification.

The 428 response

When verification fails, challengeResponse() returns 428 Precondition Required with machine-readable instructions:

WWW-Authenticate: AgentProofID realm="shop.example", challenge="Provide RFC9421 Signature",
                  alg="ed25519 ecdsa-p256-sha256", tag="web-bot-auth"

The body carries the required covered components, accepted algorithms, and a worked header example. An honest agent reads it once and authenticates on the retry.

Why 428 and not 401 or 403. 401 implies a credential the client should already hold. 403 says no and closes the conversation. 428 says the request is legitimate but a precondition is missing, and here is exactly which one — the only one that describes the situation correctly to an automated client.

Design notes

No I/O on the request path. No fetch, no DNS, no KV. Keys live in memory and refresh on a separate cold path. If our service goes down, your site keeps verifying.

Verdict caching is keyed on signature + digest of the signature base, never on the signature alone. Keying on the signature alone would be a vulnerability: a signature valid for one request would be accepted for another.

Signature-Input params are reused verbatim for the @signature-params line, never re-serialized. Re-serializing means replicating the signer's canonicalization choices, and it's the leading cause of false negatives in RFC 9421 implementations.

Key validity windows are in milliseconds. The Web Bot Auth directory format uses millisecond timestamps for nbf/exp, while RFC 7519 JWT claims use seconds. Mixing the two makes a revoked key valid for millennia with no visible symptom. Validity is checked on every lookup, not just at load — otherwise a key can expire in the gap between refreshes.

ECDSA signatures are raw r||s, 64 bytes — not DER. WebCrypto rejects DER. Conversion is handled for you.

API

| Export | Purpose | |---|---| | agentCheck(request, options) | Main entry. Never throws; always returns a result. | | keyring() | Process-wide shared KeyRing. | | KeyRing | Trusted keys. addJwk, addDirectory, refresh, prune. | | VerdictCache | Bounded verdict cache. | | buildSignatureBase(entry, ctx) | Exported for debugging failed verifications. | | parseSignatureInput(header) | RFC 8941 parser for the hot path. | | jwkThumbprint(jwk) | RFC 7638. | | expressAgentCheck(options) | Express / Connect middleware. | | withAgentCheck(handler, options) | Wrapper for any fetch handler. | | generateAgentKey, signRequest, buildManifest | From /signing. |

AgentCheckResult

{
  verified: boolean;
  agentId: string;
  ownerDomain: string;
  reputationScore: number;
  reputationBasis: "static_trust_tier" | "observed_history";
  identity: AgentIdentity | null;
  reason: DenyReason | null;
  elapsedMicros: number;
  cacheHit: boolean;
  challengeResponse: () => Response;
}

reputationScore is derived from locally verifiable facts only — directory tier, covered components, freshness. reputationBasis tells you which: it reads static_trust_tier until there is real aggregate history behind it. It is not an observed reputation, and the field says so rather than implying otherwise.

Performance

Measured with performance.now(), Node 24 on Apple Silicon, 3000 iterations:

| Path | p50 | p99 | |---|---|---| | Reject, no credentials | 3 µs | 24 µs | | Verify, warm cache | 23 µs | 101 µs | | Verify ECDSA P-256, cold | 91 µs | 151 µs |

The reject is the cheapest path in the system, not the most expensive: no cryptography, two header reads, return.

The comparison that matters isn't against zero — it's against the network round trip this architecture avoids:

| | latency | |---|---| | Local verification | 0.09 ms | | Intra-datacenter RTT | 0.5 – 2 ms | | Same-region RTT | 2 – 15 ms | | Cross-region RTT | 30 – 150 ms |

Body binding and replay defence

Signed writes must cover the body. For POST, PUT, PATCH and DELETE, content-digest (RFC 9530) is required among the covered components and checked against the body received. Without it, a signature covering only method, authority and path says nothing about the payload: any intermediary can rewrite the body and verification still passes.

signRequest computes the digest for you when given a body:

await signRequest(key, {
  method: "POST",
  url: "https://merchant.example/api/orders",
  body: JSON.stringify({ sku: "HDX-900" }),
});

Implementing it by hand? RFC 9530 uses standard base64 with padding, not base64url.

Nonces are opt-in, and single-use when present. Under the web-bot-auth profile a signature is legitimately reused across thousands of requests inside its validity window — that is what the verdict cache exists for. Emitting a nonce by default would make every signature single-use and break the primary use case. Declaring one is a statement of intent: this signature is for one request only.

import { agentCheck, MemoryNonceStore, kvNonceStore } from '@agentproofid/check';

await agentCheck(request, { nonceStore: new MemoryNonceStore() });   // one process
await agentCheck(request, { nonceStore: kvNonceStore(env.NONCES) }); // Workers

The nonce is consumed after the cryptographic check, never before: consuming it first would let an attacker burn arbitrary nonces with forged requests and lock out the legitimate traffic carrying them.

MemoryNonceStore is correct only in a single process. Across several workers it gives the illusion of protection and none of the substance — a replay simply lands on the instance that hasn't seen the nonce. Use a Durable Object or Redis. kvNonceStore is a large improvement over nothing, but KV is eventually consistent and a replay hitting another colo within the propagation window can slip through.

Not done yet

  • Signed directory responses. The spec allows directories to sign their own JWKS response; we fetch but don't verify that signature.
  • Scheduled refresh. KeyRing.refresh() exists but you must wire it to a cron trigger yourself.
  • Rate limiting per verified identity, shared across workers.

License

MIT