Most teams ship their first CX agent with a single API key loaded into an environment variable and a note in the README saying "don't rotate this, everything breaks." It works fine until it doesn't.
The shift from "fine for a demo" to "production risk" happens fast. Once your agent is calling a CRM, a payment processor, a calendar API, and a ticketing system, that one shared key is the only thing between a misbehaving session and broad access to customer data.
Six major vendors shipped AI agent identity products in Q1-Q2 2026. The IETF published a formal draft standard for workload identity in March 2026. That is not coincidence. It means the industry has acknowledged that agents are a new class of principal, and they need identity management designed for how they actually operate.
This article covers what that looks like in practice: how to give each agent session a verifiable identity, how to scope permissions to the minimum needed, how to properly delegate user authorization, and how to revoke access in under a second when something goes wrong.
Why traditional IAM breaks for AI agents
Traditional identity systems assume principals are human: they authenticate occasionally, operate at human speed, and have clearly bounded sessions. AI agents break every one of those assumptions.
A CX agent handling 500 conversations per hour makes decisions in milliseconds, touches four to six external APIs per conversation, and often runs without a human reviewing each action. The "session" might be a 90-second voice call or an async workflow spanning three days. There's no login page. There's no MFA prompt. There's no concept of "the user pressed logout."
The standard response to this is a service account with broad permissions. The service account exists permanently, doesn't change based on which user is being served, and often has far more access than any individual conversation needs. When something goes wrong, it's impossible to know which session caused it because every tool call in the logs looks identical.
What the 2026 wave of agent IAM products introduces is a different model. Each session gets its own credential, scoped to exactly the work that session is authorized to do, backed by the identity of the human whose interests the agent is serving, and valid only for the duration of the task. When the session ends, the credential expires. When something goes wrong, one revocation call kills that session and only that session.
A useful frame: traditional IAM governs who can log in. Agent IAM governs what each active session is allowed to do, right now, on behalf of whom.
The three identity models for CX agents
Not every agent needs the same approach. The right model depends on whether the agent is acting on behalf of a specific user or running as a background service.
Delegated user access. The agent acts on behalf of a specific customer or employee. It needs to access their CRM record, book time on their calendar, or look up their order history. The identity model here is OAuth 2.0 On-Behalf-Of (OBO): the user authenticates to your platform, and the agent exchanges that user token for a short-lived agent token scoped to exactly the actions this conversation requires.
Service-level automation. The agent runs background tasks not tied to a specific user session: processing a queue of escalations, sending follow-up messages, generating daily reports. The model here is OAuth 2.0 Client Credentials: the agent has its own client ID and secret, scoped to the background task types it's authorized to perform.
Scoped delegation. A parent agent delegates a subtask to a specialist agent. The specialist needs access to some of the parent's context but not all of it. This is the hardest case and the one the WIMSE spec is specifically designed to address.
For most CX agent teams, delegated user access covers the bulk of use cases. A caller is identified by phone number or authentication, the agent receives a scoped token for that caller, and every tool call carries proof that this specific caller authorized this specific action.
Implementing OAuth 2.0 On-Behalf-Of
The OBO flow isn't complicated, but a few details trip up most teams building it for the first time.
Here's the sequence for a voice agent handling a booking request:
import { TokenExchangeClient } from '@/lib/auth';
import { chanl } from '@/lib/chanl';
async function handleBookingRequest(
conversationId: string,
userToken: string // received when caller authenticated
) {
// Exchange user token for agent token scoped to this task
const { token, tokenId } = await TokenExchangeClient.exchange({
subjectToken: userToken,
subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token',
requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token',
scope: 'calendar:read calendar:write:appointments',
audience: 'https://calendar.internal.example.com',
actorClaim: {
sub: `agent:booking:${conversationId}`,
iss: 'https://your-agent-platform.example.com'
}
});
// tokenId is stored for potential revocation; token goes to the agent
await sessionStore.set(conversationId, { tokenId, issuedAt: Date.now() });
const slots = await fetchAvailableSlots(token);
const booking = await createAppointment(token, slots[0]);
// Log the tool call with full identity context
await chanl.tools.logCall({
conversationId,
toolName: 'calendar.createAppointment',
actorTokenId: tokenId,
userTokenSub: extractSub(userToken),
outcome: booking.status
});
return booking;
}The actorClaim is the critical piece. The calendar API sees a token that says: "This is User Y's authorization, being exercised by Agent X in session Z." Three layers of identity in one credential: who the user is, which agent is running, and which specific session this belongs to.
Two mistakes teams make here consistently.
The first is requesting broad scopes to avoid multiple round trips. You want "calendar:read" and "calendar:write:appointments" rather than "calendar:admin". The extra complexity is worth it. A compromised session with "calendar:read" can only leak appointment data. A compromised session with "calendar:admin" can delete the whole calendar or create appointments for other users.
The second is reusing the same agent token across conversations. Each conversation should produce a new token. When the conversation ends, revoke it. This adds one revocation call per session but dramatically reduces blast radius: a leaked token is only valid for the duration of the conversation it was issued for.
Scope minimization in practice
The hardest part of scope minimization is organizational, not technical. You need to define what each agent actually needs before you give it access, and resist the pressure to add extra scopes "just in case."
A practical structure: map each tool to the exact scopes it needs, and derive conversation-level scopes from the tool map rather than hardcoding them:
export const TOOL_SCOPES = {
'crm.lookupContactByPhone': ['crm:contact:read'],
'crm.updateCallOutcome': ['crm:contact:update:call_fields'],
'calendar.getAvailableSlots': ['calendar:read'],
'calendar.createAppointment': ['calendar:write:appointments'],
'payments.issueRefund': ['payments:refund:own_transactions'],
'knowledge.searchFaq': [] // public endpoint, no auth required
} as const;
export const CONVERSATION_TOOL_MAP: Record<ConversationType, ToolName[]> = {
'appointment-booking': ['crm.lookupContactByPhone', 'calendar.getAvailableSlots', 'calendar.createAppointment', 'crm.updateCallOutcome'],
'refund-request': ['crm.lookupContactByPhone', 'payments.issueRefund', 'crm.updateCallOutcome'],
'general-inquiry': ['crm.lookupContactByPhone', 'knowledge.searchFaq', 'crm.updateCallOutcome']
};
function getScopesForConversation(type: ConversationType): string[] {
const tools = CONVERSATION_TOOL_MAP[type];
return [...new Set(tools.flatMap(tool => TOOL_SCOPES[tool]))];
}When a conversation starts and its type is determined, the system derives exactly the scopes needed. Nothing extra. If the agent attempts a tool call outside those scopes, it gets a 403 and a logged audit entry showing the unauthorized attempt.
This matters for incident response as much as for prevention. When you review a session where something went wrong, the token's scope tells you exactly what the agent was authorized to do. The gap between what it was authorized to do and what it tried to do is precisely the attack surface you're investigating.
You can wire this directly into Chanl's tools registry: define scope requirements per tool, and Chanl enforces them at the point where credentials are issued for each session. When a session ends and you're reviewing what happened, the analytics view shows you which tool calls succeeded, which were blocked by scope enforcement, and which failed for other reasons.
JIT provisioning and the kill switch
Just-in-time provisioning means credentials don't exist until they're needed, and they stop existing when they're no longer needed.
For background service agents, this means rotating credentials on a short cycle (15-60 minutes depending on risk tolerance) rather than using a deploy-time credential that sits unchanged for months.
For user-delegated agents, JIT is tighter: each conversation gets a new credential. Here's how to implement the revocation half of the pattern:
class AgentSession {
private conversationId: string;
private tokenId: string | null = null;
constructor(conversationId: string) {
this.conversationId = conversationId;
}
async initialize(userToken: string, conversationType: ConversationType) {
const scopes = getScopesForConversation(conversationType);
const { token, tokenId } = await OAuthClient.issue({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: userToken,
scope: scopes.join(' '),
expires_in: 300 // 5 minutes, short by design
});
this.tokenId = tokenId;
return token;
}
async terminate(reason: 'natural' | 'operator_kill' | 'anomaly_detected' = 'natural') {
if (!this.tokenId) return;
await OAuthClient.revoke({ token_id: this.tokenId });
await auditLog.write({
event: 'agent_session_terminated',
conversationId: this.conversationId,
tokenId: this.tokenId,
reason,
timestamp: new Date().toISOString()
});
this.tokenId = null;
}
}
// Exposed to your monitoring system and ops dashboard
export async function killAgentSession(conversationId: string, reason: string) {
const session = await sessionStore.get(conversationId);
if (!session) return;
await session.terminate('operator_kill');
}The separation of tokenId and token is essential. You store the tokenId in your session store. The token is what the agent uses for API calls. When you call revoke, the OAuth server marks that tokenId as invalid. Every subsequent API call from that session gets a 401 and stops.
Your monitoring setup can trigger killAgentSession() automatically when it detects anomalous patterns: a single session making more tool calls than any prior session of that type, a session accessing data for a different user than the one who authenticated, or a session that has been running for three times its normal duration. Automatic revocation without waiting for human review.
Multi-agent delegation chains
Every agent-to-agent handoff is an accountability gap unless you embed delegation context into the token chain. Without it, you know an action happened but not who authorized the chain of events that led to it.
If Agent A delegates a subtask to Agent B, and Agent B calls a CRM, the CRM log shows "Agent B read a contact record." Was that authorized? By whom? In what context? You've lost the chain.
Without proper delegation tracking, each hop in a multi-agent workflow creates an accountability gap. The WIMSE spec addresses this with token chaining, but a practical interim solution is passing delegation context alongside each subtask:
async function delegateToSpecialist(
parentConversationId: string,
parentToken: string,
specialistId: 'refund-specialist' | 'booking-specialist',
task: SpecialistTask
) {
const delegationToken = await TokenExchangeClient.exchange({
subjectToken: parentToken,
scope: SPECIALIST_SCOPES[specialistId].join(' '),
audience: `agent:${specialistId}`,
actorClaim: {
sub: `agent:${specialistId}`,
delegatedBy: 'orchestrator',
parentConversation: parentConversationId
}
});
return await specialistAgents[specialistId].handle(task, delegationToken);
}Now every tool call the specialist makes carries: which specialist is calling, which orchestrator delegated to it, and which parent conversation this all traces back to. The audit log goes from "Agent B read a contact record" to "Refund Specialist read a contact record, delegated from Orchestrator in conversation C, on behalf of User D."
For CX teams building multi-step workflows, this isn't optional. If a refund is incorrectly issued, you need to trace which agent in the chain made the decision and whether each step in that chain was properly authorized. Without delegation context, you're guessing.
See how this connects to Chanl's MCP gateway: when agents call external tools via MCP, the gateway can enforce that each tool call carries a delegation-context header, blocking calls that don't provide a verifiable chain of authorization back to the originating user.
Testing identity boundaries
You can't trust that scope enforcement is working without actively testing that out-of-scope operations are blocked. The test is straightforward: provision an agent session with its defined scopes, then attempt operations that are explicitly outside those scopes. The response should be 403. If it's 200, enforcement is broken.
describe('Booking agent identity boundaries', () => {
let session: AgentSession;
let token: string;
beforeEach(async () => {
session = new AgentSession('test-conv-001');
token = await session.initialize(testUserToken, 'appointment-booking');
});
it('creates appointments within scope', async () => {
const res = await callTool('calendar.createAppointment', token, slotData);
expect(res.status).toBe(200);
});
it('cannot delete appointments it did not create', async () => {
const res = await callTool('calendar.deleteAppointment', token, {
appointmentId: 'other-users-appointment'
});
expect(res.status).toBe(403);
});
it('cannot export all CRM contacts', async () => {
const res = await callTool('crm.exportAll', token, {});
expect(res.status).toBe(403);
});
it('cannot issue refunds outside its conversation type', async () => {
const res = await callTool('payments.issueRefund', token, {
transactionId: 'any-transaction'
});
expect(res.status).toBe(403);
});
afterEach(async () => {
await session.terminate();
});
});These tests belong in your CI pipeline alongside functional agent tests. An agent that books appointments correctly but can also delete appointments it didn't create is failing in a way that's easy to miss in functional testing and serious in production.
Chanl's scenario runner can run these identity boundary checks as part of your full agent test suite. Each scenario can verify both that the agent does what it's supposed to and that it cannot do what it's not supposed to. The two are equally important and you should test them with equal rigor.
For more on how identity fits into your broader security posture, the article on zero-trust in multi-agent systems covers the trust boundary architecture for orchestration patterns. And if you're managing the secrets layer that sits below identity (API keys for legacy services that don't support OAuth), agent secrets management in production covers that separately.
The teams still running on a shared API key will find out the hard way why it matters. One compromised session with broad access is a customer data story. One compromised session with scoped credentials is a support ticket. That gap starts with the architecture decision you make before the first conversation hits production.
The tooling is now first-class, the standards are in place, and the cost of the shared-key approach is climbing as agents handle more sensitive workflows.
Track every tool call your agents make, with full identity context
Chanl logs each tool call with the agent session, user identity, and permission scope, so you can audit conversations, enforce scope boundaries, and kill a specific session instantly if something looks wrong.
Start freeCo-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
Learn Agentic AI
Weekly. Patterns for shipping agents that work. MCP, scorecards, regression tests, prompts, model comparisons.


