AI agents: ID-JAG delegation
Time: ~20 minutes. A copilot or chatbot acts for a person — when Alice asks her assistant to check her tickets, the MCP server should apply Alice's permissions and the audit log should say "Alice, via agent X". ID-JAG (Identity Assertion JWT Authorization Grant, the emerging cross-app access standard also called XAA) makes that the only thing the agent can do.
Why not just give the agent a service account?
| Approach | Problem |
|---|---|
| A powerful service account | Every user gets the same over-broad access; the audit log says "the bot did it" |
| Handing the user's token to the agent | The agent can do everything the user can, everywhere, invisibly |
| ID-JAG | Scoped, short-lived tokens that carry both identities — and either side can revoke |
The delegated token says exactly what happened:
{
"sub": "alice@example.com", ← whose authority
"act": { "client_id": "agent-x" }, ← who is acting
"scope": "my_mcp:read ...", ← only what was requested & approved
"aud": "https://your-mcp/mcp" ← only this server
}
Step 1 — Register the agent
Sidebar → WORKSPACE → Agents — every AI agent in the workspace, with the server it's connected to, its status, and when it last got a token. Click + Register agent:

Two fields:

- Name — a human label, e.g.
research-agent - Redirect URI — where the user's browser lands after login. For the
SDK's
browser_login()keep the loopback default (http://localhost:8126/callback); a web app uses its own OAuth callback URL.
Step 2 — Copy the credentials (shown once)

Put them in the agent's environment — the secret is not shown again:
AUTHSEC_ISSUER=https://mcpauthz.com
AGENT_CLIENT_ID=<client id>
AGENT_CLIENT_SECRET=<the secret you copied>
MCP_URL=https://your-mcp-server.example.com/mcp
Step 3 — Wire the agent
One call does the whole dance — browser login, token exchange, delegated token:
from authsec_sdk import AgentIdentity, browser_login
id_token = await browser_login(issuer=ISSUER, client_id=CLIENT_ID, resource=MCP_URL)
agent = AgentIdentity(issuer=ISSUER, client_id=CLIENT_ID,
client_secret=SECRET, idp_issuer=ISSUER)
async with agent:
token = await agent.access_for(
MCP_URL,
user_session={"subject_token": id_token},
requested_scopes=["my_mcp:read"],
)
The full program — including handling the first-run
PendingApprovalError with poll_until_approved(...) — is in the
Python SDK guide.
Requested scopes must exist on the target server — check its PRM document (
/.well-known/oauth-protected-resource/mcp,scopes_supported). Requesting a scope the server doesn't define looks exactly like "waiting for approval forever". This is the #1 gotcha.
Step 4 — First run: the double opt-in
Two one-time approvals, one from each side of the trust boundary:
The user consents. The browser opens to the AuthSec login; the user signs in and sees a consent screen listing exactly the scopes the agent is requesting. They click Allow — once.
The admin approves. The agent's first call parks as a pending request
on the application's Connections tab. Approve it and pick the role the
agent gets (e.g. Readonly) — a polling agent picks the approval up
automatically, no restart needed:

From then on the same user + agent + server combination goes straight through: login → cached token → tools. No consent re-run, no pending request, no admin involvement.
Revoking
Either side can kill the delegation at any time — without touching the user's own access:
Per agent — Agents page → the row's ⋯ menu → Revoke
connection. The agent's next call fails with ConnectionRevokedError:

Per identity on the app — application → Access tab → Who has access → the row's ⋯ → Revoke access. Works for any identity and affects only this application:

Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Stuck "waiting for approval", nothing on the Connections tab | Requested scopes don't exist on the target server | Use scopes from the server's PRM scopes_supported |
redirect_uri mismatch at login | Agent registered with a different redirect URI | Match the registration (default http://localhost:8126/callback) |
| Token works, then suddenly 401s | Connection revoked or token expired mid-session | Retry once; on ConnectionRevokedError, re-request access |
| Agent missing from the Agents page | It has never successfully connected | Normal — it appears after its first server connection |
Error-class reference and the full first-run program: Python SDK — ID-JAG delegation.