> ## Documentation Index
> Fetch the complete documentation index at: https://docs.afauth.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Trust API

> Endpoints at trust.afauth.org — link flow, attestation tokens, JWKS, and OIDC sign-in.

The trust attestor exposes a small HTTP surface at `trust.afauth.org`. This flow is what every signup to a default (`attested_only`) service requires: agents drive a link request through `/v1/link/*` once, then mint a per-service attestation JWT at `/v1/token` — signed with the agent's account key, no bearer token — and present it as `AFAuth-Attestation`. A service that lacks the header rejects the request with `401 attestation_required`. Services need only fetch the JWKS at `/.well-known/jwks.json` to verify tokens offline.

<Note>
  This page is a reference. For the step-by-step walkthroughs, see
  [Link your agent to a human](/guides/link-to-a-human) (agent side) and
  [Accept `afauth-trust` attestations](/guides/accept-afauth-trust) (service side).
</Note>

## Base URL

```
https://trust.afauth.org
```

All requests and responses are JSON (`application/json`) unless noted. All URLs are HTTPS.

## Endpoints at a glance

| Method   | Path                                | Auth                       | Purpose                                                               |
| -------- | ----------------------------------- | -------------------------- | --------------------------------------------------------------------- |
| `GET`    | `/.well-known/jwks.json`            | none                       | Public verification keys. Cached 300s.                                |
| `POST`   | `/v1/link/start`                    | none                       | Agent → returns deep-link URL + poll URL.                             |
| `POST`   | `/v1/link/poll`                     | request body signature     | Agent → returns the binding (id + expiry) once a human has confirmed. |
| `POST`   | `/v1/token`                         | agent §5 request signature | Agent → mints a §10 attestation JWT bound to a `service_did`.         |
| `DELETE` | `/v1/bindings/{id}`                 | session cookie             | Human → revokes a binding from the dashboard.                         |
| `GET`    | `/.well-known/openid-configuration` | none                       | OIDC discovery document. Cached 300s.                                 |
| `GET`    | `/oidc/authorize`                   | human session              | Human sign-in → redirects back with an auth code (PKCE).              |
| `POST`   | `/oidc/token`                       | PKCE `code_verifier`       | Relying party exchanges the code for an `id_token`.                   |

The human-facing surface (`/signin`, `/account`, `/link`) is browser UI, not a programmatic API. It is documented here only where it intersects the agent-side or sign-in flow.

***

## `GET /.well-known/jwks.json`

The public verification keys for `afauth-trust`-issued JWTs.

### Response — 200 OK

```json theme={null}
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "kid": "k_2026-05",
      "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
    }
  ]
}
```

### Cache headers

* `Cache-Control: public, max-age=300, stale-while-revalidate=900`

Per [§10.3.1](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#1031-trust-attestor-afauth-trust), new `kid`s are published at least one 900-second window before first use, so a 300-second cache TTL leaves consumers a comfortable margin to refresh.

The TypeScript SDK's `JwksAttestor` (built on `jose`'s `createRemoteJWKSet`) handles refresh automatically — point it at this URL with `iss: "afauth-trust"` and `algorithms: ["EdDSA"]`.

***

## `POST /v1/link/start`

Open a link request. The agent submits its DID and public key; the trust attestor returns a deep link the agent surfaces to a human, plus a poll URL the agent uses to retrieve the binding (id + expiry) after the human confirms.

### Request

```json theme={null}
{
  "agent_did":         "did:key:z6Mk...",
  "agent_pubkey_b64":  "MCowBQYDK2VwAyEA...",
  "agent_label":       "Atlas (research agent)",
  "callback_url":      "http://127.0.0.1:9876/done"
}
```

| Field              | Type   | Required | Notes                                                                                                                                                             |
| ------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_did`        | string | yes      | The agent's account DID.                                                                                                                                          |
| `agent_pubkey_b64` | string | yes      | Base64url-encoded (no padding) Ed25519 public key. For `did:key` DIDs the payload must derive from this key.                                                      |
| `agent_label`      | string | no       | Human-readable label shown on the confirmation page. ≤80 chars.                                                                                                   |
| `callback_url`     | string | no       | Loopback only (`127.0.0.1` or `localhost`). The deep-link page redirects here after confirmation, for desktop-style "open browser, confirm, return to CLI" flows. |

### Response — 200 OK

```json theme={null}
{
  "req_id":     "lr_T1uvWxYz3456_AbCdEfGhIj",
  "link_url":   "https://trust.afauth.org/link?req=eyJhbGciOiJIUzI1NiIs...",
  "poll_url":   "https://trust.afauth.org/v1/link/poll",
  "expires_in": 1800
}
```

`link_url` is the page the human visits. `req_id` is the opaque handle the agent uses when polling. Link requests expire in **30 minutes** (`expires_in`); if no human confirms by then, start over.

***

## `POST /v1/link/poll`

Poll for the human's confirmation. The agent proves it controls the keypair it submitted by signing the `req_id`.

### Request

```json theme={null}
{
  "req_id":  "lr_T1uvWxYz3456_AbCdEfGhIj",
  "sig_b64": "<base64url(Ed25519(req_id))>"
}
```

`sig_b64` is the base64url (no padding) encoding of an Ed25519 signature over the UTF-8 bytes of `req_id`, using the same keypair declared in `/v1/link/start`.

### Response — 200 OK, pending

```json theme={null}
{ "state": "pending", "phase": "awaiting_signin" }
```

Poll again after a backoff (1–2 seconds is fine; the endpoint is rate-limited at 60/minute per IP). `phase` is an optional hint for a tighter waiting message — `awaiting_signin` before the human has opened the link page, `awaiting_confirm` after.

### Response — 200 OK, confirmed

```json theme={null}
{
  "state":                    "confirmed",
  "binding_id":               "bnd_AbCd1ef-gHIj_klmN0pqRs",
  "binding_token_expires_at": 1772020800
}
```

`binding_id` records that the agent has linked — there is **no bearer token**. Minting at `/v1/token` is keyless: the agent authenticates by signing the mint request with its account key (see below), so its keypair is the only secret it keeps. Bindings are long-lived and revocable by the human at any time. The expiry is an inactivity window: the attestor re-arms it on every `/v1/token` mint, so a binding in regular use does not lapse — only one left unused for \~90 days does. `binding_token_expires_at` is the Unix timestamp at which an unused binding would lapse and the agent would have to re-link.

### Response — 410 Gone

```json theme={null}
{ "error": { "code": "link_request_expired", "message": "Link request is expired" } }
```

Also returned if a confirmed binding has already been retrieved (the binding info is delivered exactly once).

***

## `POST /v1/token`

Mint an attestation JWT scoped to a single service.

**Keyless authentication.** The agent signs the mint request with its account key using the same [RFC 9421](/concepts/signing) scheme it uses for service requests (covering `@method`, `@target-uri`, and `content-digest`; `keyid` is the agent DID). The attestor verifies the signature offline — the `keyid` is a `did:key`, so no lookup is needed — and maps the verified DID to the agent's binding. The agent's keypair is the sole credential; no bearer token is presented. Sign the full URL `https://trust.afauth.org/v1/token`.

### Request

```http theme={null}
POST /v1/token HTTP/1.1
Host: trust.afauth.org
Content-Type: application/json
Content-Digest: sha-256=:<digest of body>:
Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=...;expires=...;nonce="...";keyid="did:key:z6Mk...";alg="ed25519"
Signature: sig1=:<base64 ed25519 signature>:

{ "aud": "did:web:api.example.com" }
```

| Field | Type   | Required | Notes                                                                           |
| ----- | ------ | -------- | ------------------------------------------------------------------------------- |
| `aud` | string | yes      | The destination service's `service_did`. The JWT's `aud` will equal this value. |

### Response — 200 OK

```json theme={null}
{
  "jwt": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6Imtf...",
  "expires_at": 1764086400,
  "verification": "email",
  "binding_expires_at": 1772020800
}
```

`jwt` is a fully-formed JWT. The agent presents it on any signed AFAuth request to `did:web:api.example.com` via:

```
AFAuth-Attestation: <jwt>
```

Against an `attested_only` service, omitting this header returns `401 attestation_required`; a malformed or wrong-audience token returns `401 invalid_attestation`. Both are distinct from the trust-attestor errors below, which `trust.afauth.org` itself returns.

`verification` is the strongest method the linked human has on file — emitted for the agent's awareness, identical to the `verification` claim inside the JWT.

`binding_expires_at` is the Unix timestamp at which the binding would lapse if left unused. The attestor re-arms it to \~90 days out on every mint (an inactivity window), so a client that caches the binding's expiry should refresh it from this field on each mint rather than treating the link-time value as a fixed deadline.

### JWT shape (per [§10.3.1](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#1031-trust-attestor-afauth-trust))

```
header: { alg: "EdDSA", typ: "JWT", kid: "<from jwks.json>" }
claims: {
  iss: "afauth-trust",
  aud: "<service_did>",
  sub: "<agent_did>",
  sub_h: "<base64url HMAC-SHA256 pairwise human pseudonym>",
  iat: <unix>,
  exp: <iat + ≤900>,
  verification: "email" | "oauth" | "payment"
}
```

* `exp - iat` ≤ 900 seconds. A service MUST NOT cache a single attestation beyond its `exp`; to avoid presenting one on every request it MAY instead keep an [attested session](/guides/keep-attested-access-live) live by periodic re-presentation (§10.7).
* `aud` binds the token to a single service; replay against another `service_did` fails verification.
* `sub_h` is a pairwise pseudonym for the human principal behind the agent — stable per `(human, aud)`, unlinkable across services. Bucket anti-abuse state (quotas, bans) on it without learning the human's identity. Present whenever `verification` is set (i.e. for any human-backed binding); see [§10.4](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#104-pairwise-human-pseudonym-sub_h).
* No PII. The trust attestor never embeds the underlying address, OAuth subject, or payment details.

***

## `DELETE /v1/bindings/{id}`

Revoke a binding. Human-only — authenticated by the session cookie set at `trust.afauth.org/signin`. Not callable from an agent.

### Response — 200 OK

```json theme={null}
{ "ok": true, "revoked_at": "2026-05-29T18:30:00.000Z" }
```

After revocation, all subsequent `/v1/token` calls signed by the binding's agent key return `403 binding_revoked`. Previously-issued JWTs continue to verify until their `exp` (≤ 900 seconds out). To shorten that window further, services may operate a short-lived `iat` lower bound at their discretion.

***

## Sign in with AFAuth (OIDC)

Beyond agent attestation, `trust.afauth.org` is an **OpenID Provider**, so a service can offer human **[Sign in with AFAuth](/guides/add-sign-in-with-afauth)**. The human signs in and lands in the `(iss, sub_h)` account their agent created — the `id_token`'s `sub` is the same pairwise `sub_h` the attestation carries. Standard Authorization Code + PKCE (`S256`); normative shape in [§10.8](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#108-human-sign-in-via-the-trust-attestor-openid-provider).

<Note>
  The `id_token` `iss` is the URL `https://trust.afauth.org`; the **attestation** `iss` is the bare string `afauth-trust`. They denote the same attestor — a relying party MUST canonicalize both to one issuer before keying the account ([§10.8.4](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#1084-issuer-canonicalization-convergence-requirement)), or the human lands in an empty one. The OIDC keys are the **same** JWKS used for attestation (`/.well-known/jwks.json`).
</Note>

### `GET /.well-known/openid-configuration`

The OpenID Provider configuration. Cached 300s.

```json theme={null}
{
  "issuer": "https://trust.afauth.org",
  "authorization_endpoint": "https://trust.afauth.org/oidc/authorize",
  "token_endpoint": "https://trust.afauth.org/oidc/token",
  "jwks_uri": "https://trust.afauth.org/.well-known/jwks.json",
  "scopes_supported": ["openid"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "subject_types_supported": ["pairwise"],
  "id_token_signing_alg_values_supported": ["EdDSA"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nonce"]
}
```

### `GET /oidc/authorize`

Starts human sign-in. The relying party redirects the human's browser here; after the human authenticates, the attestor redirects back to the registered `redirect_uri` with a single-use `code`.

| Param                   | Required    | Notes                                                                                      |
| ----------------------- | ----------- | ------------------------------------------------------------------------------------------ |
| `client_id`             | yes         | The registered client (typically the service DID).                                         |
| `redirect_uri`          | yes         | MUST exactly match a registered URI, or the request is rejected before any code is issued. |
| `response_type`         | yes         | `code`.                                                                                    |
| `scope`                 | yes         | `openid`.                                                                                  |
| `code_challenge`        | yes         | PKCE challenge.                                                                            |
| `code_challenge_method` | yes         | `S256` (the only method offered).                                                          |
| `state`                 | recommended | Opaque CSRF token; echoed on the redirect back.                                            |
| `nonce`                 | recommended | Echoed into the `id_token`; bind it to the session.                                        |

If the human has no trust session, the attestor bounces to `/signin` first and resumes the request afterward. Authorization codes are single-use and short-lived (\~120 s).

### `POST /oidc/token`

Exchanges the `code` for an `id_token`. Accepts `application/x-www-form-urlencoded` or JSON. Public client — no client secret; PKCE is the proof.

| Field           | Required | Notes                                                          |
| --------------- | -------- | -------------------------------------------------------------- |
| `grant_type`    | yes      | `authorization_code`.                                          |
| `code`          | yes      | From the authorize redirect. Single-use.                       |
| `code_verifier` | yes      | The PKCE verifier for the challenge sent to `/oidc/authorize`. |
| `client_id`     | yes      | Same client as the authorize step.                             |
| `redirect_uri`  | yes      | Same value as the authorize step.                              |

#### Response — 200 OK (`Cache-Control: no-store`)

```json theme={null}
{
  "id_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6...",
  "token_type": "Bearer",
  "expires_in": 300
}
```

#### `id_token` shape (per [§10.8.2](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#1082-id-token-claims))

```
header: { alg: "EdDSA", typ: "JWT", kid: "<from jwks.json>" }
claims: {
  iss:   "https://trust.afauth.org",
  aud:   "<service_did>",
  sub:   "<sub_h — same pairwise pseudonym as the attestation>",
  iat:   <unix>,
  exp:   <unix>,
  nonce: "<echoed if supplied>"
}
```

* Verify it against the JWKS with `iss = https://trust.afauth.org` and `aud =` your `service_did`; check `exp` and the `nonce`.
* `sub` is the [§10.4](https://github.com/AFAuthHQ/spec/blob/main/spec/core.md#104-pairwise-human-pseudonym-sub_h) pairwise `sub_h` — resolve the account by `(canonicalIss(iss), sub)`. No PII is present.

### Client registration

A relying party is registered out-of-band (for `trust.afauth.org`, in the `TRUST_OIDC_CLIENTS` config):

```json theme={null}
[
  {
    "client_id":     "did:web:api.example.com",
    "service_did":   "did:web:api.example.com",
    "redirect_uris": ["https://api.example.com/auth/afauth/callback"]
  }
]
```

`service_did` MUST equal the value the service uses as the attestation `aud` — it is the audience input to the `sub_h` derivation, so a mismatch sends the human to a different, empty account (§10.8.3). The `/oidc/*` endpoints return standard OAuth 2.0 / OIDC error responses (`invalid_request`, `invalid_grant`, `invalid_client`, …), distinct from the trust-attestor envelope below.

***

## Errors

Every non-2xx response uses a structured envelope:

```json theme={null}
{
  "error": {
    "code":    "invalid_signature",
    "message": "Agent signature over req_id did not verify"
  }
}
```

| Code                    | HTTP | Meaning                                                                                  |
| ----------------------- | ---- | ---------------------------------------------------------------------------------------- |
| `invalid_request`       | 400  | Body failed schema validation.                                                           |
| `invalid_signature`     | 401  | Agent's Ed25519 signature over `req_id` did not verify against the submitted public key. |
| `unauthorized`          | 401  | Missing, invalid, or unknown bearer / session credential.                                |
| `binding_revoked`       | 403  | Binding has been revoked by the human.                                                   |
| `verification_required` | 403  | Human has no active verification methods (resolve at `trust.afauth.org/account`).        |
| `not_found`             | 404  | Link request or binding not found.                                                       |
| `binding_expired`       | 410  | Binding has passed its `expires_at`; re-link the agent.                                  |
| `link_request_expired`  | 410  | Link request expired before confirmation, or the binding was already retrieved.          |
| `rate_limited`          | 429  | See rate-limit table below. `Retry-After` header carries the wait in seconds.            |
| `internal_error`        | 500  | Unexpected. Retry, then report.                                                          |

See [Error codes](/reference/error-codes) for the envelope contract shared with the rest of the AFAuth surface.

## Rate limits

Per-IP, fixed-window in Redis.

| Endpoint group           | Limit                                                          |
| ------------------------ | -------------------------------------------------------------- |
| `POST /v1/link/start`    | 30 / minute (per IP), 20 / hour (per `agent_did`)              |
| `POST /v1/link/poll`     | 60 / minute                                                    |
| `POST /v1/token`         | 60 / minute (per IP), 10,000 / day (per binding, configurable) |
| All other read endpoints | 600 / minute                                                   |

The per-binding daily cap (`TRUST_PER_BINDING_DAILY_TOKEN_LIMIT`, default 10,000) prevents a compromised agent key from acting as an unlimited attestation oracle, while leaving headroom for the §10.7 re-mint cadence of an agent active across many services.
