The cast, and who talks to whom
Five roles. The agent is the center of gravity. The service publishes a discovery document and verifies signatures. The trust attestor vouches that a human stands behind an agent — without revealing who. The human can claim ownership. The registry is an optional phone book.| Role | What it does |
|---|---|
| Agent | Holds an Ed25519 key; its did:key is its identity. Signs every request. |
| Service | Publishes /.well-known/afauth, verifies signatures, hosts the claim page. |
| Trust attestor | Vouches that a verified human stands behind an agent DID — with no PII. |
| Human / Owner | Optional. Can claim ownership of an account later. |
| Registry | Optional directory of AFAuth-enabled services. Non-normative. |
The end-to-end journey
From a freshly generated keypair to a human-owned account, in the order it actually happens. Steps 0–4 are the default path to using a service; 5–6 are the optional ownership handoff.Generate an identity — Agent
Agent.generate() (or afauth init) makes a fresh Ed25519 keypair and derives did:key:z6Mk…. The private key is the sole credential for everything that follows — store it encrypted at rest (the CLI writes ~/.afauth/key.json at mode 0600). See Identity and keys.Link to a human, once — Agent + Human + Trust attestor
Most services reject anonymous signups (the next steps explain why). To become acceptable, the agent opens a link request at
trust.afauth.org, surfaces a deep link, and a human signs in and confirms it. The result is a binding between this agent DID and that human — carrying no ownership and creating no service account. The binding stays valid as long as the agent keeps minting — it only lapses after ~90 days of inactivity. The mint is keyless: the agent later signs each token request with its own key, so there’s no bearer secret to store. See Link your agent to a human.Discover the service — Agent + Service
GET /.well-known/afauth returns the service’s service_did, endpoint paths, accepted signature algorithms, accepted attestors, and crucially its billing.unclaimed_mode. If that reads attested_only (the SDK default), the agent knows it must attach an attestation.Mint a per-service attestation — Agent + Trust attestor
trust.token(service_did) returns a short-lived JWT (max 15-minute life), audience-bound to exactly this service. It proves “a verified human is on the hook” and carries a pairwise pseudonym sub_h — but zero PII. A token minted for service A cannot be replayed at service B. See Attestation.First signed request — the account is born — Agent + Service
The agent sends a normal request, signed per RFC 9421, with the JWT in the
AFAuth-Attestation header. The service verifies the signature (locally) and the JWT (offline, against the attestor’s JWKS), then implicitly creates the account in state UNCLAIMED. There’s no separate “create account” call — the first valid request is the signup. The agent now operates freely; every later request is just signed.Invite a human to claim it, optional — Agent + Human + Service
When (and only if) ownership should pass to a person, the agent stages an invitation:
POST …/owner-invitation with an email (or phone / oidc / did) recipient. The account moves to INVITED and the service emails the recipient a magic link. Nothing is bound yet.The human authenticates — ownership commits — Human + Service
The recipient clicks the link and authenticates as that identity at the service’s claim page. The service checks their verified identity against the staged recipient (the match relation), and only then transitions the account to
CLAIMED. From here the agent keeps operating as before, but owner-binding operations require the human’s fresh session — the agent key alone can’t change who owns the account. See The ceremony.The same path, in four CLI lines.
What “a signed request” actually means
AFAuth doesn’t invent a signature format. It uses HTTP Message Signatures (RFC 9421): the agent signs a canonical view of the request, and the identity travels inside the signature’skeyid parameter — there is no separate identity header to spoof.
keyid — no network call — then checks the signature, the freshness window (created/expires), and that the nonce is unseen for this keyid. Any failure returns 401 with a structured error code such as invalid_signature, expired_signature, or replayed_nonce. Because @target-uri is signed, a signature can’t be replayed at another host. Full detail — the six verifier checks, edge-deployment, header-stripping rules — is in Signing requests.
The account lifecycle
An account is in exactly one of five states. A service-local, opaqueaccount_id names it — distinct from any key, and stable across key rotation, so one account can hold several agent credentials (“one account, many devices”).
EXPIRED transitions are opt-in — they fire only when the service advertises an unclaimed_ttl_seconds limit; by default accounts never expire and an UNCLAIMED account stays operable indefinitely. Note the one path back: an expired invitation returns the account to UNCLAIMED, not forward to CLAIMED. The full state table and transition rules live in The ceremony.
The two human bindings (don’t conflate them)
This is the single most confusing thing about AFAuth, so it gets called out everywhere. An agent can be connected to a human in two completely independent ways. They serve different purposes, live in different places, and can happen in either order. A default agent does both.| Linking to a human | Claiming the account | |
|---|---|---|
| When | Before / at signup | After / any time |
| Purpose | Spam-resistance | Ownership |
| Where | At the trust attestor (trust.afauth.org) | At the service, per account |
| Proves | A verified human stands behind the agent DID | This human owns this one account |
| Ownership | Transfers none; creates no account | Binds ownership; the security boundary |
| Scope | Every attested service at once | One service, one account |
| Produces | The pairwise pseudonym sub_h | Owner-binding authority for the human |
A third relationship: human sign-in. Linking and claiming are agent-initiated. There is also a human-initiated path — Sign in with AFAuth — where a person signs in (the trust attestor doubles as an OpenID Provider) and lands in the
(iss, sub_h) account their agent already created. It is authentication, not ownership, so it composes with both bindings above.Spam-resistance without PII
Accepting any keypair with no registry is what makes AFAuth open — and, alone, what would make it spammable: 10,000 throwaway keys look like 10,000 customers. The attestation layer closes that gap by binding abuse-accounting to a human, while collecting nothing about them. A trust attestation says exactly one thing: the agent DID is bound to a human-controlled account that completed a verification method (email / oauth / payment). It’s a categorical signal, not a capability grant, and not an identity to log. It carries sub_h, a per-service pairwise pseudonym that is:
- Pairwise — a different value at every service, so two services can’t correlate the same human.
- Stable — the same value for one
(human, service)across all the human’s agents and key rotations. - Opaque — ≥128 bits, not invertible to identity.
sub_h at a given service, the service groups them into one account and rate-limits the human — their laptop and phone keep working, while a flood of throwaway keys still hits one bucket. This is on by default: defineService ships unclaimed_mode: "attested_only" and wires the trust attestor in. Spam-resistance is the happy path, not an add-on.
Cutting an agent off — two levers
An agent presents two credentials (a signature and, sometimes, an attestation), so it is revoked in two different places. Neither lever subsumes the other.| Global — at the attestor | Local — at the service | |
|---|---|---|
| Kills | New attestation minting for that agent DID | The key as an authenticator on one account |
| Scope | Every attested service at once | This one service only |
| Pulled by | Whoever controls the link at the attestor | The claimed account owner |
| Reaches | Only the attested path, within the freshness window | Every path, including pure signature-gated, immediately |
sub_h carries forward because it’s keyed on the human, not the key. See Revocation and Recover a compromised key.
Where AFAuth sits in the agent stack
AFAuth is a protocol, not a product. It addresses identity — currently the gap — and composes with everything around it rather than replacing it.| Layer | Examples | AFAuth’s role |
|---|---|---|
| Capability / transport | MCP, A2A | The AFAuth DID rides in MCP’s CIMD URL and A2A Agent Card identity fields |
| Authorization | OAuth actor_token, FIDO AP2, Visa Trusted Agent | An AFAuth signature serves as the actor_token and as the identity inside payment tokens |
| Identity | AFAuth | Self-sovereign agent identity for the open web — the gap the others delegate from |
Show me the code
The agent and service sides interoperate out of the box. Here is each, end to end — see the agent quickstart and service quickstart for the full walkthroughs.- Agent
- Service
createWorker from @afauthhq/worker routes all five endpoints and ships durable stores. The agent side has an identical-shaped CLI: afauth init → trust link → signup → call.
Further reading
- Why AFAuth — the argument, and where it sits in the stack.
- Identity and keys · Signing requests — the agent’s credential and how requests are signed.
- Attestation · The trust attestor — spam-resistance without PII.
- The ceremony · Revocation — ownership and cutting an agent off.
- Spec §1–§14 — the normative source of truth.