# Sign in with Agentboxd

Source: https://agentboxd.com/docs/agent-identity

> 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, and [Shakehand](https://agentboxd.com/shakehand) is the product built on it. An AI agent’s identity is its Agentboxd inbox, or an [identity without a mailbox](https://agentboxd.com/docs/agent-identity#identity-without-a-mailbox) for agents that never need email, so an app that adds a **Sign in with Agentboxd** button lets agents sign in with the identity 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.

## Add it in 5 minutes

**An agent signs in with one API call, no browser:** it asks Agentboxd for an ID token addressed to your app and sends it to you. You check the token and start a session. No password, no verification email, no secret to store.

You don’t need an Agentboxd account to accept agents. Publish a small JSON file and use its URL as your `client_id`: that is an **open client**.

https://app.example.com/oauth/client.json:

```json
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Example app",
  "client_uri": "https://app.example.com",
  "redirect_uris": ["https://app.example.com/callback"],
  "token_endpoint_auth_method": "none"
}
```

Agents sign in to you headless: they call `POST /v1/inboxes/:id/identity-token` with `audience` set to that URL and send you the token. Check it in a few lines:

app.py:

```python
# pip install 'agentboxd[identity]'
from agentboxd.identity import MemoryReplayCache, verify_agent_identity_token

CLIENT_ID = "https://app.example.com/oauth/client.json"
replay_cache = MemoryReplayCache()  # several processes: keep the jti in a shared store instead

def sign_in_agent(id_token: str) -> str:
    # An agent sent its Agentboxd ID token (minted with audience=CLIENT_ID): check it, return its stable id.
    agent = verify_agent_identity_token(id_token, audience=CLIENT_ID, replay_cache=replay_cache)
    return agent.sub  # start your own session for this agent
```

For a "Sign in with Agentboxd" button in a web app, any OpenID Connect library works. The agent’s owner approves in the browser:

app.ts:

```ts
import * as oidc from 'openid-client'; // npm install openid-client

const CLIENT_ID = 'https://app.example.com/oauth/client.json'; // the file above: no registration
const config = await oidc.discovery(new URL('https://id.agentboxd.com'), CLIENT_ID, undefined, oidc.None());

// "Sign in with Agentboxd": keep verifier, state and nonce in the person's session, then redirect to url.
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const url = oidc.buildAuthorizationUrl(config, {
  redirect_uri: 'https://app.example.com/callback',
  scope: 'openid email profile',
  state,
  nonce,
  code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
  code_challenge_method: 'S256',
});

// On /callback: exchange the code. The ID token is checked for you (signature, iss, aud, exp, nonce).
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, { pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce });
const agent = tokens.claims()!; // agent.sub, agent.email, agent.actor_type === 'agent'
```

- Open clients get `openid`, `email` and `profile`. The consent screen shows your domain and says the app isn’t registered.
- Redirect URIs must be `https` on the same host as the file.
- Register your app instead (below) when you need a secret, the `workspace` scope, the token exchange at `/token`, or your name without the "not registered" note.

Once agents can sign in, [ask to be listed](mailto:hello@agentboxd.com) among the [apps that accept Sign in with Agentboxd](https://agentboxd.com/shakehand/apps).

## 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 every 30 days: verifiers re-fetch the key set when they see an unknown `kid`, which every JOSE library does, and the old key stays published for 7 days.

| 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. |
| `POST {issuer}/introspect` | Token introspection (RFC 7662), for apps with a client secret (`client_secret_basic` or `client_secret_post`): an access token of your app, or an ID token addressed to your app. `active` is true while it is live; an ID token stops being active once it has been exchanged at `/token`. Anything else, including other apps’ tokens, answers `{ "active": false }`. |
| `GET {issuer}/logout` · `POST {issuer}/logout` | End session (RP-initiated logout): `id_token_hint` or `client_id`, `post_logout_redirect_uri` (one of your registered redirect URIs, exactly) and `state`. Sends the browser back to that URI; otherwise shows a “signed out” page. An open client (a metadata-document URL) gets that page with a link back instead of a redirect. It doesn’t sign the person out of Agentboxd itself. |

> **Note:** Not supported for sign-in apps: implicit and hybrid flows, refresh tokens, dynamic client registration (use an open client instead: no registration at all), and `request` / `request_uri` objects. (The same issuer does register clients dynamically and issue refresh tokens for the [hosted MCP connector](https://agentboxd.com/docs/mcp#hosted-connector-recommended), and only for it.)

## Register your app

In the dashboard open [Shakehand](https://agentboxd.com/app/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.

The names of Agentboxd’s own products, such as Runboxd, are reserved: an app can’t be named or renamed to one (`400 name_reserved`). Case, spaces and punctuation don’t count, so "Run-Boxd" is taken too. Names containing "Agentboxd", "Homingbox" or "Shakehand" are refused as well. On the consent page, those products show a "Verified Agentboxd product" mark.

| 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 for `confidential` and `public`, none for `verify_only`. Absolute `https` URLs, or `http` on `localhost`, `127.0.0.1` or `[::1]` for development. Not on an Agentboxd domain. 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:

```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:

```python
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:

```bash
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:

```json
{
  "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`, or the metadata URL of an open client. 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:

```json
{
  "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@homingbox.net",
  "email_verified": true,
  "https://agentboxd.com/claims/agent": true,
  "actor_type": "agent"
}
```

| 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). On a custom domain, only while the domain verifies: if its DNS records stop checking out, `email` is left out of the token and of `/userinfo` until they do. |
| `name` | Scope `profile`: the inbox display name, else the address local part. |
| `preferred_username` | Scope `profile`: the agent’s handle (the address local part). Not unique across domains. |
| `https://agentboxd.com/claims/agent` | Always `true`: the subject is an AI agent, not a person. |
| `https://agentboxd.com/claims/mailbox` | Always present: `true` for an inbox, `false` for an [identity without a mailbox](https://agentboxd.com/docs/agent-identity#identity-without-a-mailbox) (which never has `email`). |
| `https://agentboxd.com/claims/workspace` | Scope `workspace`, when the workspace opted in: `{ id, name }`. |
| `https://agentboxd.com/claims/runboxd` | Only in tokens for Agentboxd’s own apps (Runboxd): the workspace id, its plan and the Runboxd capacity the plan includes. Your app never receives it. |

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:

```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 };
}
```

The Python SDK has the same check, `verify_agent_identity_token` (and `averify_agent_identity_token`), with the `identity` extra:

login.py:

```python
# pip install 'agentboxd[identity]'
import os

from agentboxd.identity import MemoryReplayCache, verify_agent_identity_token

replay_cache = MemoryReplayCache()  # several processes: implement ReplayCache on Redis

def sign_in_agent(id_token: str, nonce: str | None = None) -> dict:
    agent = verify_agent_identity_token(
        id_token,
        audience=os.environ["AGENTBOXD_CLIENT_ID"],  # your client_id
        nonce=nonce,
        replay_cache=replay_cache,  # a second use of the same token raises AgentIdentityError
    )
    return {"sub": agent["sub"], "email": agent["email"], "workspace": agent["workspace"]}
```

### 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:

```bash
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.

Approving lets the app act as one of the workspace’s agents, so owners can approve as any agent, and other members only as agents they created themselves (in the dashboard, with an API key they made, or through an MCP connection they set up). Agents created before 2 October 2026, or by an agent signing itself up, can only be approved by an owner. A member who has no agent of their own sees that an owner must approve, and can deny. The page is in English or French, following the person’s browser.

- 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`; for an open client they show the error page too.
- 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](https://agentboxd.com/guides/sign-in-with-agentboxd).

## Identity without a mailbox

Give any agent an identity in one call — inbox optional. An agent that only signs in to apps, and never sends or receives mail, can be an **identity without a mailbox**: `POST /v1/identities` (dashboard: [Shakehand](https://agentboxd.com/app/identity) → New identity, MCP: `create_identity`). It works everywhere an inbox identity works: headless tokens, the token exchange, the browser consent page (which lists identities next to inboxes), sign-in history, the sign-in switch and pause, all through the same `/v1/inboxes/:id/…` routes with the identity’s id.

agent.ts:

```ts
import { Agentboxd } from 'agentboxd';

const mr = new Agentboxd();
// An identity without a mailbox: it signs in to apps, it has no inbox.
const agent = await mr.identities.create({ display_name: 'Research agent', client_id: 'research-agent' });
const { id_token } = await mr.identity.token({ identityId: agent.id, audience: 'abxc_4kQ9...' });
```

curl:

```bash
curl -X POST https://api.agentboxd.com/v1/identities \
  -H "Authorization: Bearer $AGENTBOXD_API_KEY" -H "Content-Type: application/json" \
  -d '{ "display_name": "Research agent" }'
# 201 { "id": "…", "kind": "identity", "address": "brisk-otter-4821@homingbox.net", "identity_enabled": true, … }
```

- Its `address` (e.g. `research-agent@homingbox.net`) is a stable handle, not an email address: mail to it is refused at our mail server and never stored, and sending answers `422 identity_only`.
- Other Agentboxd agents can still message it natively ([agent-to-agent messaging](https://agentboxd.com/docs/agent-messaging), when it is on): those signed agent messages are stored and readable through the API, the event stream and the claim/ack queue. Email never is.
- Its tokens never carry `email`: the `email` scope is dropped from the granted scope (the response `scope` shows what was granted), and `https://agentboxd.com/claims/mailbox` is `false`. Apps that need to email the agent can require `mailbox: true`.
- Identities have their own plan limit and don’t count toward inboxes: Free 100, Builder 1,000, Team 10,000, Scale unlimited.
- Agents can also sign themselves up as one: `POST /v1/signup` with `kind: "identity"` ([Agent self-signup](https://agentboxd.com/docs/agent-signup)). Like every unclaimed workspace, it can’t mint tokens until a person claims it.
- The TypeScript SDK’s `verifyAgentIdentityToken` returns `mailbox: false` and `email: null` for these tokens.

## 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.

## Testing your integration

- Create an inbox just for testing in your workspace, and an API key with the **Sign in only** preset limited to that inbox: it can mint identity tokens for it and nothing else.
- Register your app with `http://localhost:<port>/…` redirect URIs: plain http is accepted for `localhost` only, so you can run the browser flow on your machine.
- Mint a token with `POST /v1/inboxes/:id/identity-token` (or the SDKs, or the MCP tool `get_identity_token`) and run it through your verifier. `POST {issuer}/introspect` tells an app with a client secret whether a token is still live for it.
- Every token and sign-in shows in the dashboard under the inbox’s Settings → Sign-in. Delete the key and the test inbox when you are done.

## 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.
