Security

Propagating the Caller's Identity Through Agent Chains

Antonio Evans11 min read

A horizontal chain of three abstract agent nodes connected by arrows.
Key points

Key points

  1. 1Every agent-to-agent call must carry the original user and tenant identity as explicit message fields and token claims, because ambient context does not survive a hop.
  2. 2OAuth 2.0 token exchange (RFC 8693) lets each hop mint a downstream token scoped to the original user and addressed to the next service, rather than forwarding one broad token.
  3. 3The effective authority of a downstream call should be the intersection of the user's permissions and the calling agent's permissions, never the union.
  4. 4A contract test that asserts the user id and tenant id are present and unchanged at every hop turns a silent identity drop into a build failure.

The hop is where identity dies

A single agent that calls an API is a solvable identity problem: the front door validates the user's token, resolves the tenant, and passes both to the tool. Add a second agent and the problem changes character. The second agent receives a request from a program, not from a person, and most agent frameworks do not forward the user's claims by default. The runtime treats the call as "agent A is talking to agent B," and whatever tenant the first agent had in memory is now a piece of context that may or may not be serialized into the payload.

The Azure architecture guidance on multitenant agentic systems names this explicitly. A shared agent's identity typically has permissions to work across multiple tenants but does not inherently understand tenant context, and model behavior cannot be relied on to propagate tenant or user context even when it appears correct in tests: the tenant id should be set and propagated only in deterministic code (Microsoft's multitenant agentic guidance). Subagent invocation and agent handoff is called out as one of five distinct tenant-context loss risks, alongside agent identity, model decisions, tool calling and data stores.

The reason the risk is severe for agents rather than ordinary services is that a wrong database query returns bad data, while a misconfigured agent acts. It updates the wrong opportunity stage, posts to the wrong channel, creates the wrong ticket, in systems you do not own, and the downstream API accepts the call because the credential was valid (Scalekit on multi-tenant tool calling). That is the failure mode your schema and tests have to prevent.

Why ambient context fails

Ambient context means anything the agent does not have to state: a thread-local, a language model's conversational memory, a session variable on a pooled connection, or "the agent only serves one tenant anyway." Each of these breaks for a different reason. The model's memory is attacker-reachable through prompt injection, which is why the OWASP AI Agent Security Cheat Sheet treats indirect injection through retrieved documents and tool outputs as a core risk and asks that authorization be enforced in the execution component, outside the agent's context (OWASP AI Agent Security Cheat Sheet). Pooled connections outlive a request, which is exactly the trap the Postgres RLS write-up describes: a session-wide tenant setting can leak into the next request served by the same connection, while a transaction-local one disappears when the transaction ends (Postgres Row Level Security for Multi-Tenant SaaS).

There is also a harder research result worth knowing. When the tenant identifier is a tool parameter chosen by the model, a correctly validated parameter still serves the attacker, because the model is a stochastic deputy whose authority is bounded by its prompt rather than by the caller's credential. In a reported ablation, a validated tenant parameter served 26 of 26 out-of-scope attempts, and removing the parameter from the tool schema so that no signature could express the read was the structural fix, with 12 of 56 remaining trials escaping by forging writable scope (The Stochastic Deputy, arXiv). The lesson for agent chains is direct: identity has to be bound to a credential and enforced below the agent, not selected by the agent.

A message schema that carries two identities

The rule from the single-agent case extends one step: every envelope between agents carries both the end-user identity (subject and tenant) and the calling agent's own identity (actor), plus the identifiers needed for audit. Make the fields required, not optional. An optional field is dropped by the first developer who finds it inconvenient.

A minimal envelope in JSON:

{
  "schema_version": "2",
  "request_id": "01J9C4Z8Q2",
  "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "subject": {
    "user_id": "usr_8412",
    "tenant_id": "tnt_0093",
    "auth_time": 1767225600,
    "amr": ["pwd", "mfa"]
  },
  "actor": {
    "agent_id": "agent.invoice-triage",
    "agent_version": "2026.4",
    "on_behalf_of_chain": ["agent.orchestrator", "agent.invoice-triage"]
  },
  "token": "eyJhbGciOi...",
  "payload": { "task": "summarize_unpaid" }
}

Four decisions in that envelope matter more than the rest.

Both identities are explicit. The subject block answers "whose data," the actor block answers "which program is asking." Authorization downstream should evaluate the intersection, not either one alone. The WorkOS guidance states the rule plainly: the agent's effective authority should never exceed the intersection of what the agent and the user are each permitted to do, which is the confused-deputy defense (WorkOS on AI agent access control).

The chain is a list, not a scalar. When three agents are involved, an auditor needs to reconstruct user to orchestrator to specialist to tool. Scalekit describes structured tokens with nested claims using subject and actor claims for chained delegation such as user to InfraBot to ArgoCD, so every service in the chain can enforce policy and log actions properly (Scalekit on on-behalf-of for AI agents). The on_behalf_of_chain field mirrors that in the message so logs and tests can compare message and token.

The token travels, and the fields do not replace it. The message fields are for routing and audit; the token is what the downstream service validates. A service that trusts the JSON tenant_id without checking the matching claim has built a suggestion, not a control.

Schema version is required. Identity fields get added and renamed. A version integer makes a stale producer fail loudly at the consumer instead of quietly omitting a field the consumer used to require.

Envelope elementAnswersFailure if missing
subject.user_idWhose data may be read or changedQueries run with no user filter or the wrong one
subject.tenant_idWhich tenant boundary appliesCross-tenant read or write on a shared pool
actor.agent_idWhich program is exercising authorityNo attribution in the audit trail
actor chainHow the request reached this hopCannot reconstruct or revoke a delegation
tokenCryptographic proof of scope and audienceDownstream cannot verify anything
request and trace idsWhich call, from which originIncident response stalls at the hop boundary

Token exchange at every hop (RFC 8693)

Forwarding the same token down the chain is the common shortcut and the wrong one. It gives every downstream service a credential minted for the front door, with an audience that does not include them and scopes broader than the next step needs. The token exchange extension to OAuth 2.0 defines a grant type that lets a client present an existing security token to the authorization server and receive a new one for a different target, including delegated tokens (RFC 8693, OAuth 2.0 Token Exchange).

The mechanics are small enough to hold in your head: a POST to the token endpoint with the exchange grant type, a subject token, a subject token type, and optional audience, scope, actor token and requested token type parameters. The response uses the standard OAuth shape and returns the new credential in the access_token field.

POST /oauth/token HTTP/1.1
Host: auth.example.net
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=eyJhbGciOi...
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=eyJhbGciOi...
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&audience=agent.invoice-triage
&scope=invoices:read
&requested_token_type=urn:ietf:params:oauth:token-type:access_token

The actor token is the delegation decision. Including an actor token identifies the requesting service as a party distinct from the subject, which is the mechanism that separates delegation from impersonation (Descope on OAuth token exchange). Without it, the downstream service sees only the user and cannot tell that an agent acted. With it, the downstream token can carry both identities and the service can apply the intersection rule.

Audience and scope are the shots you must actually call. The exchange request's audience pins the new token to one target, which is how you prevent cross-server reuse, and the scope parameter should narrow, not widen. The on-behalf-of write-up makes the same point from the implementation side: sending the same token to every downstream service is not a good general design, each downstream API should receive a token intended for it and validate that intent independently rather than trusting the caller because it is internal (Scalekit on delegated agent access).

Tenant context does not come from the URL. A request to a path containing a tenant slug, authenticated with a token, still needs the tenant resolved from the token and checked against resource ownership. Amazon's walkthrough of a multitenant agent on Bedrock AgentCore with Aurora PostgreSQL sets this out as a layered sequence ending in row level security and role-based access control, so that the same question from two tenants yields different data from the same pool (AWS on data isolation in a multi-tenant AI agent).

Client authentication is your choice, and it matters. RFC 8693 leaves client authentication requirements to each implementation and permits unauthenticated exchanges, which the Descope explainer flags as flexibility that is not a recommendation (Descope). Any caller holding a valid token could otherwise exchange it. Require the agent to authenticate to the token endpoint with its own credential, and bind that credential to an allowlist of exchanges: agent A may request audience B with scope C, nothing more.

A worked example with numbers shows why the per-hop exchange pays for itself. Suppose a pooled agent service handles 1,000 requests a minute across 40 tenants, and one background job runs hourly with no user in scope. If that job resolves the first stored token it finds, as the multi-tenant trap describes, it writes to whichever tenant happened to be first in the store. The token is valid, the downstream accepts it, and no exception is raised (Scalekit). One such write per hour is 8,760 misdirected operations a year, each invisible to monitoring that only watches error rates. With per-hop exchange, the job cannot obtain a token without a subject, because there is no subject to present. The failure becomes an error at the job instead of a silent write in someone else's tenant.

Enforcing the boundary where the data lives

Token exchange proves who is asking. It does not by itself stop a query from reading a row belonging to another tenant, because the agent still composes the query. The second enforcement point belongs in the data layer.

Postgres Row Level Security makes that possible without trusting application code. With RLS enabled, normal access must be permitted by a policy, and with no applicable policy Postgres applies default deny, so a forgotten filter returns no rows rather than another tenant's rows. The policy compares a tenant column, which cannot be null, against the active context, and the context should be transaction-local so a pooled connection cannot carry it into the next request (Postgres Row Level Security for Multi-Tenant SaaS).

Two habits keep RLS a defense rather than a false comfort. First, set both the role and the user claim inside the transaction, the way the Supabase testing guide does with a local role of authenticated and a local request claim carrying the user's id, so tests and production use the same mechanism (Supabase testing overview). Second, test the policies, not just the application. The Supabase discussion on generated RLS tests describes reverse-predicate seeding, where a generated test asserts "another tenant cannot see this row" against a row that genuinely belongs to another tenant, because a test that checks the negative against an empty table proves nothing (Supabase RLS test discussion).

Enforcement pointWhat it stopsWhat it cannot stop
Message schema (required subject and actor)Accidental omission at a hopA producer that fills the field with a stale value
Token exchange per hopBroad token reuse, cross-audience replayA compromised authorization server
Downstream token validationTokens minted for another audienceLogic errors after validation
RLS with transaction-local contextCross-tenant reads the agent composes wronglyMisuse of a role that bypasses RLS
Contract test at each hopIdentity silently dropped between agentsA bug both producer and test share

The contract test that fails when the user id goes missing

This is the part teams skip, and it is the part that catches the bug in staging rather than in a customer review six weeks later. A contract test for an agent hop asserts four things: the envelope contains subject.user_id and subject.tenant_id; their values equal what the caller received; the token presented downstream carries matching claims; and the audience of that token is the downstream agent, not the caller's own audience.

Run it as a mocked hop in the caller's test suite, so it stays fast and hermetic. Mocking at the HTTP library level, rather than spinning up a server, lets your code execute normally while network calls are intercepted, which is the standard small-test approach in pytest (pytest-test-categories on HTTP mocking). Where your client is the standard library connection class, patching that class with a mock is enough for a minimal unit test (Stack Overflow example of mocking http.client).

import json
import pytest

USER = "usr_8412"
TENANT = "tnt_0093"

def test_hop_preserves_subject(httpx_mock, orchestrator, fake_exchange):
    httpx_mock.add_response(
        url="https://auth.example.net/oauth/token",
        method="POST",
        json={"access_token": fake_exchange(USER, TENANT), "token_type": "Bearer"},
    )
    seen = {}

    def capture(request):
        seen["body"] = json.loads(request.content)
        seen["auth"] = request.headers["authorization"]
        return httpx.Response(200, json={"ok": True})

    httpx_mock.add_callback(capture, url="https://triage.internal/run", method="POST")

    orchestrator.call_specialist(user_id=USER, tenant_id=TENANT, task="summarize")

    body = seen["body"]
    assert body["subject"]["user_id"] == USER, "user id dropped or changed at hop"
    assert body["subject"]["tenant_id"] == TENANT, "tenant claim dropped or changed"
    assert body["actor"]["agent_id"] == "agent.orchestrator"
    claims = decode_unverified(seen["auth"].split(" ", 1)[1])
    assert claims["sub"] == USER
    assert claims["tenant_id"] == TENANT
    assert claims["aud"] == "agent.invoice-triage"
    assert claims.get("act", {}).get("sub") == "agent.orchestrator"

Two properties make this test worth maintaining. It fails if a required field is omitted, because indexing the missing key raises rather than comparing equal to None. And it fails if the value is changed, because the equality check is against the constant the test invoked, not against whatever the caller happened to emit. Run one variant of it per hop in the chain, and add a negative test that removes subject from the fixture and asserts the call is rejected, so you know the downstream also checks.

For teams that already test RLS with pgTAP, the same discipline carries into the database: a test plan that impersonates two users and asserts the wrong-tenant update returns nothing is the database end of the same chain (Makerkit on pgTAP database testing). The Basejump guide describes the authentication helpers that make this practical, including a function that authenticates as a specific user and sets the required JWT values, and a function that clears authentication to simulate an anonymous caller (Basejump guide to pgTAP). The agent hop test and the policy test together cover both ends of the path.

A rollout order that works

If you are retrofitting identity onto an existing agent chain, the sequence below front-loads the cheap wins and leaves the coordination-heavy work for when you have evidence the whole path is worth investing in.

  1. Make subject and actor required in the envelope schema and reject messages that lack them. Producers break immediately, which is the point.
  2. Add the capture test to the calling agent and let it fail against the current code, so you have a reproduction of the drop before you fix it.
  3. Introduce token exchange at the first hop only, with actor tokens and narrowed audience, and confirm the downstream validates the audience.
  4. Extend exchange to each subsequent hop, adding the contract test per hop as you go.
  5. Move tenant filtering into RLS with transaction-local context, and add generated policy tests rather than hand-written happy paths.
  6. Only then remove the shared service credential from the downstream path. Its presence hides bugs that the tests should now surface.

Step six is where teams hesitate. Removing a broad credential feels risky, but keeping it is the larger risk: the broad credential is exactly what lets a background job act for the wrong tenant without raising an exception.

When several agents, model providers and environments are in play, keeping one credential store and one audit trail across the chain is usually the operational bottleneck rather than the protocol. A shared workspace with per-person keys and a private skill library makes it easier to keep the agent identities and the tests that guard them in one place; Quanta for Teams covers that ground. If the chain spans systems you do not control, a short working session on the specific hops and identity provider constraints is often faster than reading three vendor blogs; you can book a free 30-minute AI discovery call.

FAQ

FAQ

Do I really need both a user identity and an agent identity on every hop?

Yes, and conflating them is the bug the two-identity rule exists to prevent. If only the user travels, the downstream service cannot tell which program is acting or restrict it to its own allowed tools. If only the agent travels, the downstream service cannot scope data to the user or the tenant. The safe rule is that effective authority is the intersection of the two, which is how the confused-deputy pattern is closed (WorkOS).

Is passing the same token down the chain acceptable if the scope is narrow?

It is better than a broad token but still a shortcut. A token minted for the front door has an audience that does not include the downstream service, so you either disable audience validation, which is a real weakening, or the call fails. RFC 8693 exists to obtain a credential addressed to the next service, with audience and scope set for that hop, and with an actor token recording who is doing the delegation (RFC 8693, Descope).

What stops an agent from simply inventing a tenant id it was not granted?

Nothing in the message layer, which is why the claim has to be bound to a credential the agent cannot mint. In the structural defense described in the stochastic deputy paper, taking the tenant parameter out of the tool schema and binding scope to a verified credential removed every out-of-scope read in the reported trials, though 12 of 56 remaining trials escaped by forging writable scope, showing that the interface invariant depends on cryptographically protected context (arXiv). At the data layer, RLS policies using a transaction-local context add a second check the agent cannot bypass.

Where should the contract test live, and what should it assert first?

Put it in the calling agent's own test suite, mocked at the HTTP library boundary so it runs in a second or less (pytest-test-categories). Assert presence first (subject.user_id, subject.tenant_id, actor.agent_id), then equality against the values the test invoked with, then that the downstream token's subject, tenant and audience claims match. Add a negative case that strips subject and expects rejection, so you prove the callee checks rather than the caller alone.

Does this work for a chain that crosses an organizational boundary?

It requires more explicit trust, but the pattern holds. The delegating organization's authorization server must accept the subject token and the actor credential, and the downstream service must validate the token independently rather than trusting the caller because it is internal; the Scalekit on-behalf-of write-up is explicit that downstream APIs should validate tokens themselves (Scalekit). Where the two sides do not share an authorization server, treat the cross-boundary call as a fresh authentication with its own audience and keep the original user id in the actor chain for attribution.

Sources

Sources

  1. Considerations for Multitenant Agentic Systems
  2. Single-Tenant to Multi-Tenant Tool Calling Agent Auth
  3. AI Agent Security - OWASP Cheat Sheet Series
  4. Postgres Row Level Security for Multi-Tenant SaaS ...
  5. Structural Tenant Isolation for Tool-Using LLM Agents
  6. Best practices for AI agent access control
  7. Understanding "On-Behalf-Of" in AI Agent Authentication
  8. RFC 8693 - OAuth 2.0 Token Exchange
  9. What Is OAuth Token Exchange?
  10. Data isolation & security in multi-tenant AI agent for SaaS
  11. Testing Overview | Supabase Docs
  12. RLS policies, proven - auto-generated pgTAP tests and ...
  13. HTTP Mocking for Small Tests
  14. How to create a unit test for a function that uses Python's ...
  15. Database Testing with pgTAP
  16. A Guide to testing on Supabase using pgTAP - Basejump
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles