Security

Separating the Agent's Credentials From the Caller's Identity

Antonio Evans10 min read

A split diagram: on the left, a robot arm labeled 'Agent' holds a key labeled 'Service Account'.
Key points

Key points

  1. 1Issue the agent its own service account credential and send the end user's identity, including the tenant id, on every tool call so neither party can borrow the other's authority.
  2. 2Derive the tenant id from a verified credential claim, not from a model-supplied tool argument, because an agent's arguments are shaped by whatever text reaches its context.
  3. 3Test each tool call through a mock transport and fail the build when either the service account or the user identity is missing.
  4. 4Push the tenant id down to a database-enforced policy or an equivalent gateway check so isolation survives a buggy tool handler.

Where the tenant id comes from decides everything else

Most teams start with the obvious version of multi-tenant tool calling: put tenant_id in the tool schema, let the model fill it in, and validate it server-side against the caller's entitlement. That looks rigorous. It is the wrong layer.

The reason is a property of LLM agents that does not apply to ordinary programs. In a deterministic service, the arguments in a request are fixed by source code, so a tenant parameter is a convenience and validating it is sufficient. An agent instead produces arguments from a context window that, in any realistic deployment, is partly attacker-reachable through direct or indirect prompt injection. The paper that formalizes this calls the result a stochastic deputy: a deputy that holds broad delegated authority, selects which resource to use through a non-deterministic process, and cannot reliably separate instructions from data (Baig et al., The Stochastic Deputy). If the tenant identifier is a tool parameter, the effective authority of the request is bounded by the model's prompt, not by the credential.

The measured consequence is blunt. In a 373-trial ablation across eight model configurations and two transports, a correctly validated tenant parameter served every out-of-scope attempt: 26 of 26, or 26 of 41 plausible-pretext trials overall. When the parameter was removed from the schema, no tool signature could express the read at all (arXiv, structural tenant isolation). The validator did its job faithfully every time. The specification was the vulnerability.

So the design rule is short: the tenant id must originate in a credential the model cannot write to. Everything downstream, including the tests, is easier once you accept that.

The two-identity pattern in practice

Every outbound tool call carries two things:

  1. A service account credential for the agent. This authenticates the software, scopes it to the tools and verbs it may use, and gives you a principal to revoke when the deployment changes. Guidance on agent access control is consistent that agents should authenticate as distinct principals rather than reuse a user session or a long-lived shared key, and that credentials should be short-lived (WorkOS, agent access control best practices).
  2. The end user's identity, including the tenant id, carried in a signed header or a token claim. This is what the downstream service uses for authorization and audit.

The Azure architecture guidance for multitenant agentic systems describes the same split and names the failure mode: a multitenant agent's identity typically has permissions to work across several tenants but carries no inherent tenant context, and model behavior cannot be trusted to propagate tenant or user context. The recommendation is to set and propagate the tenant id only in deterministic code, with a gateway that ensures the context reaches each tool in tamper-resistant tokens (Microsoft, multitenant agentic systems).

The second identity is a delegated grant held by the user, not by you, which changes what "valid" means. An OAuth grant can be revoked from a user's phone at 11pm, invalidated by the provider on a password change, or pulled by an employer during offboarding, and none of those events notify your agent before the next tool call fails. Worse, the actions are external and irreversible: a wrong database write can be rolled back, but a wrong Slack post has been read (Scalekit, single-tenant to multi-tenant tool calling auth).

A comparison helps here, because the two credentials fail differently:

PropertyService account credentialEnd user identity (with tenant)
HolderThe agent runtimeThe downstream service, per call
AnswersWhich software is calling, and what tools it may useWhose data may be read or written
Sourced fromYour identity provider's app registrationThe login session, forwarded or exchanged
LifetimeMinutes, rotatedRequest-scoped
Effect of it going missingCall rejected as unauthenticatedCall rejected as unauthorized, or fails closed to zero rows
Typical leakA compromised runtime impersonates the agentA careless handler acts for the wrong tenant

The last row is where the multi-tenant blast radius lives. In a single-tenant agent, one delegated grant per tool means "valid token" and "authorized to act" are the same answer. At the second tenant they separate permanently, and a background job with no customer context in its execution scope will resolve the first token it finds and write to the wrong org. No exception is raised, because the credential was valid (Scalekit).

OWASP's agent cheat sheet files the same problem under tool abuse and privilege escalation, and recommends per-tool permission scoping, separate tool sets for different trust levels, and authorization enforced in the execution component outside the agent's context. A user_confirmed flag is insufficient: the component must verify the approval belongs to the current actor and the exact tool call, then check and consume it in one atomic step before execution, and fail closed for unknown tools (OWASP AI Agent Security Cheat Sheet).

Carrying the tenant into the data layer

Getting the identity onto the wire is half the pattern. The other half is making the data store refuse to answer without it.

Postgres row-level security is the usual mechanism in the pool model, where all tenants share tables. There are three common partitioning choices, and they trade isolation against operating cost: a separate database per tenant gives the strongest separation at the highest infrastructure and setup cost, a separate schema per tenant sits in the middle, and the pool model shares one instance and one schema with a tenant column on every table (Midnyte City, multi-tenant databases with RLS). AWS frames the same three as silo, bridge, and pool, and argues that a pooled model should centralize isolation enforcement rather than leave it to every SQL statement a developer writes (AWS, multi-tenant data isolation with RLS).

In the pool model, each tenant-owned table gets a non-null tenant_id with a foreign key to a tenants table, RLS is enabled, and a policy compares the row's tenant to a session-local setting (AWS). The policy does the filtering, so application code writes SELECT * FROM invoices WHERE status = 'open' and the planner adds the tenant predicate (Queryplane, Postgres RLS in practice).

Two details matter when an agent is the caller.

First, use a transaction-local setting, not a session-wide one:

-- inside a request-scoped transaction
SET LOCAL app.tenant_id = '4f9e2b...uuid';

SELECT * FROM invoices WHERE status = 'open';

SET LOCAL reverts when the transaction ends, so a pooled connection that served tenant A cannot leak its setting into the next request from tenant B. Plain SET persists for the rest of the connection's life, which means the next checkout of that connection inherits the wrong tenant (Queryplane). The helper function pattern from AWS does the same job: a current_tenant_id() function reads app.tenant_id and returns NULL when it is unset, and a NULL comparison denies all rows, so a missing context fails closed rather than open (OneUptime, securing multi-tenant data with RLS).

Second, remember that table owners and superusers bypass RLS unless you enable FORCE ROW LEVEL SECURITY, so run application queries as a non-owner role. The same source shows the grants for a dedicated app_user role rather than the table owner (OneUptime). The Azure guidance says the same thing in different words: enforce boundaries in deterministic code, not in the agent's identity (Microsoft).

A row policy also needs both halves of the write check. USING decides which existing rows a command may see, and WITH CHECK runs against the new row on insert and update. Omit WITH CHECK and a user can UPDATE a row to someone else's owner id and silently move it out of their own visibility, which surfaces later as a "where did my row go" bug rather than an error (Queryplane).

If all of this reads like a natural next step for a team wiring agents to real systems, a free 30-minute AI discovery call is a reasonable place to sanity-check the design before you build it: https://quantaintelligence.ai/calendar.

Where the tests bite: the mock transport

The tests exist to encode the decision above. Two assertions, both mandatory:

  • The request carried a service account credential, an Authorization header resolvable to your agent's principal.
  • The request carried an end user identity with a tenant claim, and the claim matches the tenant the caller belongs to.

Run the tool call through a mock transport and inspect what the code actually sent. For an httpx-based tool client, pytest-httpx intercepts requests and records them for assertion, which also keeps the test hermetic and fast; the docs example shows the httpx_mock fixture handling methods, status codes, URL patterns, and error responses without touching the network (pytest-test-categories, HTTP mocking). If your code uses the standard library client, patch the connection class instead and inspect the mocked request call (Stack Overflow, mocking http.client with pytest).

Here is the shape of the test, with the two failure cases made explicit:

import pytest

def test_tool_call_carries_agent_and_user(httpx_mock, tenant_a_ctx):
    httpx_mock.add_response(url="https://api.internal.example/invoices", json=[])
    client = build_tool_client(agent_credential=AGENT_KEY, user_ctx=tenant_a_ctx)
    client.list_invoices(status="open")

    req = httpx_mock.get_request()
    assert req.headers["authorization"] == f"Bearer {AGENT_KEY}"
    claims = decode_jwt(req.headers["x-user-context"])
    assert claims["sub"] == tenant_a_ctx.user_id
    assert claims["tenant_id"] == tenant_a_ctx.tenant_id

Then add the two tests that must fail. A hand-built context with the tenant claim stripped should make the client raise before it constructs a request. A context whose tenant is not one the service account may act for should be rejected at issuance, before the tool is reached. If either test passes, your code has a fallback somewhere, and the fallback is the bug.

There is a subtlety worth planning for. Tests that assert "tenant B cannot see tenant A's rows" only mean something if the row under test genuinely belongs to tenant A. A generated test suite that works backward from each policy to the exact rows that satisfy and violate it exists precisely because hand-written fixtures tend to be written against rows that do not actually violate the policy, which turns the assertion into decoration (Supabase discussion on auto-generated pgTAP tests). The same logic applies to the identity: assert against a tenant that really is a different tenant.

The database layer can be tested the same way, and it should be, because a policy is code. pgTAP runs inside a transaction you roll back, sets the role and the JWT claim to impersonate a user, and asserts row counts under results_eq and rejected writes under results_ne (Supabase, testing overview). Test helpers wrap that into a single authenticate_as call that sets all the required JWT values for a named user, which makes an identity-switching test a two-line setup (Basejump, testing with pgTAP).

Migrating a single-tenant agent without breaking it

The retrofit order matters, because each step depends on the one before it.

  1. Name the boundary. Write down which claim carries the tenant id, who signs it, and which services read it. If the answer to "where does tenant_id come from" is "the tool schema," you have found the defect.
  2. Split the credentials. Register the agent as its own principal with short-lived credentials. Keep the user's identity in a separate, request-scoped channel.
  3. Move the tenant out of the tool schema. Removing the parameter is what makes out-of-scope reads inexpressible rather than merely invalid. The ablation above found that 12 of 56 trials still escaped the interface by forging writable scope once the parameter was gone, which is why the context has to be cryptographically protected, not just absent from the schema (arXiv).
  4. Enforce at the store. Add RLS policies and the transaction-local setting, or the equivalent gateway check, and confirm that an unset context returns zero rows rather than everything.
  5. Lock it with tests. Add the two-identity assertion, the two negative tests, and a policy test that impersonates a second tenant, then wire the suite into CI so the check cannot be skipped.

Step 3 deserves emphasis. Many teams do steps 1, 2, 4 and 5 and skip the schema change, on the grounds that server-side validation is enough. The trial data says otherwise: validation passed 26 times out of 26 when the parameter was present. The interface has to make the read unexpressible, not just unauthorized.

One caution about the policy tests. A generated suite reads policies from the catalog and derives the roles each one applies to, and the derivation can go quiet in an unexpected way: when a policy omits its TO clause, Postgres stores the PUBLIC pseudo-role, which has no row in the roles catalog, so a naive join reports that no policy references any role. A schema with 17 policies and no TO clause is enough to hit it, and a policy named for full service access can then apply in places the report never flagged (Supabase discussion, comment on PUBLIC-scoped policies). Read the catalog view, not just the report.

The tenant id is a trust boundary, not a parameter

The decision rule the pattern encodes is simple enough to memorize: if a value determines whose data the call touches, it must come from a credential, not from the model's output. Everything else in this article follows from that one rule. The service account proves the software may make the call. The user identity proves the call may touch this tenant's rows. The database proves both by refusing to return anything when the tenant context is absent.

When you review an agent design, ask three questions in order. Where is the tenant id minted? Is it signed and audience-scoped to the service it is presented to? Does the store fail closed when it is missing? A yes to all three means the tests you write are checking a real boundary. A no anywhere means the tests are checking a parameter, and a parameter is something a prompt injection can rewrite.

Teams building this across several models often end up needing one credential path that behaves the same for text, image, and embedding calls rather than a different integration per vendor; Quanta Inference is one API key across those model types, which keeps the identity plumbing in one place: https://quantaintelligence.ai/inference.

FAQ

FAQ

Where exactly should the tenant id be minted?

At the point where the user's session is turned into a credential for the downstream service, normally inside your identity provider's token issuance flow. The agent runtime should be able to read that claim and forward it, but never to write or override it. The Azure guidance puts it as setting and propagating the tenant id only in deterministic code (Microsoft).

Is it enough to validate a tenant parameter the model supplies?

No, and there is measured evidence: with correct server-side validation in place, every out-of-scope attempt in a 373-trial ablation was served, 26 of 26 (arXiv). Validation confirms the caller is entitled to the tenant it named; it does not confirm that the tenant named was the right one. Removing the parameter from the schema is what makes the wrong request unexpressible.

What should the test assert, and what should make it fail?

Assert the presence and correctness of both identities on the outbound request: the service account credential and the user claim carrying the tenant. Commit two negative tests alongside them. Dropping the tenant claim must cause an error before a request is built, and presenting a tenant the service account may not act for must be rejected at issuance. If either negative test passes, a fallback exists and needs removal.

Why use `SET LOCAL` instead of a plain session setting for RLS?

Because connection pools are shared across tenants. A plain SET persists for the life of the connection, so the next request to check out that connection inherits the previous tenant's context. SET LOCAL scopes the value to the current transaction and reverts on commit, rollback, or return to the pool (Queryplane).

What does the two-identity pattern not fix?

It does not stop prompt injection from steering which tools the model selects, and it does not make the model reason correctly about whose authority it wields. That is why the guidance is to keep authorization in the execution component outside the agent's context, scope each tool to the minimum it needs, and require out-of-band approval for high-impact actions (OWASP AI Agent Security Cheat Sheet).

Sources

Sources

  1. Structural Tenant Isolation for Tool-Using LLM Agents
  2. Best practices for AI agent access control
  3. Considerations for Multitenant Agentic Systems
  4. Single-Tenant to Multi-Tenant Tool Calling Agent Auth
  5. AI Agent Security - OWASP Cheat Sheet Series
  6. Multi-Tenant Databases with Postgres Row-Level Security - Midnyte City
  7. Multi-tenant data isolation with PostgreSQL Row Level Security
  8. Postgres Row-Level Security: Multi-Tenant Patterns That Hold Up ...
  9. How to Secure Multi-Tenant Data with Row-Level Security in PostgreSQL
  10. HTTP Mocking for Small Tests
  11. How to create a unit test for a function that uses Python's ...
  12. RLS policies, proven - auto-generated pgTAP tests and ...
  13. Testing Overview | Supabase Docs
  14. 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