| name | agent-identity-issuer |
| description | Make a Better Auth app an OIDC/OAuth issuer (it signs tokens other apps verify) and give an AI agent its own bounded, on-behalf-of credential. Use when configuring @better-auth/oauth-provider, the jwt/JWKS plugin, OAuth client registration, the consent flow, resource-server token verification, or @better-auth/agent-auth. The official better-auth-* skills do NOT cover token issuance or agent identity — this one does, with config verified against Better Auth 1.6.x. |
Issuer & Agent Identity (Better Auth)
The official better-auth-* skills cover authenticating your own users (email/password, sessions, 2FA, security). They do not cover making your app an issuer (signing tokens other apps trust) or giving an agent its own identity. That is this skill. Every snippet below is verified against Better Auth 1.6.x and @better-auth/oauth-provider; paste the config, do not reconstruct it from memory.
1. Become an OIDC/OAuth issuer
Install: pnpm add @better-auth/oauth-provider (the jwt plugin is in core better-auth).
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins/jwt";
import { oauthProvider } from "@better-auth/oauth-provider";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
appName: "AuthCo",
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
emailAndPassword: { enabled: true, minPasswordLength: 12 },
plugins: [
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
scopes: ["openid", "profile", "email", "offline_access"],
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
}),
nextCookies(),
],
});
Mount everything with one catch-all route: src/app/api/auth/[...all]/route.ts → export const { GET, POST } = toNextJsHandler(auth).
Endpoints it exposes (under the handler base /api/auth)
- Discovery:
/api/auth/.well-known/openid-configuration
- Public keys:
/api/auth/jwks ← public params only; private key is never served
/api/auth/oauth2/authorize, /oauth2/token, /oauth2/userinfo, /oauth2/consent, /oauth2/introspect, /oauth2/revoke, /oauth2/register
The consent flow has changed (verified gotcha)
oauthProvider does not use a short consent_code. /oauth2/authorize redirects to your consentPage with a signed oauth_query string; the consent page POSTs that whole string back to /api/auth/oauth2/consent as { accept: true, oauth_query }. To JSON callers (send sec-fetch-mode: cors), both authorize and consent return { redirect, url } instead of a raw 302 — useful for headless/scripted flows.
2. Register a client (two ways)
- Self-service (recommended for real apps):
auth.api.createOAuthClient({ headers, body: { redirect_uris, token_endpoint_auth_method } }). Caveat: it requires a logged-in session and auto-generates a random client_id — great for signup, wrong when a separate app needs a stable, known id up front.
- Deterministic (fixed id/secret, e.g. a demo connector): insert a row in the
oauthClient table. The secret column must hold the same hash the plugin uses: base64url(SHA-256(secret)), unpadded. Array columns (redirectUris, scopes, grantTypes, responseTypes) are stored as JSON text. Set tokenEndpointAuthMethod: "client_secret_basic".
- Dynamic registration (
allowDynamicClientRegistration: true) exposes /oauth2/register (RFC 7591) for clients that self-enroll.
The durable question: how does a client prove who it is? (manual → DCR → CIMD)
The mechanism here is actively churning; the primitive is not. Teach the question, treat each mechanism as one instantiation:
- Manual / static — a fixed
client_id you pre-register (a DB row, as above). Stable, production-fine, what this base uses by default.
- DCR (Dynamic Client Registration, RFC 7591) — the client POSTs to
/oauth2/register, the server creates a record and returns a generated id/secret. Better Auth supports this. In MCP it is now being deprecated (kept for back-compat): it burdens the authorization server and is open to client-impersonation/spam.
- CIMD (Client ID Metadata Documents) — the
client_id is an https URL hosting a JSON metadata document; the server fetches that URL instead of keeping a record. No pre-registration. This is the new preferred direction: the MCP 2026-07-28 spec recommends it and deprecates DCR.
⚠️ Edge status (pre-release on a draft standard — pin versions, re-check at ship time): CIMD is an IETF draft (draft-ietf-oauth-client-id-metadata-document, WG-adopted, not yet an RFC). Better Auth implements it in @better-auth/cimd, and it is proven on 1.7.0-rc.0 (cimd-verify.sh 10/10 in the reference solution). The verified facts:
- Wiring: add the
cimd({ allowLoopback: true }) plugin and list it AFTER oauthProvider() — it calls extendOAuthProvider in init(), so order matters. allowLoopback (default false) lets a loopback http://localhost/...client.json client_id work for local testing; off-loopback, HTTPS is strictly enforced — so CIMD IS testable locally over plain http. Other options: refreshRate, originBoundFields, allowFetch. Alt path: oauthProvider({ extensions: [{ clientDiscovery: cimdClientDiscovery() }] }) — note extensions, not a top-level clientDiscovery.
- Discovery key: it advertises
client_id_metadata_document_supported: true.
- Cache-row reality: a URL
client_id is NOT "no DB row." CIMD auto-creates a public client row (public = 1, clientSecret = NULL, clientId = the URL) — a cached public client, distinct from a seeded confidential client.
- Two 1.7 migration gotchas: (1) pin
kysely to 0.28.17 via a pnpm override (kysely 0.29 dropped the DEFAULT_MIGRATION_TABLE runtime export the migrator imports, else every /api/auth/* route 500s); (2) OIDC discovery moved to the issuer root (internal endpoints are SERVER_ONLY) — add Next route handlers at src/app/.well-known/openid-configuration/route.ts and .well-known/oauth-authorization-server/route.ts that forward to auth.handler; JWKS stays at /api/auth/jwks. (Minor: /oauth2/userinfo returns 401 even with a valid token on rc.0 — flag, don't chase.)
The pinned set: better-auth@1.7.0-rc.0, @better-auth/oauth-provider@1.7.0-rc.0, @better-auth/cimd@1.7.0-rc.0, peer better-call@1.3.7. Stable better-auth (1.6.23) is DCR-only, so CIMD requires the 1.7 line — a real, runnable upgrade, not just a concept. FastMCP-side CIMD support is still an open issue (SEP-991). It is still a draft standard on a pre-release, so re-check versions at ship time.
3. Verify a token as a resource server (the consumer side)
A consumer validates an issued token offline, using only the JWKS — no shared secret, no call into the issuer's database:
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL("http://localhost:3000/api/auth/jwks"));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: "http://localhost:3000",
audience: "your-client-id",
});
Or use the typed helper: oauthProviderResourceClient(auth) from @better-auth/oauth-provider/resource-client.
Client-side (the issuer app's own browser code): oauthProviderClient() from @better-auth/oauth-provider/client, added to createAuthClient.
4. Give an agent its own identity (half 2 — @better-auth/agent-auth, BETA)
@better-auth/agent-auth is experimental (the standard is still moving — say so to the reader). It maps directly onto the durable primitives:
import { agentAuth } from "@better-auth/agent-auth";
agentAuth({
providerName: "AuthCo",
modes: ["delegated", "autonomous"],
capabilities: [
],
approvalMethods: ["device_authorization", "ciba"],
async onExecute({ capability, arguments: args, agentSession }) {
},
});
- The agent discovers the provider at
/.well-known/agent-configuration, registers, requests capability grants; the user approves (device flow or CIBA); the server issues short-lived JWTs whose aud matches the URL being called, carry a capabilities claim, and a jti for replay protection.
- Verify outside
onExecute: verifyAgentRequest(request, auth) or auth.api.getAgentSession({ headers }) → { user, agent (with capabilityGrants), host }.
- Teach this as one swappable instantiation of the primitives (own credential, on-behalf-of, least-privilege scope, human approval, revocable), not as the final standard.
Invariants to enforce while building
- Keep
storeClientSecret at its hashed default; never store a client secret in plaintext.
- Never serve or log a private signing key — only
/jwks (public) is reachable.
- Every issued token carries
iss, aud, and a finite exp; resource servers must check all three.
- A revoked client/token must actually fail the next call — verify via
/oauth2/revoke + a follow-up request, don't assume.