Better Auth authenticates; Access authorizes. An agent's client gets its token from Better Auth's own OAuth provider,
@better-auth/oauth-provider, which you add to betterAuth.plugins with its options as Better Auth's docs give them.
Access issues no token and serves no OAuth route.
With mcp: { resource }, Access reads the provider's tokens for your MCP endpoint, holds the agent to its user's
grants and its token's scopes, and gives the framework its mcp.authorization.
Install the provider and @better-auth/cimd in the same range as Better Auth. npm takes the newest patch of each, with
Better Auth's at the same patch:
npm install @better-auth/oauth-provider@~1.7.12 @better-auth/cimd@~1.7.12// src/access.mcp.ts
import { createAccess } from "@aventara/access/auth";
import { cimd } from "@better-auth/cimd";
import { fetchClientMetadataResource } from "@better-auth/cimd/node";
import { oauthProvider } from "@better-auth/oauth-provider";
import { Pool } from "pg";
import { policies } from "./access.policies.js";
/** The MCP endpoint, as its protected-resource metadata names it. */
const resource = "https://app.example.com/mcp";
const claims = { role: "string", banned: "boolean" } as const;
export const access = createAccess({
betterAuth: {
database: new Pool({ connectionString: process.env.DATABASE_URL }),
baseURL: "https://app.example.com",
emailAndPassword: { enabled: true },
user: {
additionalFields: {
role: { type: "string", required: false, input: false },
banned: { type: "boolean", required: false, input: false },
},
},
plugins: [
// Better Auth's OAuth provider, as its docs give it: it issues every token.
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
scopes: ["aventara:read", "aventara:write", "offline_access"],
resources: [resource],
clientRegistrationDefaultResources: [resource],
}),
// Client ID Metadata Documents: an agent's client is the domain its document is served from.
cimd({ fetchClientMetadataResource, metadataProfile: "mcp-2026-07-28" }),
],
},
policies: { rules: policies, claims },
principal: ({ sub, claims }) => ({
id: sub,
claims: {
...(claims.role === "admin" ? { role: "admin" as const } : {}),
...(claims.banned === undefined ? {} : { banned: claims.banned }),
},
}),
mcp: { resource },
});Then the framework's configuration takes it in one line, beside access:
mcp: { path: "/mcp", authorization: access.mcp }.
What access.mcp holds
access.mcp is there exactly when mcp is given, and is { servers, verify }:
serversis Access's issuer, the origin of Better Auth's base URL, alone. Mountaccess.handlerat/.well-known/oauth-authorization-servertoo, at the root of that origin, where a client looks for the provider's metadata. The issuer is https, or on this machinehttp://127.0.0.1orhttp://[::1]with any port. SetbaseURLto that address, notlocalhost.verify(token)takes the provider's JWT access token for the MCP endpoint and nothing else, and resolves who the agent acts for:{ sub, claims, clientId, scopes }, the claims read from the user now.
verify refuses a session token (GET /token), a token for another resource, an opaque token, a token bound to a DPoP
key, a client-credentials token, and one whose client is disabled, whose user is gone or no longer consents to its
scopes, or whose session has ended.
POST /oauth2/revoke does not revoke a JWT access token: the provider answers that a self-contained token cannot be
revoked server-side, and the token verifies until it expires. To stop an agent at once, revoke the user's consent to its
client, disable the client or end its session, which verify reads on every request, and keep the provider's
accessTokenExpiresIn short. /oauth2/revoke does revoke a refresh token.
With Better Auth's secondaryStorage, the provider requires session.storeSessionInDatabase: true, so the session a
token names is a database row. With preserveSessionInDatabase too, Better Auth ends that row when the session ends, and
the token stops.
Better Auth on Prisma's adapter
Better Auth's OAuth provider (1.7.12 to 1.7.14) cannot register its resource on Better Auth's Prisma adapter: it writes
allowedScopes: null, and the String[] column auth generate writes for it is a list Prisma will not leave unset.
Run Better Auth on a pg Pool (its built-in Kysely), as the samples on this page do. The tables can still live in
your Prisma schema: auth generate writes them, prisma db push makes them, and resources.authTables names the
Resource each one is.
How an agent is held
The request state reads the same token, so ctx.$state.me is the user's principal, as principal maps it. The policy
holds it to the token before the rules:
- Under the
agentorigin,aventara:readadmits the read questions andaventara:writethe writes (create,update,delete). A question the scopes do not admit is refused. One they admit is the user's own, so an agent never does what its user may not. Your rules can narrow it further by origin. - Under any other origin it is refused everything. The token opens the MCP endpoint alone, never the protocol routes,
_access/can, a server-side call or Access's ownGET /meroute. - A refused question is an audit event like any other, with the reason
deny:mcp-scopeordeny:mcp-audience.
The claims are read from the user on every request, so a ban or a role taken away reaches the agent on its next call.
Telling an agent apart
agentOf(me), from @aventara/access/auth and @aventara/access/better-auth, answers { clientId, scopes } for
the principal an agent's token yields, and null for any other principal. The mark does not show in the principal's own
fields, its spread or its JSON.
// src/agent.check.ts
import { agentOf } from "@aventara/access/auth";
/** Whether a request's principal is an agent acting for its user, and which client. */
export const actingAgent = (me: unknown): string | null => agentOf(me)?.clientId ?? null;me() is the token identity, { sub, claims }, everywhere, the MCP surface included. Only the HTTP route GET /me
answers an agent's token null.
Registration
With @better-auth/cimd, an agent's client registers itself by its client_id, the URL of its metadata document, and
your consent page can name the domain it comes from.
Open dynamic registration (allowDynamicClientRegistration with allowUnauthenticatedClientRegistration: true)
registers any client as whoever it says it is. Access refuses it unless you acknowledge it with
mcp: { resource, openRegistration: true }. Registration by a signed-in user or with an initial access token needs no
acknowledgement.
Your consent page is your app's. Show the client's name, the domain of its metadata document and the host of the redirect URI it asked for, and say what each scope lets the agent do.
What Access refuses at startup
ACCESS_OPTIONS_INVALID, on a provider that would let an agent's authorization fall short:
- no provider in the plugins, or two;
- opaque access tokens (
disableJwtPlugin); - the endpoint missing from its
resources, or noaventara:readamong itsscopes; - a refresh reuse window (
refreshTokenReuseIntervalother than 0); - a
validateRedirectUri, arequestUriResolver, or apostLogin.consentReferenceId; - open registration without
openRegistration; - an endpoint that is the provider's own UserInfo URL;
- the
serviceprofile, which hands no browser a session to consent in.
A provider or a cimd() outside ~1.7.12 is refused with ACCESS_BETTER_AUTH_VERSION. Every
authorization request must use PKCE with SHA-256 and carry its own parameters; one without, or one by reference
(request_uri), is refused with 400 invalid_request.
With the Better Auth plugin, pass the same mcp to aventaraAccess() and put oauthProvider() after it in
plugins. accessOf(auth).mcp is then the framework's mcp.authorization.
One audience per service
By default a service takes the one-org token GET /token issues, whose audience is the issuer. A service can opt in to
tokens addressed to it alone (RFC 8707). audience names its URL, and the provider lists it among its resources.
Each client the service takes is linked to it, one by one (auth.api.adminLinkClientResource, the provider's own
operator call), and asks for one of Access's scopes.
// src/access.audience.ts
import { createAccess } from "@aventara/access/auth";
import { oauthProvider } from "@better-auth/oauth-provider";
import { Pool } from "pg";
import { policies } from "./access.policies.js";
/** This service, as a client names it when it asks for a token. */
const audience = "https://app.example.com/api";
const claims = { role: "string", banned: "boolean" } as const;
export const access = createAccess({
betterAuth: {
database: new Pool({ connectionString: process.env.DATABASE_URL }),
baseURL: "https://app.example.com",
emailAndPassword: { enabled: true },
user: {
additionalFields: {
role: { type: "string", required: false, input: false },
banned: { type: "boolean", required: false, input: false },
},
},
plugins: [
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
scopes: ["aventara:read", "aventara:write", "offline_access"],
resources: [audience],
}),
],
},
policies: { rules: policies, claims },
principal: ({ sub, claims }) => ({
id: sub,
claims: {
...(claims.role === "admin" ? { role: "admin" as const } : {}),
...(claims.banned === undefined ? {} : { banned: claims.banned }),
},
}),
audience,
});- With
audience, the request state takes a bearer token only when it is the provider's access token addressed to the service alone, checked asverifychecks an agent's, and carryingaventara:readoraventara:write. - Its principal is the user's, held to those scopes under every origin. A scope never grants what the user's own grants do not.
- The one-org token is refused. A session cookie is read as before.
audiencecannot be the MCP endpoint, the issuer itself or the provider's UserInfo URL.- Access refuses a provider with
enforcePerClientResources: false, and open registration. Withmcpbeside it, the audience inclientRegistrationDefaultResourcesorclientRegistrationAllowedResourcesis refused too.
On the kiosk profile, a session bound to a till never gets an agent's token (The kiosk profile).