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 have | Flow |
|---|---|
| An API or CLI that agents call directly | Headless: 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 client | Headless + 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" button | Authorization 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.
| Endpoint | Purpose |
|---|---|
GET {issuer}/.well-known/openid-configuration | Discovery document (also at {issuer}/.well-known/oauth-authorization-server). |
GET {issuer}/.well-known/jwks.json | Public signing keys (JWKS). |
GET {issuer}/authorize · POST {issuer}/authorize | Browser sign-in: response_type=code, exact redirect_uri, scope with openid, state, nonce, code_challenge + code_challenge_method=S256. |
POST {issuer}/token | authorization_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}/userinfo | Claims for a Bearer access token. |
POST {issuer}/revoke | Revokes 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.
| Type | For |
|---|---|
confidential | Server-side apps. Gets a client_secret, shown once (we keep a hash). Browser sign-in and token exchange. |
public | Single-page and native apps. No secret; browser sign-in with PKCE, and token exchange (the assertion is the proof). |
verify_only | Headless only. No secret and no redirect URIs: agents send you tokens and you verify them yourself. |
redirect_uris: 1 to 10 forconfidentialandpublic, none forverify_only. AbsolutehttpsURLs, orhttponlocalhost,127.0.0.1or[::1]for development. No fragments, no user info, no wildcards. Matched exactly.allowed_scopes: fromopenid,email,profile,workspace;openidis required. Defaultopenid email profile.subject_type:pairwise(default) orpublic. It andtypecan’t change after registration.- Your
client_idis public: it is theaudof 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.
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 }),
});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 -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" }'{
"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’sclient_id. An unknown or disabled app answers400 unknown_audience.scope: space-separated, defaultopenid email. Scopes outside the app’sallowed_scopesanswer400 invalid_scope.workspaceis 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_disabledwhen the inbox’s sign-in is turned off,403 identity_unavailablefor temporary inboxes,403 org_suspendedfor suspended workspaces,429 rate_limitedabove 30 tokens per inbox per minute.
#Verify the token
The ID token is a JWT signed with ES256. Its claims:
{
"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
}| Claim | Value |
|---|---|
iss | The issuer, exactly. |
sub | Pairwise by default: stable for your app, different at every other app. Public subjects are the inbox id. |
aud | Your client_id (a single string). |
iat · exp · auth_time | exp - iat is at most 300 seconds. |
jti | Unique per token: the key of your replay cache. |
nonce | Echoed when the agent passed one (always in the browser flow). |
email · email_verified | Scope email: the inbox address, always verified (we control delivery to it). |
name | Scope profile: the inbox display name, else the address local part. |
https://agentboxd.com/claims/agent | Always true: the subject is an AI agent’s inbox, not a person. |
https://agentboxd.com/claims/workspace | Scope 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:
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 -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 PKCEcode_challengewithS256.prompt=noneanswerslogin_required. - An unknown client or an unregistered
redirect_urishows an error page and never redirects. Other errors redirect witherror,stateandiss. - 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 & path | Notes |
|---|---|
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-ins | Paginated 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
emailclaim is the same everywhere, so don’t ask for it if you don’t need it. - Single use. Keep each accepted
jtiuntil itsexp, 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/agentto route agents to API-first onboarding or different limits, not to trust them more. - Keep secrets server-side. Never ship a
client_secretin a browser or mobile app; register apublicclient there.