Skip to main content

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?

ApproachProblem
A powerful service accountEvery user gets the same over-broad access; the audit log says "the bot did it"
Handing the user's token to the agentThe agent can do everything the user can, everywhere, invisibly
ID-JAGScoped, 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:

Agents page

Two fields:

Register AI agent

  1. Name — a human label, e.g. research-agent
  2. 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)

Agent registered — credentials

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:

Connections tab — access requests and active connections

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 agentAgents page → the row's ⋯ menu → Revoke connection. The agent's next call fails with ConnectionRevokedError:

Agents list — Revoke connection

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:

Who has access — Revoke access

Troubleshooting

SymptomCauseFix
Stuck "waiting for approval", nothing on the Connections tabRequested scopes don't exist on the target serverUse scopes from the server's PRM scopes_supported
redirect_uri mismatch at loginAgent registered with a different redirect URIMatch the registration (default http://localhost:8126/callback)
Token works, then suddenly 401sConnection revoked or token expired mid-sessionRetry once; on ConnectionRevokedError, re-request access
Agent missing from the Agents pageIt has never successfully connectedNormal — it appears after its first server connection

Error-class reference and the full first-run program: Python SDK — ID-JAG delegation.