Distribuée
Cloud & AI Security
← All insights

AI security

AI agents calling your APIs: least privilege, hop by hop

A Bedrock agent on a single IAM role is a shared admin account. Workload identity, On-Behalf-Of token exchange, per-tool Gateway authorization: the method and artefacts.

· 7 min · #bedrock#agentcore#ai-agents#iam#owasp-llm#least-privilege#oidc

In most AI platforms I audit, the agent runs under a single IAM execution role, and that role can do roughly everything the agent might ever need: read customer records, write to the CRM, call the billing API. It’s simpler to build. It’s also, in effect, a shared admin account used by every user of the agent — and by anyone who manages to make it execute an instruction it shouldn’t have followed.

The agent is no longer a chatbot answering questions. It calls tools, internal APIs, databases, sometimes other agents. OWASP calls this “excessive agency”: too much reachable functionality, too much permission per function, too much autonomy without human validation on high-impact actions — one of the most expanded categories in the 2025 LLM Top 10. In December 2025, OWASP published a dedicated framework, the Top 10 for Agentic Applications, specifically addressing identity and tool misuse across multi-agent chains.

This article documents the control I put in place on engagements: a dedicated identity per agent, a scoped token at every hop, per-tool authorization, and the evidence to produce for an auditor or a client. The technical thread here is AWS Bedrock AgentCore Identity, which gained in April 2026 exactly the piece that was missing to do this properly on AWS.

Multi-hop AI agent: a scoped token per hop, not a single shared role

The threat: one IAM role for the whole agent

The classic production agent pattern has three links, and each has a different blast radius if compromised:

User → Agent. The user sends a request, possibly along with a document, an email, or a web page the agent reads. This is the entry point for prompt injection — usually indirect, hidden in content the agent treats as data but that the model partly interprets as instruction. An OWASP researcher put it plainly at Infosecurity Europe 2026: the problem isn’t solved at a fundamental level, because an LLM processes everything as a single token sequence with no native privilege boundary.

Agent → Tool A. The agent translates an intent (legitimate or injected) into a tool call: a SQL query, an API call, a ticket write. If the agent runs under a single, broad IAM role — the case I see most often — that call executes with the service’s rights, not the rights of the user who started the conversation.

Tool A → Tool B. Modern agents chain calls: read a ticket, then call a payment API, then write to a CRM. Each additional hop multiplies the blast radius of a single identity compromised upstream.

The common thread across the incidents I see on engagements isn’t a sophisticated exploit. It’s an agent with one service role, able to act for any user with the same rights — and therefore, under injection, able to reach any user’s data or trigger any action that role allows.

The control: one identity per agent, a scoped token at every hop

Step 1 — a workload identity, distinct from the execution role

Amazon Bedrock AgentCore Identity separates the agent’s identity (the workload identity) from the IAM execution role that runs the code. The execution role no longer needs direct access to DynamoDB, an internal API, or a third-party service: it only has the right to exchange a token via sts:AssumeRoleWithWebIdentity and GetWorkloadAccessTokenForJWT. In practice, even if the agent’s code is manipulated through prompt injection, it can’t improvise an access path that bypasses this exchange.

Step 2 — exchange the user token for a scoped token (On-Behalf-Of)

This is the piece that was missing on AWS until April 2026: AgentCore Identity now supports On-Behalf-Of token exchange (RFC 8693). The principle: the agent never holds the target service’s credentials. It presents its workload token to AgentCore Identity, which fetches the client secret from Secrets Manager and performs the RFC 8693 exchange with the service — the agent only ever sees the final scoped token, cryptographically bound to the authenticated user’s sub. It cannot request a token for a user other than the one who initiated the call.

from bedrock_agentcore.identity.auth import requires_access_token

@requires_access_token(
    provider_name="internal-billing-api",
    scopes=["invoices:read"],
    auth_flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
)
def _get_billing_token(*, access_token: str) -> str:
    return access_token

@tool
def get_customer_invoices(customer_id: str) -> str:
    token = _get_billing_token()
    resp = requests.get(
        f"{BILLING_API_URL}/customers/{customer_id}/invoices",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    return resp.json()

On the IAM side, the agent’s execution role only gets what’s strictly needed to perform the exchange — never a broad, direct data access. For AWS resources reachable directly (DynamoDB, S3), scope further with a condition on a JWT claim, for example the user’s department propagated from Cognito:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:Query"],
      "Resource": "arn:aws:dynamodb:eu-west-1:123456789012:table/CustomerRecords",
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": ["${aws:PrincipalTag/department}"]
        }
      }
    }
  ]
}

Result: an HR agent structurally cannot read finance department records, no matter what an injected prompt tries to make it do — the decision is enforced by IAM, not by the system prompt.

Step 3 — authorize every tool, not just every agent

An agent with access to an AgentCore Gateway exposing ten tools shouldn’t be able to call all of them under the same identity. Gateway interceptors run before (REQUEST) or after (RESPONSE) the call reaches the target, and let you filter by JWT scope at the tool level, not just the gateway level:

def lambda_handler(event, context):
    body = event["mcp"]["gatewayRequest"]["body"]
    token = event["mcp"]["gatewayRequest"]["headers"].get("Authorization", "").replace("Bearer ", "")
    scopes = decode_jwt_payload(token).get("scope", "").split()

    target, tool = body.get("params", {}).get("name", "__").split("___")
    authorized = target in scopes or f"{target}:{tool}" in scopes

    if not authorized:
        return {
            "interceptorOutputVersion": "1.0",
            "mcp": {"transformedGatewayResponse": {
                "statusCode": 403,
                "headers": {},
                "body": {"error": f"missing scope for {target}:{tool}"},
            }},
        }
    return {
        "interceptorOutputVersion": "1.0",
        "mcp": {"transformedGatewayRequest": {"headers": event["mcp"]["gatewayRequest"]["headers"], "body": body}},
    }

With this filter, an agent holding the billing-api:getInvoices scope but not crm-api:deleteContact gets the second call rejected before it ever reaches the CRM — the check is no longer left to the system prompt’s discretion.

Step 4 — human confirmation on high-impact actions

OWASP’s third root cause under “excessive agency” is unbounded autonomy: an agent able to delete, transfer, or publish without validation. In practice, this means an explicit list of tools flagged requires_human_review: true (deletion, payment, external send), intercepted at the Gateway to trigger a pending state instead of a direct call, and confirmation carried over a channel outside the model’s control (UI, Slack, ticket) — never a simple question asked inside the same conversation as the potentially injected prompt.

Evidence for the auditor

What I hand over at the end of an engagement, usable as-is:

# Agent execution role: no broad direct access, only the token exchange
aws iam get-role-policy --role-name agentcore-billing-agent-exec --policy-name workload-token-exchange

# Confirm no attached policy grants a wide *-style data access
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:eu-west-1:123456789012:analyzer/agents-prod \
  --filter '{"resource":{"contains":["agentcore-billing-agent-exec"]}}'

# Per-invocation traceability: reconstruct the full user -> agent -> tool chain
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=InvokeAgentRuntime \
  --start-time "$(date -u -d '24 hours ago' +%FT%TZ)" \
  | jq '.Events[] | {eventTime, username, requestParameters}'

Expected outcome: the execution role only carries the token exchange, IAM Access Analyzer finds no cross-tenant access on that role, and every CloudTrail invocation ties back to an identified user — not a generic service account. That triptych, not a screenshot of the system prompt, is what answers a SOC2 auditor’s or a client’s question of “what can this agent actually do, and on whose behalf.”

Conclusion

Least privilege on an AI agent isn’t a prompt-engineering checklist — it’s an identity architecture: one workload identity per agent, a scoped token bound to the user at every hop, per-tool authorization, human confirmation on high-impact actions. If your Bedrock or RAG platform is in production and nobody has precisely answered “what can this agent do, and for whom,” the AI & Bedrock Security Audit covers exactly that scope in two weeks.

Sources: AWS Security Blog — Propagate user authorization context in AI agents with Amazon Bedrock AgentCore, AWS — Bedrock AgentCore Identity OBO token exchange (April 2026), AWS ML Blog — Fine-grained access control with AgentCore Gateway interceptors, OWASP Top 10 for Agentic Applications (2026), OWASP Top 10 for LLM Applications (2025), RFC 8693 — OAuth 2.0 Token Exchange.

Found this useful? Share it.

Go further

A topic, a project, a question?

Distribuée supports demanding SMBs on cloud security, CI/CD supply chain and AI security.

Book 15 min