docs/content/docs/plugins/agent-auth.mdx
AI Agents MCP Capabilities
The Agent Auth plugin lets your Better Auth server act as an Agent Auth provider. It's a server implementation of the Agent Auth Protocol.
It gives AI agents a standard way to discover your service, register themselves, request approval, and execute scoped capabilities using short-lived signed JWTs. It comes with adapters for OpenAPI and MCP — so you can turn an existing REST API or MCP server into an agent-auth-enabled service without writing capabilities by hand.
onExecute handler directly from an OpenAPI 3.x spec/.well-known/agent-configurationlocation URLs)```package-install
@better-auth/agent-auth
```
Client and CLI packages (optional):
```bash
npm install @auth/agent @auth/agent-cli
```
Start by defining the capabilities your service exposes and an `onExecute` handler that performs the action for an authenticated agent.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { agentAuth } from "@better-auth/agent-auth"; // [!code highlight]
export const auth = betterAuth({
plugins: [
agentAuth({ // [!code highlight]
providerName: "Acme", // [!code highlight]
providerDescription: "Acme project and deployment APIs for AI agents.", // [!code highlight]
modes: ["delegated", "autonomous"], // [!code highlight]
capabilities: [ // [!code highlight]
{ // [!code highlight]
name: "deploy_project", // [!code highlight]
description: "Deploy a project to production.", // [!code highlight]
input: { // [!code highlight]
type: "object", // [!code highlight]
properties: { // [!code highlight]
projectId: { type: "string" }, // [!code highlight]
}, // [!code highlight]
required: ["projectId"], // [!code highlight]
}, // [!code highlight]
}, // [!code highlight]
{ // [!code highlight]
name: "list_projects", // [!code highlight]
description: "List projects the current user can access.", // [!code highlight]
}, // [!code highlight]
], // [!code highlight]
async onExecute({ capability, arguments: args, agentSession }) { // [!code highlight]
switch (capability) { // [!code highlight]
case "list_projects": // [!code highlight]
return [{ id: "proj_123", name: "marketing-site" }]; // [!code highlight]
case "deploy_project": // [!code highlight]
return { // [!code highlight]
ok: true, // [!code highlight]
projectId: args?.projectId, // [!code highlight]
requestedBy: agentSession.user.id, // [!code highlight]
}; // [!code highlight]
default: // [!code highlight]
throw new Error(`Unsupported capability: ${capability}`); // [!code highlight]
} // [!code highlight]
}, // [!code highlight]
}), // [!code highlight]
], // [!code highlight]
}); // [!code highlight]
```
The plugin provides `auth.api.getAgentConfiguration()`, but you should expose it from your app root at `/.well-known/agent-configuration`.
```ts title="app/.well-known/agent-configuration/route.ts"
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const configuration = await auth.api.getAgentConfiguration();
return NextResponse.json(configuration);
}
```
Run the migration or generate the schema to add the agent, host, grant, and approval tables.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```package-install
npx auth migrate
```
</Tab>
<Tab value="generate">
```package-install
npx auth generate
```
</Tab>
</Tabs>
If you want type-safe access to the plugin endpoints from a Better Auth client, add the client plugin too.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { agentAuthClient } from "@better-auth/agent-auth/client"; // [!code highlight]
export const authClient = createAuthClient({
plugins: [
agentAuthClient(), // [!code highlight]
],
});
```
The Agent Auth flow usually looks like this:
/.well-known/agent-configurationaud that matches the URL it calls) and invokes each granted capability at default_location or at that capability’s own location, if you set oneThe discovery document tells agents how to interact with your server. The plugin includes provider metadata, supported modes, approval methods, absolute endpoint URLs, and a default_location field.
Important fields for execution:
issuer — The provider’s base URL (Better Auth baseURL).endpoints — Absolute URLs for each route (for example execute points at POST /capability/execute on that base).default_location — The full URL of the default execute endpoint. It always matches endpoints.execute. Agents use this as the JWT aud when a capability does not define a custom URL, and as the request URL for those capabilities.Expose it from your app root:
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function GET() {
const configuration = await auth.api.getAgentConfiguration();
return NextResponse.json(configuration);
}
If your service already has an OpenAPI spec, you can turn the entire API into an agent-auth provider in a few lines. createFromOpenAPI reads the spec and produces everything the plugin needs: capabilities (one per operationId), input/output JSON Schemas, a proxy onExecute handler, and optionally providerName / providerDescription from info.
import { betterAuth } from "better-auth";
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",
}),
}),
],
});
That is all it takes. Every operation with an operationId in the spec becomes a capability whose name is that id. Path, query, and header parameters plus the JSON request body are merged into a single input schema, and the 200/201 response body becomes output.
The proxy handler calls your upstream API on behalf of the agent. Use resolveHeaders to inject the credentials each request needs (for example an internal service token or a user-scoped access token looked up from agentSession).
createFromOpenAPI(spec, {
baseUrl: "https://api.example.com",
async resolveHeaders({ agentSession }) {
const token = await getAccessToken(agentSession.user.id);
return { Authorization: `Bearer ${token}` };
},
});
Control which capabilities are auto-granted to new hosts. You can pass true (all), a single HTTP method string, an array of methods, or a callback that receives the full runtime context.
createFromOpenAPI(spec, {
baseUrl: "https://api.example.com",
defaultHostCapabilities: ["GET", "HEAD"],
});
Map HTTP methods to approvalStrength so mutating operations require stronger user verification (for example WebAuthn) while reads use a normal session.
createFromOpenAPI(spec, {
baseUrl: "https://api.example.com",
approvalStrength: {
GET: "session",
POST: "webauthn",
PUT: "webauthn",
DELETE: "webauthn",
},
});
locationWhen you set location, every derived capability gets that URL. Agents call it directly (with the agent JWT) instead of going through the default execute endpoint—useful when you want the agent to hit the real API URL and handle the session in your own middleware rather than proxying through onExecute.
createFromOpenAPI(spec, {
baseUrl: "https://api.example.com",
location: "https://api.example.com/agent/execute",
});
If you only need part of the pipeline, the adapter also exports the lower-level helpers:
fromOpenAPI(spec) — returns Capability[] only (no handler, no host caps).createOpenAPIHandler(spec, opts) — returns only the onExecute proxy handler so you can pair it with hand-written capabilities or filter the spec yourself.import {
fromOpenAPI,
createOpenAPIHandler,
} from "@better-auth/agent-auth/openapi";
const capabilities = fromOpenAPI(spec);
const onExecute = createOpenAPIHandler(spec, {
baseUrl: "https://api.example.com",
});
agentAuth({ capabilities, onExecute });
Capabilities are the contract between your application and an agent. Each capability has a name, a description, and optionally a JSON Schema input definition.
By default, agents call default_location from discovery (the execute URL) and the plugin runs onExecute. If you set location on a capability, agents call that absolute URL instead—for example an existing REST route—and onExecute is not used for those requests; you resolve the agent session with the helpers below and implement the handler yourself.
Use capabilities to expose narrow, reviewable actions instead of broad API access.
agentAuth({
capabilities: [
{
name: "create_issue",
description: "Create an issue in the current workspace.",
input: {
type: "object",
properties: {
title: { type: "string" },
body: { type: "string" },
},
required: ["title"],
},
},
],
});
Optional location — agents call this URL with the agent JWT instead of the default execute URL:
{
name: "create_issue",
description: "Create an issue in the current workspace.",
location: "https://api.example.com/v1/issues",
}
locationlocation — Agents POST to default_location (endpoints.execute) with { capability, arguments }. After the plugin validates the JWT and grant, it runs onExecute.location — Agents call that URL (your REST handler, another service, an OpenAPI operation URL, etc.). onExecute does not run for that call. Resolve agentSession in your handler using the helpers below, then enforce grants and your business logic.onExecuteFor custom location routes (or any non-execute handler), the agent still sends an Authorization: Bearer header with the agent JWT. Whatever framework you use, take the incoming Headers (e.g. request.headers, or your runtime’s equivalent) and pass them through—the verification path is the same: signature, aud, replay (jti), expiry, and (when present) request-binding claims.
auth.api.getAgentSession({ headers }) runs that flow in-process and returns AgentSession or null. verifyAgentRequest(request, auth) does the same by forwarding the Request’s headers to GET /agent/session via auth.handler—pick whichever fits your code shape; there is no Hono-vs-Next split, only “headers in, session out.”
import { auth } from "@/lib/auth";
export async function POST(request: Request) {
const agentSession = await auth.api.getAgentSession({
headers: request.headers,
});
if (!agentSession) {
return new Response("Unauthorized", { status: 401 });
}
// Check grants, enforce constraints, run your handler…
}
// Equivalent when you already have `Request` + `auth` and prefer a helper:
import { verifyAgentRequest } from "@better-auth/agent-auth";
const agentSession = await verifyAgentRequest(request, auth);
Checking grants and inputs
After you have agentSession, inspect agentSession.agent.capabilityGrants. These are active DB grants intersected with the JWT’s capabilities claim (same as execute). For the capability this route implements, ensure there is a matching grant:
const CAP = "create_issue";
const allowed = agentSession.agent.capabilityGrants.some(
(g) => g.capability === CAP && g.status === "active",
);
if (!allowed) {
return new Response("Forbidden", { status: 403 });
}
If that grant has constraints, validate the request body or query the same way POST /capability/execute would—otherwise a client could bypass constraints by calling your custom URL. The plugin does not re-run execute’s constraint helpers on arbitrary routes; that logic stays in your handler (or call into shared code you extract from your onExecute path).
What you get on the session
agentSession.user — Resolved user for the agent (delegated host user or resolveAutonomousUser).agentSession.agent — Id, name, mode, capabilityGrants, host id, metadata.agentSession.host — Host record when the agent is linked to a host.Types are exported from @better-auth/agent-auth (for example AgentSession).
aud)The JWT aud must match what the server expects for the URL being called:
location — Use default_location / endpoints.execute, or issuer / base URL values the plugin already allows.location — aud should be that same absolute URL. GET /capability/list includes location when set. Invalid location values in config fail at startup.Single capability in the JWT — If capabilities lists exactly one id, aud may equal that capability’s location when set.
Multiple capabilities in the JWT — Per-capability location values are not accepted as aud; use the issuer, base path, or default execute endpoint instead.
Behind a reverse proxy, set trustProxy if you need Host / X-Forwarded-Proto to line up with aud validation.
Use resolveCapabilities to show different capability sets to different callers, such as plan-gated, user-specific, or organization-specific capabilities.
onExecuteRuns for capabilities that use the default execute URL (no per-capability location). The plugin verifies the JWT (including aud), attaches agentSession, checks the grant, then calls onExecute. Capabilities with a custom location never hit this path—you handle them in your own route using the session helpers above.
agentAuth({
capabilities: [
{
name: "create_issue",
description: "Create an issue in the current workspace.",
},
],
async onExecute({ capability, arguments: args, agentSession }) {
if (capability !== "create_issue") {
throw new Error("Unsupported capability");
}
return {
ok: true,
title: args?.title,
createdBy: agentSession.user.id,
};
},
});
The plugin supports two approval methods:
device_authorization for browser-based approval with a user codeciba for backchannel approval flowsBy default, both are enabled. You can restrict or customize them with approvalMethods and resolveApprovalMethod.
agentAuth({
approvalMethods: ["ciba", "device_authorization"],
resolveApprovalMethod: ({ preferredMethod, supportedMethods }) => {
if (preferredMethod && supportedMethods.includes(preferredMethod)) {
return preferredMethod;
}
return "device_authorization";
},
deviceAuthorizationPage: "/device/capabilities",
});
Use onEvent to capture important lifecycle events such as:
This hook is a good place to write audit logs or feed analytics pipelines.
The Agent Auth plugin supports many options. These are the ones you will usually start with:
export const agentAuthOptionsType = {
providerName: {
description: "Human-readable provider name returned in discovery metadata.",
type: "string",
required: false,
},
providerDescription: {
description: "Description returned in the discovery document.",
type: "string",
required: false,
},
modes: {
description: 'Supported agent modes. Defaults to ["delegated", "autonomous"].',
type: '("delegated" | "autonomous")[]',
required: false,
},
capabilities: {
description:
"Capability definitions (name, description, optional input, optional absolute location — if set, agents call this URL instead of default_location and you use session helpers in your handler).",
type: "Capability[]",
required: false,
},
onExecute: {
description:
"Handler for capabilities invoked via the default execute URL (default_location). Not called when the agent uses a custom per-capability location.",
type: "function",
required: false,
},
requireAuthForCapabilities: {
description: "Require a host or agent JWT to list and describe capabilities.",
type: "boolean",
required: false,
},
approvalMethods: {
description: 'Supported approval methods. Defaults to ["ciba", "device_authorization"].',
type: "string[]",
required: false,
},
resolveApprovalMethod: {
description: "Choose the approval method for a request.",
type: "function",
required: false,
},
deviceAuthorizationPage: {
description: "Path or absolute URL for the user-facing device approval page.",
type: "string",
required: false,
},
defaultHostCapabilities: {
description: "Default capabilities applied to newly created hosts.",
type: "string[] | function",
required: false,
},
allowDynamicHostRegistration: {
description: "Allow unknown hosts to register dynamically.",
type: "boolean | function",
required: false,
},
onEvent: {
description: "Callback for audit and lifecycle events.",
type: "function",
required: false,
},
trustProxy: {
description:
"Trust X-Forwarded-Proto when validating JWT aud against request host (use behind a reverse proxy). Defaults to false.",
type: "boolean",
required: false,
},
}