Skip to main content
AFAuth standardises the error response so clients can program against code without parsing message. Every error response — at every endpoint, for every failure — uses the same shape.

The shape

{
  "error": {
    "code":    "invalid_signature",
    "message": "Signature verification failed",
    "details": { }
  }
}
FieldRequiredNotes
codeyesA stable identifier from the §11.3 reserved codes.
messageyesHuman-readable. SHOULD NOT contain sensitive details.
detailsnoError-specific structured information. Use freely; clients ignore unknown keys.

Status codes

Services use the standard HTTP status family per §11.2:
StatusMeaning
400 Bad RequestMalformed request — invalid JSON, missing required fields, invalid DID syntax
401 UnauthorizedSignature verification failed, key revoked, or attestation invalid
403 ForbiddenOperation not permitted in the current state
404 Not FoundAccount does not exist (only when implicit signup is disabled)
409 ConflictState conflict — e.g., account already CLAIMED, key already revoked
410 GoneAccount is EXPIRED or invitation has expired
429 Too Many RequestsRate limit exceeded
503 Service UnavailableService temporarily unable to process AFAuth requests
Status alone isn’t enough to act on — multiple codes share 401. Always inspect code.

Consuming errors as a client

const res = await fetch(signed.url, {
  method: signed.method,
  headers: signed.headers,
  body: signed.body,
});

if (!res.ok) {
  const body = await res.json();
  const { code, message } = body.error;

  switch (code) {
    case "expired_signature":
      // Clock drift or you sat on the signed request too long. Re-sign.
      return resign();
    case "replayed_nonce":
      // You reused a nonce — bug in your signer, not the service.
      throw new Error(`signer bug: ${message}`);
    case "revoked_key":
      // Your key was revoked. Get a new one + restart signup.
      throw new Error("key revoked, re-onboard");
    case "rate_limit_exceeded":
      // Honour Retry-After.
      const retryAfter = Number(res.headers.get("retry-after") ?? 1);
      await sleep(retryAfter * 1000);
      return retry();
    case "attestation_required":
      // Service is attested_only and you didn't send AFAuth-Attestation.
      throw new Error("service requires attestation");
    default:
      throw new Error(`afauth: ${code}: ${message}`);
  }
}
This is the typical decision tree. Three codes are particularly worth disambiguating: owner_authentication_required (no owner session at all), owner_session_too_stale (session present but expired against the §7.5 freshness window), and owner_binding_blocked (an agent-signed request to an owner-binding operation). All three return 403, but the user-facing prompt differs — re-authenticate vs. tell the user this is operator-only.

Producing errors as a service

The TypeScript SDK throws AFAuthError(code, status, message) from its handlers. Render the throw to the envelope:
function errorResponse(err: unknown): Response {
  if (typeof err === "object" && err !== null &&
      "code" in err && "status" in err && "message" in err) {
    const e = err as { code: string; status: number; message: string };
    return new Response(
      JSON.stringify({ error: { code: e.code, message: e.message } }),
      { status: e.status, headers: { "content-type": "application/json" } },
    );
  }
  return new Response(
    JSON.stringify({ error: { code: "internal_error", message: "internal error" } }),
    { status: 500, headers: { "content-type": "application/json" } },
  );
}
Full reference: examples/recipes/verify.ts.

Custom error codes

Services MAY define additional codes for service-specific conditions — example_quota_exceeded, feature_not_in_plan, etc. Per §11.3, custom codes SHOULD be prefixed with a service-specific namespace so clients can distinguish them from reserved codes.

Further reading