Skip to main content
AFAuth reserves rate_limit_exceeded (429) in §11.3 but takes no position on policy — you choose the limits and the backend. The Server enforces per-route limits when you give it a RateLimiter and a per-route config; everything beyond the protocol routes — your app’s abuse and quota controls — is yours to layer on top.

Turn on per-route limits

Supply a rateLimiter and a rateLimits map. Each named route enforces its own limit; routes you omit aren’t limited.
import { defineService, MemoryRateLimiter } from "@afauthhq/server";

const server = defineService({
  // ...baseUrl, serviceDid, stores, recipients...
  rateLimiter: new MemoryRateLimiter(),
  rateLimits: {
    accounts:              { limit: 5,  windowSeconds: 3600 },  // §6.4 explicit signup
    account_introspection: { limit: 60, windowSeconds: 60 },
    owner_invitation:      { limit: 3,  windowSeconds: 3600 },
    key_rotation:          { limit: 5,  windowSeconds: 86400 },
  },
});
The bucket key is `${route}:${agentDid}`per route, per agent DID (the signing keyid). Claim completion is keyed by token; the owner-gated key operations are keyed by owner userId, since those are owner-authenticated rather than agent-signed. An exhausted bucket returns 429 rate_limit_exceeded with a Retry-After header.

Choose a backend

RateLimiter is a one-method interface, so you can back it with anything:
interface RateLimiter {
  take(key: string, config: RateLimitConfig): Promise<RateLimitDecision>;
}
// RateLimitConfig:   { limit: number; windowSeconds: number }
// RateLimitDecision: { ok: boolean; retryAfter?: number; remaining?: number; resetAt?: number }
ImplementationBackendUse for
MemoryRateLimiter (@afauthhq/server)in-process maptests, single-instance services
KvRateLimiter (@afauthhq/worker)Workers KV, fixed-windowCloudflare deployments
your ownRedis token bucket, Durable Object, Cloudflare Rate Limitinghorizontally-scaled production
Implementations MAY over-count under racing instances (fail-safe) but MUST NOT under-count. On Cloudflare:
import { KvRateLimiter } from "@afauthhq/worker";

const server = defineService({
  // ...
  rateLimiter: new KvRateLimiter(env.AFAUTH_RATELIMIT),
  rateLimits: { account_introspection: { limit: 60, windowSeconds: 60 } },
});

Limit the human, not just the key

Per-DID limits don’t stop a Sybil: an attacker spins up fresh keypairs, each with its own bucket. The defense is to bucket on the account, which groups all of a human’s agents. A default (attested_only) service attaches every agent carrying the same per-service human pseudonym sub_h onto one account_id (§10.4.4) — so a human’s whole fleet shares one quota no matter how many keys they spin up. For application-level quotas, bans, and abuse counters, key on account_id (resolve it with accounts.getByAgentDid(did)), not on the agent DID:
const account = await accounts.getByAgentDid(verified.agentDid);
await myQuota.take(`search:${account.accountId}`, { limit: 1000, windowSeconds: 86400 });

Client side — honor Retry-After

A well-behaved agent backs off on 429 instead of hammering:
case "rate_limit_exceeded": {
  const retryAfter = Number(res.headers.get("retry-after") ?? 1);
  await sleep(retryAfter * 1000);
  return retry();
}
A 429 rate_limited from the trust attestor (minting attestations too fast) is distinct from a service 429 and from a revocation — back off, don’t re-link. See Keep attested access live.

Where to next

Attestation & sub_h

How a human’s fleet groups onto one account.

Error codes

rate_limit_exceeded and the rest.

Deploy to Cloudflare Workers

KvRateLimiter and the durable stores.

Verify a request

Authenticate before you meter.