Blog · Guides
How does an AI agent prove who it is when it signs in to your app?
Agents now sign up to apps the way people do: a form, a password, a verification email. A signed identity token does the same job in one API call, and you can check it with standard tools.
- Published
- By
- Agentboxd team
- Reading time
- 7 min
#Why does an agent need to prove who it is?
More apps now get visits from agents, not only from people: agents that read docs, call APIs, open accounts and come back the next day. For each one, your app has to answer the same question it asks a person: who is this, and is it the same one as last time?
Today agents usually answer it the way people do. They fill in a sign-up form, choose a password, wait for a verification email and click the link. It works, but every step was designed for a person. The password has to be stored somewhere the agent can read it. The email loop needs an inbox and a parser. And the app still can’t tell that it is talking to an agent, so it can’t route it to an API-first onboarding or give it its own limits.
What the agent needs is a way to show a signed statement: “I am this agent, and this proof is for your app, right now.” That is what an identity token is.
#What is Sign in with Agentboxd?
Agentboxd runs an OpenID Connect provider at https://id.agentboxd.com. OpenID Connect is the same standard behind most “Sign in with …” buttons. The difference is the subject: here it is an AI agent, and its identity is its Agentboxd inbox. An agent that doesn’t need email can have an identity without a mailbox instead.
The tokens are ordinary OpenID Connect ID tokens, signed with ES256. Any standards-compliant library can check them, and nothing about them is specific to one framework.
| Password and verification email | An API key you issue | Sign in with Agentboxd | |
|---|---|---|---|
| What the agent keeps | A password for your app | A key for your app | Nothing for your app |
| What a leak exposes | That account, until changed | That account, until revoked | One token, for at most 5 minutes |
| You know it is an agent | No | Only if you track it | Yes, a claim in every token |
| Same agent next time | If it remembers the password | If it keeps the key | Yes, a stable sub for your app |
| Setup for you | A sign-up flow and email | Key management | Register a client, verify tokens |
#How does the sign-in work?
There are two ways in. A web app can add a button that the agent’s owner clicks and approves in the browser. Most agents never see a browser, though, so this article covers the headless flow, where the agent signs in with one API call.
1. You register your app in the dashboard under Identity, or with POST /v1/identity/clients. You get a client_id. It is public: it is the audience of every token agents send you. 2. You give the agent a nonce, a random value for this one login attempt. Optional, but it ties the token to the attempt. 3. The agent asks for a token for your client_id, with the nonce, and sends it to you. 4. You verify the token and start your own session.
Step 3, from the agent’s side:
import { Agentboxd } from 'agentboxd';
const mr = new Agentboxd(); // a key with identity:sign, such as the "Sign in only" preset
const { nonce } = await fetch('https://app.example.com/login/agent/nonce').then((r) => r.json());
// A single-use ID token addressed to one app, valid for at most 5 minutes.
const { id_token } = await mr.identity.token({
inboxId: process.env.AGENT_INBOX_ID!,
audience: 'abxc_4kQ9...', // the app's client_id
nonce,
});
await fetch('https://app.example.com/login/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token, nonce }),
});import os
import requests
from agentboxd import Agentboxd
mr = Agentboxd() # a key with identity:sign
nonce = requests.get("https://app.example.com/login/agent/nonce").json()["nonce"]
tok = mr.identity.token(os.environ["AGENT_INBOX_ID"], audience="abxc_4kQ9...", nonce=nonce)
requests.post("https://app.example.com/login/agent", json={"id_token": tok["id_token"], "nonce": nonce})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" }'An agent working in Claude Desktop or Cursor can do the same through the MCP server’s get_identity_token tool.
#How does your app verify the token?
Your app checks six things before it trusts a token:
- The signature, against the public keys at
https://id.agentboxd.com/.well-known/jwks.json. ES256 only. - The issuer (
iss) is exactlyhttps://id.agentboxd.com. - The audience (
aud) is yourclient_id. A token minted for another app must not open yours. - The time.
expis in the future andiatis not, with at most 30 seconds of clock tolerance. - The nonce, if you gave one, matches the one for this login attempt.
- Single use. You haven’t seen this token’s
jtibefore. Keep each acceptedjtiuntil itsexp.
The TypeScript SDK does all six in one call:
import { MemoryReplayCache, verifyAgentIdentityToken } from 'agentboxd/identity'; // npm install agentboxd jose
const replayCache = new MemoryReplayCache(); // several processes: keep the jti in Redis instead
export async function signInAgent(idToken: string, nonce?: string) {
const agent = await verifyAgentIdentityToken(idToken, {
audience: process.env.AGENTBOXD_CLIENT_ID!, // your client_id
nonce,
replayCache, // a second use of the same token throws
});
// agent.sub is stable for your app: key your user record on it.
return { sub: agent.sub, email: agent.email, mailbox: agent.mailbox };
}In Python, any JWT library that reads a key set works. With PyJWT:
import os
import jwt # pip install "pyjwt[crypto]"
ISSUER = "https://id.agentboxd.com"
AGENT_CLAIM = "https://agentboxd.com/claims/agent"
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
seen: dict[str, int] = {} # jti -> exp; several processes: keep it in Redis instead
def sign_in_agent(id_token: str, nonce: str | None = None) -> dict:
key = jwks.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
key.key,
algorithms=["ES256"],
audience=os.environ["AGENTBOXD_CLIENT_ID"],
issuer=ISSUER,
leeway=30,
options={"require": ["exp", "iat", "jti", "sub"]},
)
if nonce is not None and claims.get("nonce") != nonce:
raise ValueError("nonce mismatch")
if claims.get(AGENT_CLAIM) is not True:
raise ValueError("not an agent identity")
if claims["jti"] in seen:
raise ValueError("token already used")
seen[claims["jti"]] = claims["exp"]
return {"sub": claims["sub"], "email": claims.get("email")}If you would rather not keep a replay cache, let the issuer do it. Exchange the agent’s token at the token endpoint with your client secret. The issuer checks that the token is addressed to you, unexpired and unused, and that the agent’s sign-in is still on. A second exchange of the same token fails:
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" }
# The same token again: 400 { "error": "invalid_grant" }#What does your app learn about the agent?
Only what you ask for, through the scopes you register:
| Claim | What it tells you |
|---|---|
sub | A stable id for this agent at your app. By default it is different at every other app, so apps can’t match agents across services. Key your user record on it. |
https://agentboxd.com/claims/agent | Always true: the subject is an AI agent, not a person. |
https://agentboxd.com/claims/mailbox | true for an inbox, false for an identity without a mailbox. |
email | Scope email: the inbox address, always verified, because we control delivery to it. Never present for an identity without a mailbox. |
name, preferred_username | Scope profile: the display name and the handle. |
https://agentboxd.com/claims/workspace | Scope workspace, and only when the workspace chose to share its name: its id and name. |
Use the agent claim to send agents to API-first onboarding, or to give them their own limits. Don’t use it to trust them more.
#What keeps this safe?
- Tokens are short and single use. Each one lives at most 5 minutes, and there are no refresh tokens. An agent that needs to sign in again makes one more call. Your own session decides how long it stays signed in.
- A token only opens the app it names. The audience check stops a token minted for one app from working at another.
- The nonce ties a token to one login attempt, so a token captured from one attempt can’t complete a different one.
- Keys rotate. Tokens carry a
kid, and verifiers fetch the key set again when they see a new one. Every common JOSE library does this, and so does the SDK. - The owner can switch it off. Every inbox has a Sign-in tab in the dashboard. Turning sign-in off refuses new tokens, browser approvals and token exchanges for that inbox at once. Tokens already issued expire within 5 minutes.
- Every sign-in is on record. The same tab lists the apps an inbox signed in to and each token, and the
identity.token_issuedandidentity.signed_inwebhook events record each one as it happens.
#FAQ
Does the agent need an email inbox to sign in?
No. An identity without a mailbox signs in the same way. Its tokens have no email claim and say mailbox: false. If your app needs to email the agent, require mailbox: true.
Can a person sign in with it?
The subject is always an agent. For a web app, the browser flow lets the agent’s owner approve the sign-in and pick which of their agents signs in, but the identity in the token is still the agent’s.
What happens if a token leaks?
It works for one app, once, for at most 5 minutes, and only if the nonce matches when you use one. Compare that with a leaked password or API key, which keeps working until someone notices.
Which libraries work?
Any OpenID Connect or JWT library that supports ES256 and reads a key set. For a browser button, the Sign in with Agentboxd guide has working setups for Better Auth and Auth.js.
What does it cost?
Signing in is on every plan. Each token issued counts as one sign-in: 10,000 a month on Free, 100,000 on Builder and 1,000,000 on Team. Identities without a mailbox have their own limit, separate from inboxes: 100 on Free, 1,000 on Builder and 10,000 on Team. The plans page has the details.
#Related posts
- How can an AI agent read an email verification code without regex?: for apps that still sign agents up by email.
- Giving an AI agent its own email address
- Prompt injection by email: how to protect AI agents that read mail
The full reference, including the browser flow and every endpoint, is in Sign in with Agentboxd.