Skip to main content
You’re going to make a service accept AFAuth-signed requests. After this, any AI agent in the world can sign up to your service with its own keypair — no portal to maintain, no API keys to provision.

Install

pnpm add @afauthhq/agent     # client / agent
pnpm add @afauthhq/server    # service handlers + Verifier
pnpm add @afauthhq/worker    # Cloudflare Workers bindings
pnpm add @afauthhq/core      # primitives shared by the above

Configure a Server

defineService is the opinionated convenience factory. It returns a Server — the same class that wraps Verifier and exposes endpoint handlers for discovery, signup, account introspection, owner invitation, claim completion, and key rotation — but with two spam-resistance switches flipped ON by default:
  1. Discovery advertises billing.unclaimed_mode: "attested_only" (§9.2).
  2. The attestor defaults to trustAttestor() (AFAP-0006 §10).
The net effect: un-attested implicit signups are rejected at the wire, and all of a human’s agents — which carry the same per-service pseudonym sub_h (§10.4) — are grouped onto one account (§10.4.4: one account, many devices). Bucket your free-tier quota, rate limits, and bans on the account and a human’s whole fleet shares one bucket, no matter how many keypairs they spin up — while their legitimate PC and phone agents both keep working.
Because this default rejects un-attested signups, an agent reaches your service by linking to a human at trust.afauth.org and presenting an AFAuth-Attestation JWT — the agent quickstart shows that step (the afauth signup CLI auto-mints it). To accept un-linked agents while you ramp, set attestation: "optional" below.
import {
  consoleEmailHandler,
  defineService,
  MemoryAccountStore,
  MemoryNonceStore,
  MemoryRevocationList,
} from "@afauthhq/server";

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

  // Storage. Memory stores are for development only — swap for D1 /
  // KV / Postgres / Redis in production. See @afauthhq/worker for the
  // Cloudflare-native durable stores.
  nonceStore: new MemoryNonceStore(),
  accounts: new MemoryAccountStore(),
  revocationList: new MemoryRevocationList(),

  // The §7.2 owner-invitation ceremony. consoleEmailHandler logs the
  // magic link to stderr — replace with your real email transport.
  recipients: { email: consoleEmailHandler },

  // §7.2: redirect_url is rejected unless its host is in this list.
  redirectAllowList: ["yourapp.com"],

  // attestation: "required" is the default. Override with:
  //   "optional" — verify when present, accept un-linked agents
  //                while you ramp (migration path)
  //   "off"      — read-only / paid-only services where a human
  //                signal is overkill
});
The synthesized discovery doc derives the canonical §4.1 endpoints from baseUrl: /afauth/v1/accounts, /afauth/v1/accounts/me/owner-invitation, /afauth/v1/accounts/me/keys/rotate, and /afauth/v1/claim (plus the /claim claim page). These match the paths @afauthhq/agent signs, so a default agent interoperates out of the box. To customize paths, advertise additional accepted_attestors, or declare limits, pass a discovery override — it merges on top:
defineService({
  /* ... */
  discovery: {
    endpoints: { accounts: "/afauth/v1/accounts", /* ...other paths */ },
    billing: { accepted_attestors: ["afauth-trust", "stripe-projects"] },
    limits: { unclaimed_rate_limit_per_hour: 100 },
  },
});
For multi-attestor setups, custom HmacAttestor, or fully custom discovery, drop to new Server({...}) directly — see @afauthhq/server overview.

Route incoming requests to the handlers

export default {
  async fetch(req: Request): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname === "/.well-known/afauth") {
      return server.handleDiscovery(req);
    }
    if (url.pathname === "/afauth/v1/accounts/me") {
      // Implicit signup: the first signed request from an unknown agent DID
      // creates the account here (there is no separate create endpoint).
      return server.handleAccountIntrospection(req);
    }
    if (url.pathname === "/afauth/v1/accounts/me/owner-invitation") {
      return server.handleOwnerInvitation(req);
    }
    if (url.pathname === "/afauth/v1/accounts/me/keys/rotate") {
      return server.handleKeyRotation(req);
    }
    // /afauth/v1/claim/<token> — wire to server.handleClaimCompletion(req,
    // session), building an OwnerSession from your claim page's auth layer.
    // See the worker example: https://github.com/AFAuthHQ/typescript-sdk/blob/main/examples/worker/src/index.ts

    return new Response("not found", { status: 404 });
  },
};

Verify it works

Hit the discovery endpoint:
curl https://api.example.com/.well-known/afauth
For deeper conformance, run the spec’s Appendix C vectors against your Verifier in your own test suite — @afauthhq/server’s test suite already does this, so cloning that pattern is the fastest path. The canonical vectors live at AFAuthHQ/spec/vectors.

Production checklist

  • Swap Memory* stores for durable backends. @afauthhq/worker ships D1AccountStore (multi-agent accounts; atomic device-grouping via the (iss, sub_h) index), KvNonceStore, KvRevocationList. See Deploy to Cloudflare Workers.
  • Replace consoleEmailHandler with a real email transport.
  • Host the claim page — the SDK doesn’t route it for you because it’s service UI. Reference: examples/worker/src/index.ts.
  • Decide your billing mode. The defineService default is attested_only — set attestation: "off" to opt out, or "optional" for a migration path.

Next steps