Skip to content
↑↓ to move · Enter to openEsc to close
Release notes · security · rate limiting · release notes

Rate limiting every public write endpoint on agenid.com

Shipped · commits d8d9f96

Before this change, 25 rapid unauthenticated POSTs to https://www.agenid.com/api/v1/agents were all processed, with no 429 and no backoff. That was threat T-12 in the threat model, registration flooding and relay abuse, and it was the last threat in the model that anyone with an HTTP client could exploit today.

Every POST endpoint is now rate limited, a batch endpoint has a size cap, and a wiring test fails the build if a new public write route is added without the limiter. Every write was already signature-verified; what was missing was a bound on volume.

Two layers, and why the order is the design

The first layer is an in-process fixed window that cannot fail. The second is a durable counter in Postgres, rate_limit_hit, which performs the increment and the decision in one statement so concurrent requests serialize on the row instead of racing.

The ordering gives the only honest definition of fail-open for this system. If the database is unreachable, the limiter degrades to per-instance limiting, never to unbounded. A test asserts exactly that by making 100 attempts against a broken durable layer and observing that only the burst gets through.

The limit is checked before the request body is read, and a caller refused by the in-process floor never reaches the database, so the limiter cannot itself become an amplifier.

Identifying the caller without trusting the caller

The client is identified from platform-set headers, x-real-ip and x-vercel-forwarded-for, not from x-forwarded-for, which the caller supplies. Keying on a caller-supplied header would let a caller mint a fresh bucket per request: a limiter that looks like one and bounds nothing. IPv6 addresses are collapsed to their /64, so one allocation is one bucket.

POST /api/retell/bind also caps its agents array at 25 and returns 400 batch_too_large above that. Without the cap, the rate limit would bound requests while one request could still trigger an unbounded number of store writes.

The wiring test found six more routes

The gap analysis named four surfaces, and the first pass bounded those four. A guard that walks every route.ts under app/api and requires each POST handler to call the limiter then failed on six more routes. All ten are now bounded. The standing rule applies: a fix scoped to the instance you found is a sample, not a fix.

The guard also asserts that the limiter runs before the body is parsed, and it carries an explicit exemption list with a stated reason per entry. Removing the limiter from the registration route turns three tests red.

Counters are telemetry, not registry data

rate_limit_counters ships with row-level security enabled and no policies at all, unlike every other table in the schema. Per-caller request counts are operational telemetry, and publishing them would turn the limiter's own state into a reconnaissance surface.

A follow-up fix corrected the grants on the two SECURITY DEFINER functions. Revoking EXECUTE from anon and authenticated removed nothing, because Postgres grants EXECUTE to PUBLIC on new functions. A corrective migration closes that, a test fails on any such function whose EXECUTE is never revoked from PUBLIC, and the sweep of old windows is now actually called, on roughly one durable hit in 200.

What this does not do

Rate limiting bounds volume. It does not authenticate anyone. Nothing yet binds a live HTTP request to a registered agent, and the residual risk from distributed sources is rated medium rather than closed. It also changes nothing about levels. The reference deployment at agenid.com issues nothing above L1_REGISTERED today: L2 and higher require a VerificationAssertion signed by a root authority key, and that key has not been created yet. L5 is reserved and is not issuable at all.

Which agent is acting is not the same question as what it is allowed to do. AgenID answers the first. Protocol v1.1.1 defines no signed authorization object, so permission stays with the operator and with the party on the other side.

To see it, register an agent at /issue; the write path it calls is one of the ten bounded routes.

Frequently asked questions

What happens if the rate-limit database is down?

The durable layer is skipped and the in-process floor still applies, so limiting degrades to per-instance rather than disappearing.

Why not use the X-Forwarded-For header?

The caller controls it. Keying a limiter on it would let each request claim a new identity and a new bucket.

Does rate limiting authenticate agents?

No. It limits how often a source may write. Request-level authentication, binding a live request to a registered agent, is planned and not implemented.

What agenid.com issues today

The reference deployment at agenid.com issues DECLARED and L1_REGISTERED only. L2_DOMAIN_VERIFIED and higher require a VerificationAssertion signed by a root authority key, and that key has not been created yet. L5 is reserved by the specification and is not issuable at all.

Identity is not permission

Which agent is acting and who declared it is one question. What that agent is allowed to do is another, and it stays with the party on the other side and with the operator. Protocol v1.1.1 defines no signed authorization object.

Give your agent an identity

Register an agent in the browser — the signing key is generated on your device and never sent to AgenID — or resolve an existing AgenID and re-check its signature yourself.