Docs · Guides

Sign in with Agentboxd

An OpenID Connect provider where an AI agent’s identity is its inbox: agents sign in to your app with one API call, no password or verification email.

Agentboxd is an OpenID Connect provider. An AI agent’s identity is its Agentboxd inbox, so an app that accepts Sign in with Agentboxd lets agents sign in with the inbox they already have, instead of creating a password account and clicking a verification email. The tokens are ordinary OpenID Connect ID tokens, so any standards-compliant library can check them.

#Which flow to use

You haveFlow
An API or CLI that agents call directlyHeadless: the agent calls POST /v1/inboxes/:id/identity-token for your client_id and sends you the ID token. You verify it against the public keys and keep a replay cache of jti.
The same, but you want the issuer to enforce single use, or you already run an OAuth clientHeadless + token exchange: post the agent’s token to {issuer}/token with the JWT bearer grant (RFC 7523). You get a fresh ID token and an access token; a replayed token gets invalid_grant.
A web app with a "Sign in with Agentboxd" buttonAuthorization code + PKCE: the agent’s owner approves in the browser and picks which inbox signs in. state, nonce and S256 PKCE are required.

Every token lives at most 5 minutes and there are no refresh tokens: an agent signs in again with one API call. Start your own session once you have verified who the agent is.

#Issuer and endpoints

The issuer is https://id.agentboxd.com. Point your library at the discovery document and it finds the rest. Signing is ES256 only, with keys that rotate: verifiers re-fetch the key set when they see an unknown kid, which every JOSE library does.

EndpointPurpose
GET {issuer}/.well-known/openid-configurationDiscovery document (also at {issuer}/.well-known/oauth-authorization-server).
GET {issuer}/.well-known/jwks.jsonPublic signing keys (JWKS).
GET {issuer}/authorize · POST {issuer}/authorizeBrowser sign-in: response_type=code, exact redirect_uri, scope with openid, state, nonce, code_challenge + code_challenge_method=S256.
POST {issuer}/tokenauthorization_code and urn:ietf:params:oauth:grant-type:jwt-bearer. Client authentication client_secret_basic, client_secret_post or none (public clients).
GET {issuer}/userinfo · POST {issuer}/userinfoClaims for a Bearer access token.
POST {issuer}/revokeRevokes an access token (RFC 7009). Always 200.

#Register your app

In the dashboard open Identity and register the app, or call POST /v1/identity/clients with a key that has identity:manage. Clients belong to the workspace, so inbox-scoped keys can’t manage them.

TypeFor
confidentialServer-side apps. Gets a client_secret, shown once (we keep a hash). Browser sign-in and token exchange.
publicSingle-page and native apps. No secret; browser sign-in with PKCE, and token exchange (the assertion is the proof).
verify_onlyHeadless only. No secret and no redirect URIs: agents send you tokens and you verify them yourself.
  • redirect_uris: 1 to 10 for confidential and public, none for verify_only. Absolute https URLs, or http on localhost, 127.0.0.1 or [::1] for development. No fragments, no user info, no wildcards. Matched exactly.
  • allowed_scopes: from openid, email, profile, workspace; openid is required. Default openid email profile.
  • subject_type: pairwise (default) or public. It and type can’t change after registration.
  • Your client_id is public: it is the aud of every token agents present to you.

#Headless sign-in

The agent needs an API key with identity:sign; the Sign in only preset (sign_in) is inboxes:read plus identity:sign and nothing else. An inbox-scoped key can only sign in as its own inbox.

agent.ts
import { Agentboxd } from 'agentboxd';

const mr = new Agentboxd(); // a key with identity:sign (preset "sign_in")

// A 5-minute, single-use ID token addressed to one app (its client_id).
const { id_token } = await mr.identity.token({
  inboxId: inbox.id,
  audience: 'abxc_4kQ9...',
  nonce, // optional: the one the app gave you
});

await fetch('https://app.example.com/login/agent', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ id_token }),
});
agent.py
from agentboxd import Agentboxd

mr = Agentboxd()  # a key with identity:sign

tok = mr.identity.token(inbox_id, audience="abxc_4kQ9...", nonce=nonce)
requests.post("https://app.example.com/login/agent", json={"id_token": tok["id_token"]})
curl
curl -X POST https://api.agentboxd.com/v1/inboxes/$INBOX_ID/identity-token \
  -H "Authorization: Bearer $AGENTBOXD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "audience": "abxc_4kQ9...", "nonce": "n-3f9a2c" }'
201 Created
{
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjYwOTI1LWFiY2QiLCJ0eXAiOiJKV1QifQ...",
  "token_type": "id_token",
  "issuer": "https://id.agentboxd.com",
  "audience": "abxc_4kQ9...",
  "sub": "Qm9vZ2xlLXBhaXJ3aXNlLXN1YmplY3QtZXhhbXBsZQ",
  "jti": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
  "scope": "openid email",
  "expires_in": 300,
  "expires_at": "2026-09-25T09:19:03.000Z"
}
  • audience (required): the app’s client_id. An unknown or disabled app answers 400 unknown_audience.
  • scope: space-separated, default openid email. Scopes outside the app’s allowed_scopes answer 400 invalid_scope. workspace is only granted when the workspace shares its name (Settings); otherwise it is dropped.
  • expires_in: 30 to 300 seconds, default 300. nonce: up to 256 characters, echoed in the token.
  • 403 identity_disabled when the inbox’s sign-in is turned off, 403 identity_unavailable for temporary inboxes, 403 org_suspended for suspended workspaces, 429 rate_limited above 30 tokens per inbox per minute.

#Verify the token

The ID token is a JWT signed with ES256. Its claims:

ID token payload
{
  "iss": "https://id.agentboxd.com",
  "sub": "Qm9vZ2xlLXBhaXJ3aXNlLXN1YmplY3QtZXhhbXBsZQ",
  "aud": "abxc_4kQ9...",
  "iat": 1790327643,
  "exp": 1790327943,
  "auth_time": 1790327643,
  "jti": "3f2e1d0c-9b8a-4765-8432-10fedcba9876",
  "nonce": "n-3f9a2c",
  "email": "support-bot@agents.agentboxd.com",
  "email_verified": true,
  "https://agentboxd.com/claims/agent": true
}
ClaimValue
issThe issuer, exactly.
subPairwise by default: stable for your app, different at every other app. Public subjects are the inbox id.
audYour client_id (a single string).
iat · exp · auth_timeexp - iat is at most 300 seconds.
jtiUnique per token: the key of your replay cache.
nonceEchoed when the agent passed one (always in the browser flow).
email · email_verifiedScope email: the inbox address, always verified (we control delivery to it).
nameScope profile: the inbox display name, else the address local part.
https://agentboxd.com/claims/agentAlways true: the subject is an AI agent’s inbox, not a person.
https://agentboxd.com/claims/workspaceScope workspace, when the workspace opted in: { id, name }.

To accept a token: check the signature against the JWKS (ES256 only), iss, aud equal to your client_id, exp in the future and iat not in the future with at most 30 seconds of clock tolerance, the nonce if you gave one, and that you haven’t seen its jti before. The TypeScript SDK does all of it:

login.ts
import { MemoryReplayCache, verifyAgentIdentityToken } from 'agentboxd/identity'; // npm install agentboxd jose

const replayCache = new MemoryReplayCache(); // several processes: back it with Redis (SET jti 1 NX EXAT exp)

export async function signInAgent(idToken: string, nonce?: string) {
  const agent = await verifyAgentIdentityToken(idToken, {
    audience: process.env.AGENTBOXD_CLIENT_ID!, // your client_id
    nonce,        // if you handed the agent one
    replayCache,  // single use: a second presentation of the same jti throws
  });
  // agent.sub is stable for your app and different at every other app: key the user on it.
  return { sub: agent.sub, email: agent.email, isAgent: agent.isAgent };
}

Or let the issuer enforce single use

Exchange the agent’s token at the token endpoint with the JWT bearer grant. The issuer checks that the token is addressed to the authenticated client, unexpired and unused, and that the inbox’s sign-in is still on. It records the jti, so a second exchange fails. You get a fresh ID token (new jti, no nonce) and a 5-minute access token for {issuer}/userinfo.

curl
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 sign-in

For a "Sign in with Agentboxd" button, configure Agentboxd as an OpenID Connect provider in your auth library with the discovery URL, your client_id and client_secret, and the scopes you need. The person is sent to agentboxd.com, signs in to their dashboard if needed, sees your app’s name, its redirect host and the scopes, picks which inbox identity to use, and approves. There is no remembered consent: every sign-in is approved.

  • Required on {issuer}/authorize: state, nonce, and a PKCE code_challenge with S256. prompt=none answers login_required.
  • An unknown client or an unregistered redirect_uri shows an error page and never redirects. Other errors redirect with error, state and iss.
  • Codes live 60 seconds, work once, and are bound to the client, the redirect URI and the PKCE challenge.
  • The authorization response carries iss (RFC 9207); check it if your library supports it.

Working setups for Better Auth and Auth.js are in the Sign in with Agentboxd guide.

#Per-inbox control and history

Every inbox has a Sign-in tab in the dashboard: turn its identity off, see the apps it signed in to and every token and sign-in. Turning it off immediately refuses new tokens, browser approvals, token exchanges and /userinfo for that inbox; tokens already issued expire within 5 minutes.

Method & pathNotes
GET /v1/inboxes/:id/identity{ inbox_id, enabled, apps: [{ client_id, name, homepage_url, last_signed_in_at, sign_ins }] }. Needs inboxes:read.
PATCH /v1/inboxes/:id/identity{ enabled }. Needs identity:manage.
GET /v1/inboxes/:id/identity/sign-insPaginated sign-in log: client_id, client_name, event (token_issued or signed_in), flow (headless, authorization_code, jwt_bearer), jti, ip, created_at. Kept 180 days.

Two webhook events record every sign-in: identity.token_issued when a headless token is minted and identity.signed_in when an app redeems a code or a token at {issuer}/token. The payload is { inbox, client: { client_id, name }, flow, jti, sub, expires_at }.

#From an MCP client

The MCP server’s get_identity_token tool takes inbox_id, audience and an optional nonce and returns the token, so an agent in Claude Desktop or Cursor can sign itself in to an app that asks for one.

#Security notes

  • Key users on `sub`, not `email`. Pairwise subjects stop apps from matching agents across services. The email claim is the same everywhere, so don’t ask for it if you don’t need it.
  • Single use. Keep each accepted jti until its exp, or use the token exchange, which enforces it for you. Bind tokens to a login attempt with a nonce.
  • Short-lived by design. 5 minutes at most, no refresh tokens. Your own session decides how long the agent stays signed in.
  • Agents are agents. Use https://agentboxd.com/claims/agent to route agents to API-first onboarding or different limits, not to trust them more.
  • Keep secrets server-side. Never ship a client_secret in a browser or mobile app; register a public client there.