|Agent-Auth.
Build a server

Build a server

How to add Agent Auth support to your service using the Better Auth plugin — setup, capabilities, OpenAPI adapter, execution, verification, and configuration.

This guide covers how to add Agent Auth support to your service. The fastest way is using the @better-auth/agent-auth plugin, which handles registration, device flow, capability management, and JWT verification out of the box.

Installation

Install Better Auth and the Agent Auth plugin:

npm install better-auth @better-auth/agent-auth

Add the plugin to your auth configuration:

import { betterAuth } from "better-auth";
import { agentAuth } from "@better-auth/agent-auth";

export const auth = betterAuth({
  // ... your existing config
  plugins: [
    agentAuth({
      providerName: "my-service",
      providerDescription: "My service description",
    }),
  ],
});

The plugin automatically:

  • Creates the necessary database tables (agentHost, agent, agentCapabilityGrant, approvalRequest)
  • Exposes the /.well-known/agent-configuration discovery endpoint
  • Handles agent registration with Host JWT verification
  • Manages the device authorization approval flow
  • Verifies Agent JWTs on capability execution
  • Enforces capability grants and constraints

Run your database migrations after adding the plugin so the new tables are created.

Define capabilities

Define the capabilities your service offers. Each capability has a name, description, and optional input/output JSON schemas:

import { agentAuth } from "@better-auth/agent-auth";

agentAuth({
  providerName: "bank",
  capabilities: [
    {
      name: "check_balance",
      description: "Check the balance of a bank account",
      input: {
        type: "object",
        required: ["account_id"],
        properties: {
          account_id: { type: "string", description: "The account ID" },
        },
      },
      output: {
        type: "object",
        properties: {
          balance: { type: "number" },
          currency: { type: "string" },
        },
      },
    },
    {
      name: "transfer_funds",
      description: "Transfer funds between accounts",
      approvalStrength: "webauthn",
      input: {
        type: "object",
        required: ["from", "to", "amount"],
        properties: {
          from: { type: "string" },
          to: { type: "string" },
          amount: { type: "number" },
        },
      },
    },
  ],
  defaultHostCapabilities: ["check_balance"],
});

Capabilities listed in defaultHostCapabilities are auto-granted to agents from trusted hosts. All other capabilities require explicit user approval through the approval flow.

The approvalStrength field controls how much proof is required to approve a capability:

  • "none" — auto-grant, no user interaction
  • "session" — requires an active user session (default)
  • "webauthn" — requires physical presence via WebAuthn (fingerprint, face scan, hardware key)

Handle execution

The onExecute handler is called when an agent executes a capability. The plugin validates the JWT, checks grants, and enforces constraints before your handler runs:

agentAuth({
  capabilities: [/* ... */],
  onExecute: async ({ capability, arguments: args, agentSession }) => {
    switch (capability) {
      case "check_balance": {
        const balance = await db.getBalance(args.account_id);
        return { balance: balance.amount, currency: balance.currency };
      }
      case "transfer_funds": {
        const result = await db.transfer(args.from, args.to, args.amount);
        return { transaction_id: result.id, status: "completed" };
      }
      default:
        throw new Error(`Unknown capability: ${capability}`);
    }
  },
});

The return value determines the response type:

  • Plain value — sync response, returned as { data: result }
  • asyncResult(url) — returns 202 Accepted with a polling URL
  • streamResult(body) — returns an SSE stream

Async execution

For long-running operations, return an asyncResult with a status URL the client can poll:

import { agentAuth, asyncResult } from "@better-auth/agent-auth";

agentAuth({
  onExecute: async ({ capability, arguments: args }) => {
    if (capability === "generate_report") {
      const job = await startReportJob(args);
      return asyncResult(
        `https://api.example.com/jobs/${job.id}/status`,
        5 // retry-after seconds
      );
    }
  },
});

Streaming execution

For streaming responses, return a streamResult with a ReadableStream:

import { agentAuth, streamResult } from "@better-auth/agent-auth";

agentAuth({
  onExecute: async ({ capability, arguments: args }) => {
    if (capability === "stream_analysis") {
      const stream = createAnalysisStream(args);
      return streamResult(stream);
    }
  },
});

OpenAPI adapter

If your service already has an OpenAPI spec, you can derive capabilities and execution logic directly from it instead of defining them manually. The OpenAPI adapter turns each operation (with an operationId) into a capability and proxies execution to your upstream API.

Quick setup with createFromOpenAPI

The simplest way — generates capabilities, execution handler, provider info, and default host capabilities all at once:

import { agentAuth } from "@better-auth/agent-auth";
import { createFromOpenAPI } from "@better-auth/agent-auth/openapi";

const spec = await fetch("https://api.example.com/openapi.json")
  .then(r => r.json());

export const auth = betterAuth({
  plugins: [
    agentAuth({
      ...createFromOpenAPI(spec, {
        baseUrl: "https://api.example.com",
        defaultHostCapabilities: ["GET", "HEAD"],
        approvalStrength: {
          GET: "session",
          POST: "webauthn",
          DELETE: "webauthn",
        },
        async resolveHeaders({ agentSession }) {
          const token = await getServiceToken(agentSession.user.id);
          return { Authorization: `Bearer ${token}` };
        },
      }),
      // you can still override or add options
      deviceAuthorizationPage: "/approve",
    }),
  ],
});

createFromOpenAPI returns an object with providerName, providerDescription, capabilities, onExecute, and optionally defaultHostCapabilities — all derived from the spec. Spread it into agentAuth() and override anything you need.

How it works

Each OpenAPI operation with an operationId becomes a capability:

OpenAPI fieldCapability field
operationIdname
description or summarydescription
Parameters + request bodyinput (JSON Schema)
200/201 response bodyoutput (JSON Schema)

When a capability is executed, the handler maps arguments back to path parameters, query parameters, headers, and request body, then forwards the request to the upstream API.

The handler automatically detects response types:

  • 202 responses → async result with polling URL
  • text/event-stream → streaming result
  • application/json → parsed JSON
  • Everything else → plain text

createFromOpenAPI options

OptionTypeDescription
baseUrlstringUpstream API base URL (required)
resolveHeaders(context) => Record<string, string>Add auth headers (e.g. Authorization) to upstream requests
fetchtypeof fetchCustom fetch implementation
defaultHostCapabilitiesboolean | string | string[] | functionWhich capabilities to auto-grant (see below)
approvalStrengthApprovalStrength | Record<string, ApprovalStrength> | functionRequired approval level per capability
locationstringCustom execution URL for all capabilities

defaultHostCapabilities filter:

  • true — all capabilities
  • "GET" — all GET operations
  • ["GET", "HEAD"] — all operations matching listed methods
  • (cap, context) => boolean — callback with runtime context (userId, hostId, mode)

Using fromOpenAPI and createOpenAPIHandler separately

For more control, use the lower-level helpers independently:

import { agentAuth } from "@better-auth/agent-auth";
import {
  fromOpenAPI,
  createOpenAPIHandler,
} from "@better-auth/agent-auth/openapi";

const spec = await fetch("https://api.example.com/openapi.json")
  .then(r => r.json());

const capabilities = fromOpenAPI(spec);

const onExecute = createOpenAPIHandler(spec, {
  baseUrl: "https://api.example.com",
  async resolveHeaders({ agentSession }) {
    return { Authorization: `Bearer ${await getToken(agentSession.user.id)}` };
  },
});

agentAuth({
  providerName: "my-api",
  capabilities,
  onExecute,
  defaultHostCapabilities: capabilities
    .filter(c => c.name.startsWith("get_"))
    .map(c => c.name),
});

fromOpenAPI(spec) returns Capability[]. createOpenAPIHandler(spec, opts) returns an onExecute function. This lets you filter capabilities, add custom ones, or mix OpenAPI-derived and hand-written capabilities.

Verify agent requests

Inside Better Auth endpoints

On your API routes, verify agent sessions with a single call:

const session = await auth.api.getAgentSession({
  headers: request.headers,
});

if (session) {
  const { agent, user, host } = session;
  // agent.id, agent.mode, agent.capabilityGrants
  // user.id, user.email (for delegated agents)
  // host.id, host.status
}

This extracts the JWT, verifies the Ed25519 signature, checks the agent's status, and resolves its capability grants. For delegated agents, you get the linked user.

In custom routes (outside Better Auth)

Use verifyAgentRequest to verify agent JWTs in routes that aren't part of Better Auth:

import { verifyAgentRequest } from "@better-auth/agent-auth";

export async function handleCustomRoute(request: Request) {
  const session = await verifyAgentRequest(request, auth);

  if (!session) {
    return new Response("Unauthorized", { status: 401 });
  }

  // session has the same shape as getAgentSession
  return new Response(JSON.stringify({ data: "..." }));
}

This forwards the JWT to the plugin's /agent/session endpoint internally, running the full verification flow.

Approval flow

When a host registers a delegated agent, the server returns a verification URL and user code. You need to build an approval page where users can:

  1. See which agent is requesting access and from which host
  2. Review the requested capabilities
  3. Approve or deny the request
  4. Optionally trust the host for future auto-approval

The deviceAuthorizationPage option controls the URL returned to clients:

agentAuth({
  deviceAuthorizationPage: "/approve",
  // or a full URL: "https://app.example.com/approve"
});

The page receives agent_id and code as query parameters. Your app loads the approval request, displays the details, and calls auth.api.approveCapability() or the approval endpoint to approve or deny.

The approval page should require fresh authentication — a long-lived session cookie alone is not sufficient (see Security Considerations). The freshSessionWindow option (default: 300 seconds) controls how recent the session must be.

Filter capabilities per user

Use resolveCapabilities to show different capabilities to different users — useful for plan-gated features, admin-only actions, or per-org capability sets:

agentAuth({
  resolveCapabilities: ({ capabilities, agentSession, hostSession }) => {
    if (!agentSession?.user) return capabilities;

    const user = agentSession.user;
    return capabilities.filter(cap => {
      if (cap.name === "admin_action") return user.role === "admin";
      return true;
    });
  },
});

Events and audit logging

Use onEvent to hook into agent lifecycle events for audit logging, analytics, or side effects:

agentAuth({
  onEvent: async (event) => {
    switch (event.type) {
      case "agent.created":
      case "agent.revoked":
      case "capability.approved":
      case "capability.denied":
      case "capability.executed":
        await auditLog.write(event);
        break;
    }
  },
});

Event types include: agent.created, agent.revoked, agent.reactivated, agent.key_rotated, host.created, host.revoked, capability.requested, capability.approved, capability.denied, capability.granted, capability.executed, and more.

The capability.executed event includes timing (durationMs), the capability name, arguments, output, and success/error status.

Configuration reference

All options with their types and defaults:

OptionTypeDefaultDescription
providerNamestringProvider name for discovery
providerDescriptionstringHuman-readable service description
capabilitiesCapability[]Capability definitions
onExecutefunctionCapability execution handler
modesAgentMode[]["delegated","autonomous"]Supported agent modes
defaultHostCapabilitiesstring[] | function[]Capabilities auto-granted to new hosts
deviceAuthorizationPagestring"/device/capabilities"Approval page path or URL
approvalMethodsstring[]["ciba","device_authorization"]Supported approval methods
resolveApprovalMethodfunctionPrefers device_authorizationChoose approval method per request
allowedKeyAlgorithmsstring[]["Ed25519"]Allowed key algorithms (JWK curve names)
jwtMaxAgenumber60Max JWT age in seconds
agentSessionTTLnumber3600Sliding session TTL (seconds). 0 to disable
agentMaxLifetimenumber86400Max session lifetime from activation (seconds). 0 to disable
absoluteLifetimenumber0Hard lifetime from creation — agent is revoked when elapsed. 0 to disable
maxAgentsPerUsernumber25Max active agents per user. 0 to disable
freshSessionWindownumber | function300How recent the user's session must be for approval (seconds)
blockedCapabilitiesstring[][]Capabilities that can never be granted
requireAuthForCapabilitiesbooleanfalseRequire auth to list/describe capabilities
allowDynamicHostRegistrationboolean | functionfalseAllow unknown hosts to self-register
resolveCapabilitiesfunctionFilter capabilities per user context
resolveQueryfunctionCustom capability search (replaces built-in BM25)
resolveAutonomousUserfunctionResolve a virtual user for autonomous agents
resolveGrantTTLfunctionPer-capability grant TTL
validateCapabilitiesfunctionValidate capability names at registration
onEventfunctionAudit/event callback
onHostClaimedfunctionCalled when a host is linked to a user
onAutonomousAgentClaimedfunctionCalled when an autonomous agent is claimed
trustProxybooleanfalseTrust X-Forwarded-Proto for audience validation
jtiCacheStorage"memory" | "secondary-storage""memory"Where to store JTI values for replay protection
jwksCacheStorage"memory" | "secondary-storage""memory"JWKS cache backend
dangerouslySkipJtiCheckbooleanfalseDisable JWT replay protection (dev only)
proofOfPresenceobject{ enabled: false }WebAuthn config for approval
rateLimitRecord<path, { window, max }>sensible defaultsPer-endpoint rate limits
schemaobjectCustom database schema overrides

Cache storage

By default, JTI replay protection and JWKS caching use in-memory Maps. For multi-instance deployments, switch to "secondary-storage" which uses Better Auth's configured secondaryStorage (e.g. Redis):

agentAuth({
  jtiCacheStorage: "secondary-storage",
  jwksCacheStorage: "secondary-storage",
});

If secondaryStorage is available and you're still using "memory", the plugin logs a warning.

Proof of presence (WebAuthn)

For high-risk capabilities, require physical presence via WebAuthn:

agentAuth({
  proofOfPresence: {
    enabled: true,
    rpId: "example.com",       // defaults to hostname from baseURL
    origin: "https://example.com", // defaults to baseURL origin
  },
  capabilities: [
    {
      name: "delete_account",
      approvalStrength: "webauthn", // this capability requires WebAuthn
    },
  ],
});

Requires the @better-auth/passkey plugin to be installed so users can register authenticators.

Data model

The plugin creates four tables:

TablePurpose
agentHostHost identities with public keys, linked users, and trust status
agentPer-agent records with status, mode, lifetime clocks, and keypairs
agentCapabilityGrantPer-capability grants with status, constraints, and metadata
approvalRequestDevice authorization and CIBA approval requests

See Data Model for the full schema.

Rate limiting

The plugin applies sensible rate limits by default:

PathDefault
/agent/register10 requests / 60s
/agent/rotate-key, /agent/cleanup5 requests / 60s
/agent/approve-capability5 requests / 60s
All other agent/capability endpoints60 requests / 60s

Override per-endpoint:

agentAuth({
  rateLimit: {
    "/agent/register": { window: 120, max: 5 },
    "/capability/execute": { window: 60, max: 200 },
  },
});

Building from scratch

If you're not using Better Auth, you can implement the protocol from scratch. You need:

  • 4 database tables — hosts, agents, agent capability grants, approval requests (see Data Model)
  • Ed25519 verification — verify Host and Agent JWT signatures
  • Device authorization flow — RFC 8628 for user approval
  • Discovery endpoint/.well-known/agent-configuration
  • Protocol endpoints — register, status, capabilities, execute, etc.

Read the full specification for complete endpoint definitions, JWT claims, error codes, and edge cases.

Ask AI

Ask anything about Agent Auth Protocol.

Ask anything about Agent Auth Protocol.