TypeScript · OpenID Connect
Add Sign in with Agentboxd to your app
Agents that use your product today sign up with a password and click a verification email. With Sign in with Agentboxd they prove who they are with the inbox they already have: one API call on the agent’s side, one token check on yours.
- Written against
- agentboxd 0.1 · jose 6.2.12 · better-auth 1.7.6 · @auth/core 0.41.3 · Node.js 22+
- Checked
- 25 September 2026
- Runnable example
- examples/identity
Agentboxd runs an OpenID Connect provider at https://id.agentboxd.com where the subject is an AI agent’s inbox. This guide adds it to an app three ways: a headless sign-in for agents that call your API directly, the same token exchanged at the issuer so it enforces single use, and a "Sign in with Agentboxd" button through Better Auth or Auth.js. Every snippet is in examples/identity/ in the Agentboxd repository.
#Register your app
Open Identity in the dashboard and register the app. Pick Server app (confidential) if you have a backend and want the button, Public app for a single-page or native app, or Verify tokens only if agents only ever send you tokens. You get a client_id (it is the aud of every token) and, for server apps, a client_secret shown once.
- Redirect URIs are matched exactly. Better Auth uses
{BETTER_AUTH_URL}/api/auth/callback/agentboxd; Auth.js uses/api/auth/callback/agentboxdin Next.js and/auth/callback/agentboxdelsewhere.http://localhostworks for development. - Leave the subject type on pairwise: each inbox gets a
subthat is stable for your app and different at every other app. - The agent needs an API key with
identity:sign. The Sign in only preset has just that andinboxes:read.
#Headless: the agent gets a token
The agent asks Agentboxd for an ID token addressed to your client_id and sends it to you. It lives at most 5 minutes and works once. If you hand out a nonce first, the token is also bound to that login attempt.
import { Agentboxd } from 'agentboxd';
const RP_URL = 'https://app.example.com';
const mr = new Agentboxd(); // AGENTBOXD_API_KEY: a key with identity:sign (preset "sign_in")
// Optional: ask the app for a nonce, so the token is bound to this login attempt.
const { nonce } = await (await fetch(`${RP_URL}/login/nonce`)).json();
// A 5-minute, single-use ID token addressed to this app only.
const { id_token } = await mr.identity.token({
inboxId: process.env.INBOX_ID!,
audience: process.env.AGENTBOXD_CLIENT_ID!, // the app's client_id
nonce,
});
const res = await fetch(`${RP_URL}/login/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token, nonce }),
});From Python it is mr.identity.token(inbox_id, audience=client_id, nonce=nonce), and MCP clients have the get_identity_token tool.
#Headless: verify it
verifyAgentIdentityToken fetches the issuer’s keys (and re-fetches them when they rotate), checks the ES256 signature, iss, aud, exp and iat with 30 seconds of tolerance, the nonce and the agent claim, and records the jti in your replay cache so the same token can’t be used twice. It needs jose next to agentboxd: npm install agentboxd jose.
import { AgentIdentityError, MemoryReplayCache, verifyAgentIdentityToken } from 'agentboxd/identity';
const CLIENT_ID = process.env.AGENTBOXD_CLIENT_ID!;
/** One process: in memory. Several processes: back it with Redis (SET jti 1 NX EXAT exp). */
const replayCache = new MemoryReplayCache();
export async function verifyAgent(idToken: string, nonce?: string) {
try {
const agent = await verifyAgentIdentityToken(idToken, {
audience: CLIENT_ID,
...(nonce ? { nonce } : {}),
replayCache, // a second presentation of the same jti is rejected
});
// agent.sub is pairwise: stable for this app, different at every other app.
return { sub: agent.sub, email: agent.email, name: agent.name, isAgent: agent.isAgent };
} catch (err) {
if (err instanceof AgentIdentityError) throw new Error(`sign-in refused: ${err.code}`); // token_replayed, token_expired, ...
throw err;
}
}Find or create the user by sub, then start your own session. How long it lasts is up to you: Agentboxd issues no refresh tokens, and the agent can sign in again with one call whenever you ask.
Let the issuer enforce single use instead
If you run several processes without a shared cache, or already use an OAuth client, exchange the agent’s token at the token endpoint with the JWT bearer grant (RFC 7523). The issuer checks it is addressed to you, unexpired, unused and that the inbox’s sign-in is still on, then returns a fresh ID token and a 5-minute access token for /userinfo. A replay gets invalid_grant.
const ISSUER = 'https://id.agentboxd.com';
const basic = Buffer.from(`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`).toString('base64');
const res = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: idToken }),
});
const tokens = await res.json();
// invalid_grant: expired, wrong audience, sign-in turned off for the inbox, or already used.
if (!res.ok) throw new Error(tokens.error);
// A fresh ID token (new jti, no nonce): verify it like any other.
const agent = await verifyAgentIdentityToken(tokens.id_token, { audience: CLIENT_ID });curl -X POST https://id.agentboxd.com/token \
-u "$AGENTBOXD_CLIENT_ID:$AGENTBOXD_CLIENT_SECRET" \
-d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
-d assertion="$ID_TOKEN"
# 200 { "access_token": "abxat_...", "token_type": "Bearer", "expires_in": 300,
# "id_token": "eyJ...", "scope": "openid email" }
# A second exchange of the same token: 400 { "error": "invalid_grant" }#Browser button with Better Auth
For a button that a person clicks (the agent’s owner), use the authorization code flow. Better Auth’s Generic OAuth plugin reads the discovery document, sends state, a PKCE challenge and a nonce, and verifies the ID token. The person approves on agentboxd.com and picks which of their inboxes signs in.
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';
const ISSUER = process.env.AGENTBOXD_ISSUER ?? 'https://id.agentboxd.com';
export const auth = betterAuth({
// database: your adapter
user: {
additionalFields: { isAgent: { type: 'boolean', required: false, defaultValue: false, input: false } },
},
plugins: [
genericOAuth({
config: [
{
providerId: 'agentboxd',
name: 'Agentboxd',
discoveryUrl: `${ISSUER}/.well-known/openid-configuration`,
clientId: process.env.AGENTBOXD_CLIENT_ID!,
clientSecret: process.env.AGENTBOXD_CLIENT_SECRET!,
scopes: ['openid', 'email', 'profile'],
pkce: true,
requireIdTokenVerification: true,
mapProfileToUser: (profile) => ({
name: typeof profile.name === 'string' ? profile.name : undefined,
email: typeof profile.email === 'string' ? profile.email : null,
emailVerified: profile.email_verified === true,
isAgent: profile['https://agentboxd.com/claims/agent'] === true,
}),
},
],
}),
],
});
// In the browser:
// await authClient.signIn.social({ provider: 'agentboxd', callbackURL: '/dashboard' });#Browser button with Auth.js
Auth.js (NextAuth.js v5 and the other @auth/* packages) takes a small OIDC provider. Agentboxd requires PKCE, state and a nonce, so list all three in checks.
import type { OIDCConfig, OIDCUserConfig } from '@auth/core/providers';
export interface AgentboxdProfile extends Record<string, unknown> {
sub: string;
email?: string;
email_verified?: boolean;
name?: string;
'https://agentboxd.com/claims/agent': true;
}
export default function Agentboxd(options: OIDCUserConfig<AgentboxdProfile>): OIDCConfig<AgentboxdProfile> {
return {
id: 'agentboxd',
name: 'Agentboxd',
type: 'oidc',
issuer: 'https://id.agentboxd.com',
checks: ['pkce', 'state', 'nonce'], // Agentboxd requires all three
authorization: { params: { scope: 'openid email profile' } },
profile: (p) => ({ id: p.sub, name: p.name ?? p.email ?? null, email: p.email ?? null, image: null }),
options,
};
}// auth.ts (Next.js, next-auth v5)
import NextAuth from 'next-auth';
import Agentboxd from './agentboxd-provider';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Agentboxd({ clientId: process.env.AUTH_AGENTBOXD_ID, clientSecret: process.env.AUTH_AGENTBOXD_SECRET })],
callbacks: {
jwt({ token, profile }) {
if (profile) token.isAgent = profile['https://agentboxd.com/claims/agent'] === true;
return token;
},
},
});#Security checklist
- Key users on `sub`, not on
email. The email claim is the inbox address and the same everywhere; don’t request it if you don’t need it. - Enforce single use: a replay cache of
jtiuntilexp, or the token exchange. - Check `aud` and `iss` exactly and accept ES256 only. The SDK helper does both.
- Treat agents as agents.
https://agentboxd.com/claims/agentis alwaystrue; use it for API-first onboarding or different limits, not for more trust. - Keep the client secret on the server. Browsers and mobile apps get a public client.
- The inbox owner can turn sign-in off for an inbox at any time. New tokens, exchanges and
/userinfostop at once; issued tokens expire within 5 minutes.
#Next steps
- Sign in with Agentboxd reference: endpoints, claims, errors and the per-inbox sign-in log.
- Webhooks:
identity.token_issuedandidentity.signed_inrecord every sign-in. - TypeScript SDK:
mr.identity.*andverifyAgentIdentityToken.