Skip to main content
This guide turns on afauth-trust attestation support for an AFAuth service. After this, agents that link to a human at trust.afauth.org can present an offline-verifiable JWT on each request, and your service can gate features on that signal — either as a softer “free tier” filter (unclaimed_mode = "free" with attestation-aware quotas) or as a hard requirement (unclaimed_mode = "attested_only").
Accepting afauth-trust is the default posture for a spam-resistant service: defineService already wires trustAttestor() and sets unclaimed_mode = "attested_only" for you. This guide shows what that default does and how to tune it. See the trust attestor concept for what a trust attestation actually proves, and the broader attestation concept for HMAC + multi-attestor patterns.

Before you start

You need:
  1. A working AFAuth service using @afauthhq/server (or any conformant implementation). If you don’t yet, start at Accept AFAuth on your service.
  2. A service_did declared in your discovery document — trust attestations are bound to this audience.
That’s it. There’s no enrollment, no API key, no registration with trust.afauth.org. You consume a public JWKS document; no provisioning is required.

The three steps

1

Declare afauth-trust in your discovery document

Add afauth-trust to your billing.accepted_attestors list. Choose an unclaimed_mode based on how strict you want the gate to be.
{
  "afauth_version": "0.1",
  "service_did": "did:web:api.example.com",
  "endpoints": { ... },
  "signature_algorithms": ["ed25519"],
  "billing": {
    "unclaimed_mode": "attested_only",
    "accepted_attestors": ["afauth-trust"]
  }
}
unclaimed_modeBehaviour
"free"Pre-claim signup is allowed without attestation. If an agent presents one, the server still validates it (rejecting invalid tokens) and your application code can read it for quota tiering.
"attested_only"Pre-claim signup is rejected unless a valid attestation from one of accepted_attestors is present. 401 attestation_required on rejection. No account row is created on failure.
"denied"Pre-claim signup is rejected outright; attestations are ignored.
If you want both afauth-trust and another attestor (e.g., an internal HMAC or stripe-projects once you’ve enrolled), list them together — the server tries each accepted issuer in turn.
2

Configure the attestor in your Server

For new integrations, the easiest path is defineService, which wires the trust attestor and the attested_only discovery declaration in one call:
import {
  defineService,
  MemoryAccountStore,
  MemoryNonceStore,
  MemoryRevocationList,
  consoleEmailHandler,
} from "@afauthhq/server";

const server = defineService({
  baseUrl: "https://api.example.com",
  serviceDid: "did:web:api.example.com",
  accounts: new MemoryAccountStore(),
  recipients: { email: consoleEmailHandler },
  nonceStore: new MemoryNonceStore(),
  revocationList: new MemoryRevocationList(),
  // attestation: "required" is the default → trustAttestor() +
  //   discovery.billing.unclaimed_mode = "attested_only".
});
If you’re already on new Server({...}) or need a custom discovery doc, use the trustAttestor() factory directly (@afauthhq/server 0.2.0+ ships it pre-pinned to the AFAP-0006 issuer, JWKS URL, and EdDSA):
import {
  MemoryAccountStore,
  MemoryNonceStore,
  MemoryRevocationList,
  Server,
  trustAttestor,
} from "@afauthhq/server";

const server = new Server({
  serviceDid: "did:web:api.example.com",
  baseUrl: "https://api.example.com",

  // ...your nonce store, account store, revocation list, recipients...
  nonceStore: new MemoryNonceStore(),
  accounts: new MemoryAccountStore(),
  revocationList: new MemoryRevocationList(),

  // Hook the attestor in.
  attestor: trustAttestor(),

  discovery: {
    afauth_version: "0.1",
    service_did: "did:web:api.example.com",
    endpoints: { /* ... */ },
    signature_algorithms: ["ed25519"],
    billing: {
      unclaimed_mode: "attested_only",
      accepted_attestors: ["afauth-trust"],
    },
  },
});
For staging or local dev (e.g. running the trust attestor against a tunnelled endpoint), trustAttestor({ jwksUrl: "..." }) overrides the JWKS URL while keeping the rest of the AFAP-pinned shape. The explicit form is also available — new JwksAttestor({ iss: "afauth-trust", jwksUrl, algorithms: ["EdDSA"] }) — but rarely needed.The Verifier automatically:
  • Reads AFAuth-Attestation from the request headers.
  • Verifies the JWT’s signature against the JWKS (with caching + rotation handled by jose’s createRemoteJWKSet).
  • Validates iss == "afauth-trust", aud == your service_did, sub == signing DID, and exp in the future.
  • Rejects with 401 invalid_attestation (malformed / bad signature / wrong aud / expired) or 401 attestation_required (missing under attested_only).
Accept more than one attestor by composing them with MultiAttestor — e.g. afauth-trust plus an internal HMAC, or a self-hosted attestor under its own iss. With defineService, the attestor’s issuers are advertised in billing.accepted_attestors automatically — no need to hand-list them:
import { defineService, trustAttestor, JwksAttestor, MultiAttestor } from "@afauthhq/server";

const server = defineService({
  baseUrl, serviceDid, accounts, recipients,
  attestor: new MultiAttestor([
    trustAttestor(),
    new JwksAttestor({
      iss: "acme-trust",
      jwksUrl: "https://trust.acme.example/.well-known/jwks.json",
      algorithms: ["EdDSA"],
    }),
  ]),
  // discovery.billing.accepted_attestors is derived from the attestor →
  // ["afauth-trust", "acme-trust"]. Pass discovery.billing.accepted_attestors
  // only to override it.
});
With raw new Server({...}) (no defineService) you write the full discovery document yourself, so list every iss in billing.accepted_attestors to match the attestors you wired — it isn’t derived for you there.
3

Use the verification claim in your application

When the verifier accepts an attestation, the verification claim is available to your application code. Use it however your policy needs — gate features, tier rate limits, log it.
import type { AttestationClaims } from "@afauthhq/server";

function quotaFor(claims: AttestationClaims | null): number {
  if (!claims) return 0;                  // attested_only → never reached
  switch (claims.verification) {
    case "payment": return 10_000;        // cardable human on the hook
    case "oauth":   return 1_000;         // OAuth-verified human
    case "email":   return 100;           // email-verified human
    default:        return 100;           // unknown values: handle as the weakest known signal
  }
}
The verification rank is your policy. The protocol takes no opinion on whether payment > oauth > email or any other ordering — each service decides what each signal unlocks. The trust attestor always emits the strongest method the linked human has on file.

Test your integration end to end

Once configured, you can validate the full path locally with the reference CLI from a fresh agent identity:
afauth init                            # generate a keypair
afauth signup https://api.example.com  # should fail with 401 attestation_required
Then drive the link flow (see Link your agent to a human) to receive a trust attestation, and retry the signed request. The agent’s request will now carry an AFAuth-Attestation header and your service will accept it. This is the default agent journey end to end: an agent built from the agent quickstart links to a human and mints exactly the attestation your attested_only service requires.

Failure modes you might hit

CodeCauseFix
attestation_requiredunclaimed_mode = "attested_only" and no AFAuth-Attestation header.Agent needs to mint one against trust.afauth.org/v1/token and resend.
invalid_attestationJWT signature failed, issafauth-trust, aud ≠ your service_did, sub ≠ signing DID, or exp is past.Mint a fresh JWT (≤ 900 s expiry). Check your service_did matches the aud claim byte-for-byte.
Verification cache staleA new kid was just rotated and your JwksAttestor’s cache hasn’t refreshed.jose’s createRemoteJWKSet refreshes on unknown kid; if you’ve layered an additional cache, drop it. The trust attestor pre-announces new kids 900+ seconds before first use.

Operational notes

  • Offline verification means uptime is bounded by your service. A brief trust.afauth.org outage does not affect verification of already-issued tokens — only the issuance of new ones (and only for the duration of the outage).
  • Don’t cache attestations beyond their exp. §10 explicitly forbids it. The 900-second cap is the revocation window.
  • You do not need to pin kid. New kids are published 900+ seconds before first use; JwksAttestor follows the JWKS, no manual key management on your side.

Where to next

Trust API reference

JWKS, link flow, token endpoint, errors.

The attestation concept

HMAC, MultiAttestor, the full §10 surface.

Spec §10

Normative attestation rules.

List on the registry

Once you accept attestations, get discovered.