Skip to content
Docs

Add AgentID to Better Auth

Put a Continue with AgentMail button in your app, so AI agents can sign in to it. Assumes a running Better Auth app; every step is copy-paste.

On this page

01Register a client in the console

Open the AgentID console and register a client with your app name and redirect URI. AgentID generates the client id and uses client_secret_basic automatically. Scope selection happens in the Better Auth configuration in step 3.

The redirect URI is an address on your own app — your origin, plus Better Auth’s fixed callback path:

redirect URI
https://yourapp.com/api/auth/oauth2/callback/agentid

The trailing agentid must match the providerId in step 3.

02Copy the secret while it is on screen

Copy the client_id and client_secret before closing the dialog. The secret is shown only once.

The AgentID console's client secret dialog, warning that the secret is displayed only once, with the client ID and client secret redacted and the secret's copy button circled.
Circled: the copy button, and this is the only time it will work.

Watch out. You now have a client id and a client secret — your app’s username and password. Step 3 needs both.

03Point it at AgentID

This is the whole integration. Add the plugin to the auth config you already have, and keep the comments — each marked line costs you a working sign-in if you drop it. No new tables: the plugin uses the ones your app already migrated.

.env
AGENTID_CLIENT_ID=<from step 2>
AGENTID_CLIENT_SECRET=<from step 2>
lib/auth.ts
import { betterAuth } from 'better-auth'
import { genericOAuth } from 'better-auth/plugins'

export const auth = betterAuth({
    // ...your existing options (database, plugins, …)
    onAPIError: { errorURL: '/sign-in-failed' }, // REQUIRED to see AgentID's errors
    plugins: [
        genericOAuth({
            config: [
                {
                    providerId: 'agentid', // same name as the redirect URI's last part
                    discoveryUrl: 'https://auth.agentid.com/.well-known/openid-configuration',
                    clientId: process.env.AGENTID_CLIENT_ID!,
                    clientSecret: process.env.AGENTID_CLIENT_SECRET!,
                    scopes: ['openid', 'email', 'profile'],
                    pkce: true, // REQUIRED: this connector does not bind the browser session with an OIDC nonce
                    authentication: 'basic', // REQUIRED; matches what you registered
                    // REQUIRED; agents with no display name cannot sign in without it
                    mapProfileToUser: (p) => ({ name: p.name ?? p.email.split('@')[0] }),
                },
            ],
        }),
    ],
})

This base configuration identifies the agent without requesting information about its human owner.

New to Better Auth? Its installation guide covers the app, the database and the migration; come back here once it runs.

04Add the client plugin and the button

Your existing /api/auth handler already serves the callback path from step 1 — there is no route to write. Add the client half of the plugin, and a button:

lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
import { genericOAuthClient } from 'better-auth/client/plugins'

export const authClient = createAuthClient({
    plugins: [genericOAuthClient()], // alongside any plugins you already have
})
your sign-in button
await authClient.signIn.oauth2({ providerId: 'agentid', callbackURL: '/' })

05Sign in, and let the agent approve

Open your app and click the button. You land on a page showing a request id, and it waits.

AgentID's approval page, titled Approve this sign-in, with a spinner reading Waiting for your agent to approve, a list of what Better Auth will receive, the request id beside a copy button, an agent approval instructions button, and a five-minute expiry countdown.
This sign-in opted into the owner scopes from step 6, so its receive list shows the owner fields too.

The waiting is the point. Give that id to your agent. It signs the id and sends it back, and the browser returns to your app signed in, with session.user.email set to the agent’s inbox address. What the agent does is at https://auth.agentid.com/docs/approve, linked from the waiting page too.

Watch out. If it fails, look in two places. AgentID’s own errors land on the page you set as onAPIError.errorURL, as error and error_description. Later failures show only oauth_code_verification_failed in the browser, and the real message is in the terminal running npm run dev.

06Optional: ask who owns the agent

If your app needs the human behind the agent, add owner_profile and owner_email to the scopes array above. The agent’s signing credential must permit them. The owner claims do not arrive in the id_token, so session.user will not have them; read them separately from /userinfo:

app/owner/route.ts
import { headers } from 'next/headers'

import { auth } from '@/lib/auth'

export async function GET() {
    const { accessToken } = await auth.api.getAccessToken({
        body: { providerId: 'agentid' },
        headers: await headers(), // needed; it finds the token through the session
    })

    const res = await fetch('https://auth.agentid.com/v0/userinfo', {
        headers: { Authorization: 'Bearer ' + accessToken },
    })

    return Response.json(await res.json())
}
what comes back
{
  "sub": "…", "org": "…",
  "email": "agent-inbox@yourdomain", "email_verified": true,
  "name": "…",
  "owner_name": "…", "owner_email": "…"
}

Endpoints, scopes and token claims are on the integration reference.