Security
Key points

Key points

  1. 1A presenter identity is one dedicated non-human account bound to exactly one acting human, while a shared service account is one credential reused by many users or agents.
  2. 2The shared account fails a basic audit test because the log records the account, not the person or tenant behind the call.
  3. 3Safety comes from a signed, short-lived token carrying the user id and tenant id, plus enforcement in deterministic code below the model.
  4. 4A replay test that sends the same token to a second tenant, and expects a deny plus an audit record, proves isolation cheaply.

The two identities, and why the difference matters

When an AI agent calls your invoice API, the downstream service must answer one question before anything else: which identity should it see? If every call carries one shared service identity, the downstream system may only know the agent application, not which user initiated the operation or which tenant owns the data 1. That single fact drives everything else here.

A presenter identity is a separate, non-human account that acts for exactly one human user. It has its own credentials, its own lifecycle, and a hard binding to one user id and one tenant id. A shared service account is one credential many users, agents or background jobs reuse. It is simpler to operate, and it hides the actor.

Microsoft's architecture guidance states the risk plainly: a multitenant agent's identity typically has permissions to work across multiple tenants but does not inherently understand tenant context, so boundaries must be enforced in deterministic code, not the identity itself 2. The presenter design pushes that enforcement one step earlier, into the credential, which is the point.

The decision rule: if two people can ever cause the same credential to be used, the credential cannot attribute the action. If attribution is a requirement (and for anything touching customer data it is), you need one identity per acting user.

What the shared account leaves in the audit log

Here is the failure stated as a test you can run on your own logs. Take one write action performed for user A in tenant A, and one for user B in tenant B. Ask whether the two log lines differ in the fields that identify the human and the tenant.

With a shared service account they usually do not. Both lines show the same principal, the same credential id and the same client. The user and tenant, if they appear at all, sit in a request body the caller controls. WorkOS draws the same contrast: a service account produces one log line, while an agent's authority is a chain of delegation that should be traceable to the original authorization event 1. A shared account collapses that chain into a single anonymous hop.

That is why the shared account fails an audit test rather than merely looking untidy. Investigators and auditors need to reconstruct who acted, on whose behalf, and under which tenant. If the values that answer those questions are parameters rather than claims bound to a credential, the log proves only that the service account made a call.

The second failure is worse. When a credential spans several tenants, cross-tenant access requires breaking nothing. Scalekit describes a concrete case: a background job with no customer context in execution scope resolves the first stored token it finds and updates records in the wrong organization, and the downstream API accepts the call because the token was valid 3. No exception, no 403. The log shows a legitimate call by the shared account.

Research on tool-using agents formalizes this. When a tenant identifier is a tool parameter, the effective authority of the request is bounded by the model's prompt rather than by the caller's credential, because the arguments the model supplies are the output of a stochastic process whose context an attacker can partly reach 4. In the reported ablation, a correctly validated tenant parameter served every out-of-scope pretext attempt: 26 of 26. Removing the parameter from the tool schema was what closed the path.

A worked example: two tenants, one token

Make it numeric. Suppose the agent platform holds two delegated grants, one for Alice in tenant A and one for Bob in tenant B, and an hourly job reconciles pending invoices. With a shared account, the job runs with no tenant in scope.

  • Calls in one hour: the job processes 400 invoices across both tenants.
  • Calls attributable to a human from the log alone: 0, because every line names the service account.
  • Calls that could land in the wrong tenant: any of the 400, because nothing in the credential narrows the set.
  • Time to detect: in the case Scalekit describes, the anomaly surfaced six weeks later, in a customer review 3.

Now run the same job with presenter identities. Each work item carries a presenter token issued for one user and one tenant, valid for a few minutes and bound to a specific audience.

  • Calls attributable to a human: 400, because the token subject is the user and the tenant is a signed claim.
  • Calls that could land in the wrong tenant: those whose token names the other tenant, which the target service rejects before reading a row.
  • Time to detect: the duration of one request, because the deny is logged with both the claimed tenant and the resource tenant.

The numbers are illustrative, but the ratio is the insight: a shared account gives you one attributable principal for every call it makes, however many humans sit behind them.

The schema: bind user and tenant to the credential

The presenter record is small. Keep it in its own table, apart from human users and apart from any service-account registry, because its lifecycle differs: created when a user first delegates, suspended when the delegation is revoked, deleted when the user is deprovisioned.

FieldTypeWhy it exists
presenter_idopaque string, primary keyThe identity itself, never reused across users
user_idforeign key to usersThe one human this presenter acts for
tenant_idforeign key to tenantsThe one tenant boundary this presenter is confined to
scopesarray of stringsFine-grained capabilities, not roles
client_idstringThe agent application, so the token is audience-bound
statusactive, suspended, revokedRevocation must be a state change, not a delete
created_at, revoked_attimestampsProvenance for the audit log

Two rules make this schema do the work. First, presenter_id maps to exactly one user_id and one tenant_id, enforced by a unique constraint, so a record cannot represent two people. Second, no caller supplies tenant_id as a request parameter to a tool. The tenant comes from the presenter record, travels in the token, and the tool schema has no field for it, which is the structural defense the arXiv work recommends 4.

WorkOS recommends minute-scale token lifetimes and just-in-time issuance over long-lived secrets, with every cache where a credential might persist inventoried 5. The presenter record is the administrative anchor; the token is its short-lived projection.

The token: short, signed, and audience-bound

The token should carry actor and subject separately. In the delegation pattern Scalekit describes, a token can hold both the agent identity and the user identity as nested claims, so downstream services can enforce policy and log actions against the right human 6. OAuth 2.0 Token Exchange is the standardized way to mint such a token: present the user's token, name the audience, and receive a narrower token addressed to one downstream API 7. The audience parameter pins the issued token to a target and prevents cross-server reuse 8.

A presenter token should satisfy all of these:

  1. Subject is the user id. Actor is the presenter id. Neither is readable from the request body.
  2. Tenant id is a signed claim, not a query parameter.
  3. Audience names the one service the token is for.
  4. Lifetime is minutes, not days, and issuance happens just in time.
  5. Scope is a narrow verb on a narrow resource, checked by a gateway the model cannot instruct.

A minimal verification step in Python, assuming you decode and signature-check the token with your identity provider library first:

def admit(token_claims, resource_tenant_id, required_scope):
    # token_claims come from a verified signature, never from the request body
    if token_claims["aud"] != SERVICE_AUDIENCE:
        return deny("wrong audience")
    if token_claims["tenant_id"] != resource_tenant_id:
        return deny("tenant mismatch")
    if required_scope not in token_claims["scope"]:
        return deny("scope not granted")
    return allow(user_id=token_claims["sub"], presenter_id=token_claims["act"])

The deny path must write an audit record naming the claimed tenant and the requested tenant. That record is the artifact your replay test asserts on.

One more rule: the presenter's effective authority is the intersection of what the presenter is allowed and what the user is allowed, never the union 5. A user who loses access in the identity provider should lose it in the agent at the next issuance, not at the next rotation. Auth0 makes the adjacent point that fine-grained scopes let you block any tool call not explicitly granted to the agent, which limits what a successful prompt injection can reach 9.

The replay test: same token, second tenant, expect deny

This is the test that proves the design, and it costs less than any penetration test. Issue a real presenter token for a user in tenant A. Send it to a resource owned by tenant B. Assert three things: the response is a deny, the deny is a 403 rather than a 404 that leaks existence, and an audit record exists with the presenter id, the user id, the claimed tenant and the target tenant.

Keep the test small and hermetic. Small tests should finish in under a second and cannot touch the network, so mock HTTP at the library level and let your own authorization code run normally 10. pytest-httpx provides a fixture that intercepts client calls and returns canned responses, which is exactly the shape needed for a cross-tenant request 10. If your code uses the standard library client, patch the connection object with a mock and set status and body explicitly, a pattern documented for that client 11.

def test_token_cannot_replay_into_second_tenant(httpx_mock, audit_log):
    token = issue_presenter_token(user="alice", tenant="tenant-a", aud="billing")
    httpx_mock.add_response(url="https://billing.internal/tenants/tenant-b/invoices",
                            status_code=403, json={"error": "tenant mismatch"})
    resp = billing_client.post("/tenants/tenant-b/invoices",
                               headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 403
    assert audit_log.last.presenter_id == "presenter-alice"
    assert audit_log.last.target_tenant == "tenant-b"

Three more cases belong in the same file, because each catches a different mistake:

CaseInputExpectedWhat it catches
Cross-tenant replayToken for A sent to B403 plus audit recordTenant taken from request, not token
Wrong audienceToken for billing sent to CRM403Audience not validated
Expired tokenToken past its lifetime401Long-lived credentials
Revoked userToken valid, presenter suspended403Authority not intersected with user status

Run these on every build. A design that passes all four is safe to extend. A design that passes only the first is one parameter change away from the failure Scalekit describes 3.

Choosing between the two, by what the log can prove

The comparison comes down to what each option leaves behind after an incident.

QuestionShared service accountPresenter identity
Who acted?The account, alwaysThe user, from the token subject
Which tenant?A request parameterA signed claim
Can one call reach two tenants?Yes, if the credential spans themNo, the tenant is bound
Blast radius of a leaked credentialEvery user and tenant it servesOne user in one tenant until expiry
Revocation granularityAll users at onceOne presenter, one user
Operational costOne secret to rotateOne record per user, issued just in time
Audit answer after an incidentThe service account did itNamed user, named tenant, named presenter

Use the shared account only where the actor genuinely is the account: scheduled jobs with no human behind them, health checks, machine-to-machine calls where the caller is the principal. The moment a human's intent drives the call, the shared account has already lost the information you will need. Azure's guidance lists agent identity as the first place tenant context is lost, precisely because the identity carries no implicit tenant 2. OWASP's agent security sheet adds the operational half: grant the minimum tools per task, scope permissions per tool, and enforce authorization in the execution component rather than in the agent's context, failing closed for unknown tools 12.

If you are still deciding how agents reach models at all, that is a separate choice from identity and worth making separately. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key, so your presenter design stays the same whichever model the agent calls (https://quantaintelligence.ai/inference).

Rolling it out without a big-bang migration

Do not rewrite every caller. Sequence the work in four steps, each useful on its own.

  1. Instrument first. Add presenter_id, user_id, tenant_id and target_tenant_id to your audit schema, and log the request-body tenant alongside anything the credential claims. Once the fields exist you can measure how often they disagree, and that number is your business case.
  2. Issue tokens for new integrations only. New agent connections get presenter identities and audience-bound tokens from day one, so the code path exists before anyone depends on it.
  3. Move the highest-risk tool calls next. Anything writing to a system you do not own, such as a CRM, a ticketing tool or a messaging channel, goes first, because those actions are external and irreversible 3.
  4. Retire shared accounts last, and only after the replay tests pass for every presenter in production. Keep the shared account for jobs whose actor truly is the account.

Two operational details decide whether this sticks. Deprovisioning must revoke presenters automatically when a user leaves, or you build the orphaned-agent problem IAM teams already struggle with, where unused tokens and unnamed owners accumulate 13. And per-user keys rather than one shared admin key make attribution possible at the agent's own boundary; Quanta for Teams gives each member their own API key inside a shared credit pool, which matches the one-identity-per-person rule (https://quantaintelligence.ai/for-teams).

If you want a second opinion on where the identity provider ends and the agent platform begins before you start step 2, a short discovery call is often enough to settle it (https://quantaintelligence.ai/calendar).

FAQ

FAQ

What exactly is a presenter identity?

A dedicated non-human account that acts for exactly one human user, with the user id and tenant id bound to the credential rather than passed as request parameters. It has its own lifecycle: created at first delegation, suspended on revocation, deleted at deprovisioning. The presenter is the actor, the user is the subject, and the token carries both.

Why can't I just validate the tenant parameter on a shared account?

Because the tenant parameter is chosen by the caller, and for an LLM agent the caller's arguments are model output shaped by a context an attacker can partly reach. The arXiv study found a correctly validated tenant parameter served 26 of 26 out-of-scope pretext attempts, and removing the parameter from the tool schema closed the path 4. Validation is not the problem; the parameter is.

How short should the token lifetime be?

Short enough that a leaked token is worthless before it is used, and issued just in time per task rather than cached for a day. WorkOS recommends minute-scale lifetimes and tracking every cache where a credential might persist 5. Pick a value your issuance path can sustain, then verify it with the expired-token test.

Does token exchange solve tenant isolation on its own?

No. Token exchange propagates identity and narrows audience, but the downstream API still has to verify that the resource belongs to the tenant named in the token. Scaled guidance treats exchange as one component, with token validation and tenant resolution as separate layers 3. Treat it as a link in a chain that ends with a resource-level ownership check.

What should the replay test assert beyond a 403?

It must assert that an audit record exists, and that the record names the presenter, the user, the tenant the token claimed and the tenant the resource belonged to. Without that assertion you have proven the request was blocked but not that you can reconstruct what happened, which is the reason for choosing a presenter identity at all. Log everything and make the log legible is the same advice WorkOS gives for agent audit generally 1.

Sources

Sources

  1. AI agents vs service accounts: Key differences and what to ...
  2. Considerations for Multitenant Agentic Systems
  3. Single-Tenant to Multi-Tenant Tool Calling Agent Auth
  4. Structural Tenant Isolation for Tool-Using LLM Agents
  5. Best practices for AI agent access control
  6. Understanding "On-Behalf-Of" in AI Agent Authentication
  7. RFC 8693 - OAuth 2.0 Token Exchange
  8. What Is OAuth Token Exchange?
  9. Access Control in the Era of AI Agents
  10. HTTP Mocking for Small Tests
  11. How to create a unit test for a function that uses Python's ...
  12. AI Agent Security - OWASP Cheat Sheet Series
  13. AI Agent Identity Access Management: 2026 Enterprise ...
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