Skip to main content
The protocol accepts any well-formed Ed25519 keypair — the agent is self-sovereign, no central registry, no gatekeeper. But “any keypair” also means a service has no way to distinguish:
  • A legitimate, well-behaved enterprise agent from
  • A throwaway abuse-bot generated five seconds ago.
When a service needs that distinction, AFAuth’s attestation layer has the agent present a JWT signed by a third party (an attestor) that vouches for the agent’s runtime context — including, for the default afauth-trust attestor, that a verified human is on the hook via a per-service pseudonym sub_h (§10.4).
For new integrations, attestation is on by default. The defineService factory in @afauthhq/server sets unclaimed_mode: "attested_only" and wires trustAttestor() automatically. Pass attestation: "off" to opt out for read-only or paid-only services, or "optional" when your free tier is cheap enough that moderation + graded limits are enough anti-abuse (un-attested signups are allowed; a presented attestation still validates). Which mode fits is an economic call — see When you want attestation. See the service quickstart.
Attestation has two default sides that must line up. The service turns attested_only on (the defineService default, above). The agent links to a human once — afauth trust link — and then mints a fresh per-request attestation: afauth signup reads the service’s attested_only flag and auto-mints. A default agent that has never linked can’t sign up to a default service, so linking is part of the standard agent journey, not an advanced step.

When you want attestation

  • Spam resistance. A bad actor spinning up 10,000 throwaway keypairs looks like 10,000 customers to your quota table. With attestation, all of one human’s agents carry the same sub_h, so defineService groups them onto one account (§10.4.4): one account, many devices — the human’s PC and phone agents share an account, like signing into one account from two devices. Bucket your free-tier quota / rate limits / bans on the account (or sub_h) and a human’s fleet draws from one bucket — no legitimate multi-device user is locked out.
  • Pre-claim billing. A service that wants stronger assurance that someone billable is behind the agent before granting paid features can require an attestation from a commerce attestor (stripe-projects, fido-agent-payments, mastercard-verifiable-intent).
  • Enterprise compliance. A service serving B2B customers may want to limit pre-claim signup to agents running in known enterprise platforms — microsoft-entra-agent-id, google-cloud-agent-identity, aws-iam-agent.
  • Rate-limit tiering. Attested agents get higher rate limits than anonymous ones; key the buckets off sub_h.

When you don’t

Services that genuinely don’t need a human-on-the-hook signal — read-only public APIs, paid-only services where billing is the implicit anti-abuse signal — should pass attestation: "off" to defineService (or use new Server({...}) without an attestor). The protocol doesn’t require attestation; only the convenience factory does.

How it works

The agent presents an attestation JWT in a header:
AFAuth-Attestation: <JWT signed by an accepted attestor>
Required claims per §10.2:
  • iss — the attestor identifier (e.g., "stripe-projects").
  • sub — the requesting agent’s account DID.
  • exp — token expiry (unix seconds), in the future at the time of verification.
  • Other claims are attestor-specific.
The service verifies the JWT against the attestor’s published verification key (asymmetric attestors) or shared secret (HMAC attestors). The verifier MUST validate iss matches an accepted attestor and sub matches the request’s signing DID.

Three attestor classes

The TypeScript SDK ships three implementations of Attestor:
ClassVerifiesUse for
HmacAttestorHS256 JWTs against a shared secretFirst-party service-operator attestors (you mint the token yourself)
JwksAttestorAsymmetric JWTs against a JWKS URLThe default trust attestor (afauth-trust); platform / commerce attestors (Stripe, Entra, etc.)
MultiAttestorDispatches by iss claimA real service that accepts multiple attestor sources
import {
  HmacAttestor,
  JwksAttestor,
  MultiAttestor,
} from "@afauthhq/server";

const attestor = new MultiAttestor([
  new HmacAttestor({
    iss: "my-service",
    secret: process.env.ATTESTATION_SECRET!,
  }),
  new JwksAttestor({
    iss: "stripe-projects",
    jwksUrl: "https://stripe.example/.well-known/jwks.json",
    algorithms: ["ES256"],
  }),
]);
Pass the composite attestor to Server; the attested_only enforcement happens automatically. Full recipe: examples/recipes/attestor.ts.

afauth-trust — the default trust attestor

Platform and commerce attestors (Entra, Stripe, etc.) are operated by third parties outside any single service’s control, which would otherwise leave attested_only unreachable from a clean v0.1 deployment. To close that gap, AFAP-0006 reserves afauth-trust as a recognized attestor identifier, operated by afauth.org at trust.afauth.org. A trust attestation says one thing: the agent DID in sub is bound to a human-controlled account verified by the categorical method in the verification claim — one of "email", "oauth", "payment". The token carries no PII — the consuming service gets a signal, not an identity. Full model: the trust attestor. @afauthhq/server ships a one-line trustAttestor() factory that pre-pins the AFAP-0006 issuer, JWKS URL, and algorithm. For new integrations, you don’t even need to call it — defineService wires it in automatically. If you’re using new Server({...}) directly:
import { trustAttestor } from "@afauthhq/server";

const attestor = trustAttestor();
Then list afauth-trust in your discovery document’s billing.accepted_attestors. Full walkthrough: Accept afauth-trust attestations.

unclaimed_mode = "attested_only"

This is the discovery-document signal that turns on enforcement. defineService sets it by default; if you’re using new Server({...}) directly, declare it yourself:
{
  "billing": {
    "unclaimed_mode": "attested_only",
    "accepted_attestors": ["afauth-trust", "stripe-projects"]
  }
}
…the server refuses implicit signup from any agent that doesn’t present a valid AFAuth-Attestation header. The error response is 401 attestation_required. The account row is not created on rejection — failing closed before any side effects. On the agent side, the afauth signup command reads this same field and auto-mints a trust attestation when present; agents that haven’t run afauth trust link are guided to do so before retrying. Other modes ("free", "denied") don’t enforce attestation; if an agent presents one anyway, the server still validates it (rejecting invalid tokens with 401 invalid_attestation) but doesn’t require one.

Lifetime

By default, attestations are presented on a per-request basis, and the service MUST NOT cache an attestation beyond its exp — it’s a gate on the signed request, not a session token. Cadence is a spectrum, and most services want the middle:
  • Signup-only. Check attestation once when the account is created, then trust the agent’s signature. Cheapest — but an attestor-side revoke never reaches you, the §8.5 blind spot.
  • Per-request. Require a fresh attestation on every request. An attestor revoke takes effect within the token lifetime (≤ 15 min), but the attestor sits on every request’s critical path.
  • Attested session (§10.7). Keep a currently-valid attestation on file and challenge with 401 attestation_required when it lapses; the agent re-presents a fresh one. Revocation takes effect within the freshness window, with no per-request attestor dependency. See Keep attested access live.

Security note

Services that accept attestations MUST validate the attestor’s signature against an authoritative key source. Stale JWKS or expired shared secrets can permit forged attestations. The JwksAttestor from @afauthhq/server uses jose’s createRemoteJWKSet, which caches valid keys and honours key rotation. For HMAC attestors, rotate the shared secret on a schedule.

Further reading