AWS Builder Center

Securing agentic workflows: Identity propagation with Amazon Bedrock AgentCore

Learn how Amazon Bedrock AgentCore implements identity propagation for secure agentic workflows using OAuth token exchange and Lambda interceptors.

A user asks an agent: "create a Jira ticket from this meeting summary." Simple enough. Except the agent now has to call Jira. With what token, exactly?
Agents don't just think: they act. They call APIs, read files, write to databases, send messages on your behalf. The moment an agent acts on behalf of a user, you have an identity problem you didn't have before: whose identity does the agent carry, and how far down the call chain does it travel?
Two common approaches have significant limitations. Hardcoded service credentials cannot scale beyond a single user. Passing the user's JWT unchanged to downstream tools grants broader privileges than necessary and provides no audit trail for agent actions.
This article walks through the identity pattern in Amazon Bedrock AgentCore, with a focus on outbound identity and the AWS Lambda interceptor at the gateway level. At the end of the reading, you'll have a mental model you can build on.
Note: The resources discussed in this article incur AWS charges, including Amazon Bedrock AgentCore Runtime hours, Gateway requests, Lambda invocations for interceptors, and AWS KMS operations. Customer-managed KMS keys incur additional charges beyond AWS-managed keys. See AWS Pricing for current rates.

Prerequisites

This article heavily dives into OAuth and OIDC concepts. It also provides examples of AWS Identity and Access Management (AWS IAM) permissions with Amazon Bedrock AgentCore service.

The identity problem in agentic systems

Agentic workflows are multi-hop by nature: User → Agent → Tool A → Tool B → … Each hop needs an identity. Each hop has different blast radius if compromised. Passing the original user JWT through the entire chain has four significant limitations.
Privileges accumulate, they don't shrink. The user's token typically has broad scopes (read everything the user can read, write everything the user can write). Tool B doesn't need all of that. It needs one operation against one resource. Forwarding the original token violates least privilege at every single hop.
Confused deputy attacks are a significant risk. When Tool A holds the user's full-scope token, it can call other tools while impersonating the user, and downstream tools cannot distinguish between legitimate and malicious calls.
You lose the audit chain. When everything carries the same token, your access logs say "user X did this"; but they never tell you which agent acted on behalf of user X, or which tool in the chain originated the call. For incident response, this is the difference between "we know what happened" and "we have no idea what happened."
Re-prompting the user mid-flow doesn't scale. You can't OAuth-redirect every time the agent needs to talk to a new downstream system. The flow has to be silent and pre-authorized, but still scoped.
The alternative is act-on-behalf: at each hop, the calling identity exchanges its current token for a new, narrower token specifically issued for the next downstream target. The user authorizes once. The token vault and the protocol do the rest.
This is exactly the pattern AgentCore Identity implements. Let's look at the building blocks.

AgentCore Identity: the building blocks

Three primitives matter: workload identity, the token vault, and the inbound/outbound auth split. Get those three right and the rest is plumbing.

Workload identity

A workload identity is not an AWS Identity and Access Management (AWS IAM) role, and not a Amazon Cognito user. It's a new identity primitive ; agent-specific, with its own ARN ; that says who the agent is, separate from who the user is.
Two things matter about it in practice:
  • It's provisioned automatically when you create an AgentCore Runtime. You don't go define it manually.
  • It's backed by a Service-Linked Role (AWSServiceRoleForBedrockAgentCoreRuntimeIdentity): AWS manages the trust policy so you can focus on the policy.
Mental model: think of it as a passport for the agent. The user has their own passport. The agent has its own. When the agent acts for a user, both passports are involved; never one or the other.

The token vault

OAuth tokens have to live somewhere. The token vault is an AWS Key Management Service (AWS KMS)-encrypted store, scoped per agent-user pair.
The "agent-user pair" wording matters: a token stored for user A on agent X is never accessible to user B, and never accessible to agent Y. There is no cross-tenant leakage by design. Refresh is automatic: you don't write rotation code, you don't get paged at 3am because a refresh token expired silently.
For regulated workloads (financial services, healthcare), you can back the vault with a customer-managed AWS KMS key instead of an AWS-managed one.

Inbound vs. outbound auth

This split is the single most important thing to internalize.
Inbound = who can invoke the agent. Two options, mutually exclusive per runtime:
  • IAM SigV4: for service-to-service calls inside AWS. This is the default option.
  • JWT Bearer via OIDC discovery URL: IdP-agnostic, Amazon Cognito, Okta, Entra ID, anything that exposes a discovery document. This is the path for human-initiated calls.
Outbound = what the agent can access on the way out. Two flows:
  • 2LO (machine-to-machine):OAuth 2.0 client credentials. The agent authenticates as itself. Use when the downstream resource doesn't care who the human is, only that "an authorized agent" is calling.
  • 3LO (user-delegated): OAuth 2.0 authorization code. The agent acts on behalf of a specific human. Use when the downstream resource enforces user-level access (Jira tickets the user can see, GitHub repos they own, etc.).
AspectIAMAgentCore Identity
Identity typeRole / userWorkload identity
Auth protocolSigV4OAuth 2.0 / OIDC
Delegation modelAssume roleToken exchange (OBO)
Credential storagen/aAWS KMS-encrypted vault, per agent-user pair
Token lifecycleShort-lived AWS Security Token Service (AWS STS)Auto-refresh in vault

Outbound identity: passing tokens the right way

This is where the pattern earns its keep. Walk through the flow once and the rest of the design falls out naturally.

The X-Amzn-Bedrock-AgentCore-Runtime-User-Id header

In a 3LO flow, AgentCore needs to know which user the agent is acting for, so it can pull the right OAuth token from the vault. That's what this header does.
The caller includes it on the InvokeAgentRuntime call. AgentCore reads it, looks up the agent-user pair, retrieves the right token. Calling with this header requires the additional IAM action bedrock-agentcore:InvokeAgentRuntimeForUser ; separate from the standard invoke action, intentionally.
One non-negotiable rule: derive this value from authenticated principal context, never from a client-supplied parameter. If your front-end accepts a userId from the browser and forwards it to the runtime header, you've built an impersonation primitive into your own system. Take the user-id from the validated JWT, not from the request body.

The token exchange flow

Here's the 3LO flow in three steps. (CLI for clarity, in real code you'd use the SDK annotations, see "SDK Declarative Pattern")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Step 1: User authenticates with the IdP (Cognito, Okta, etc.) and gets a JWT.
# The caller invokes the agent runtime, passing the JWT and the user-id header.

# Step 2: Inside the agent, exchange the inbound JWT for a workload access token.
$ aws bedrock-agentcore get-workload-access-token-for-jwt \
--workload-name my-agent \
--user-token "<inbound-jwt>"
# → { "workloadAccessToken": "..." }

# Step 3: Exchange the workload token for a downstream-scoped token.
$ aws bedrock-agentcore get-resource-oauth2-token \
--resource-credential-provider-name github-provider \
--oauth2-flow ON_BEHALF_OF_TOKEN_EXCHANGE \
--scopes "repo:read" \
--workload-identity-token "<workload-access-token>"
# → { "accessToken": "scoped-github-token" }
The downstream call uses scoped-github-token. That token only has repo:read, only for this specific user, only against this specific provider. The original JWT never leaves the agent boundary.

On-Behalf-Of (OBO) token exchange

OBO, which was released in GA in late April 2026 , is now the recommended pattern for multi-hop propagation. It's the standardized version of step 3 above.
Two grant types are supported:
  • RFC 8693 (OAuth 2.0 Token Exchange) ; grant_type=urn:ietf:params:oauth:grant-type:token-exchange
  • RFC 7523 §2.1 (JWT Authorization Grant) ; grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
For RFC 8693, the actor token is what identifies the agent itself during the exchange (vs. the subject token, which identifies the user). Three options: M2M, AWS_IAM_ID_TOKEN_JWT, or NONE. Pick M2M if your IdP issues client-credentials tokens for the agent; pick AWS_IAM_ID_TOKEN_JWT if you're inside AWS and want IAM-issued identity; pick NONE if the downstream provider doesn't care about actor identity.
OBO is available in 14 regions at GA. Check the docs for the current list before assuming your region is covered.

SDK declarative pattern

The CLI is good for understanding the flow. In agent code you don't write any of that ; the SDK does it for you with annotations:
1
2
3
4
5
6
7
8
from bedrock_agentcore.identity.auth import requires_access_token

@requires_access_token(provider="github-oauth-provider", scopes=["repo:read"])
def list_repos(token: str):
# token is injected by the AgentCore SDK.
# No manual retrieval, no manual refresh, no boilerplate.
headers = {"Authorization": f"Bearer {token}"}
return httpx.get("https://example.com/user/repos", headers=headers).json()
@requires_access_token and @requires_api_key are the two annotations to know. They turn the entire token exchange into a single decorator.

The AWS Lambda interceptor at gateway level

Previous covered identity inside the agent. But agents talk to tools through the AgentCore Gateway, and that's where you want a second enforcement point: the place where you can decide, per request, whether this user is allowed to call this tool with this token.
That enforcement point is the Lambda interceptor.

What is AgentCore Gateway?

AgentCore Gateway is an MCP (Model Context Protocol) gateway that turns REST APIs and Lambda functions into agent-compatible tools. It is not Amazon API Gateway; different service, different purpose.
Why MCP matters: it's the standard the agent uses to discover tools (tools/list) and invoke them (tools/call). The gateway speaks MCP on the agent side and whatever protocol the underlying tool speaks (REST, Lambda) on the other side.

Interceptors: the enforcement point

Interceptors are Lambda functions you attach to a gateway at one of two points:
  • REQUEST interceptor ; runs before the gateway calls the target tool.
  • RESPONSE interceptor ; runs after the target returns, before the agent sees the result.
You can attach at most one of each per gateway. The request interceptor is where identity enforcement happens; the response interceptor is where you filter what the agent gets to see.
1
Agent → Gateway → [REQUEST interceptor] → Tool → [RESPONSE interceptor] → Agent
Two important defaults:
  • passRequestHeaders: false ; the Authorization header is not forwarded to your interceptor unless you explicitly enable it. Security-first default. Only enable it when you genuinely need the header.
  • The gateway IAM role must have lambda:InvokeFunction on your interceptor ; the CDK bind() method handles this for you.

Request interceptor mechanics

The interceptor receives the full MCP request and must return a transformed version. Both payloads are provided in JSON-RPC 2.0 format.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// Input ; what the interceptor receives
{
"interceptorInputVersion": "1.0",
"mcp": {
"gatewayRequest": {
"headers": { "Authorization": "Bearer eyJ..." },
"body": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "create_jira_ticket", "arguments": { "...": "..." } }
}
}
}
}

// Output ; what the interceptor returns
{
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayRequest": {
"headers": { "Authorization": "Bearer <scoped-downstream-token>" },
"body": { "jsonrpc": "2.0", "method": "tools/call", "params": { "...": "..." } }
}
}
}
The pattern: extract the inbound user JWT from the input, call GetResourceOauth2Token to mint a scoped downstream token, swap the Authorization header, return. The original user token never reaches the tool.

CDK setup

The alpha CDK construct keeps the wiring short:
1
2
3
4
5
6
7
8
9
10
11
import * as agentcore from '@aws-cdk/aws-bedrock-agentcore-alpha';

const interceptor = agentcore.LambdaInterceptor.forRequest(myLambdaFn, {
passRequestHeaders: true // required to receive the Authorization header
});

const gateway = new agentcore.Gateway(this, 'MyGateway', {
// ...
requestInterceptor: interceptor
});
// bind() auto-grants the gateway IAM role lambda:InvokeFunction on myLambdaFn
That's it for the plumbing. The interceptor Lambda itself is where the interesting code lives ; and that's where the use cases below come in.

Use cases

Here are some use-cases you may encounter when deploying agents.

Token passing to downstream APIs

The core use case. Agent calls GitHub, Jira, Slack, Salesforce ; anything that enforces user-level access. The interceptor extracts the inbound JWT, calls GetResourceOauth2Token against the right credential provider, injects the scoped token into the downstream call. The user's original token never crosses the gateway boundary into the tool.

Fine-grained tool access control via JWT scopes

Decode the JWT in the interceptor, check the scope claims against the requested tool name. The convention is mcp-target-<id> (full access to a target) or mcp-target-<id>:<toolName> (tool-level granularity). If the scope is missing, return an error from the interceptor ; tools/call is blocked at the gateway, before the tool runs and before the LLM sees the result. This provides an effective authorization point in the chain.

Dynamic tool discovery via response interceptor

Same idea, applied to tools/list. The response interceptor filters the returned tool list based on the user's scopes. Two benefits: smaller attack surface (the agent literally cannot ask for a tool it doesn't see), and reduced prompt-injection risk (an attacker prompting the LLM can't trick it into calling a tool that isn't in its toolbox).

Multi-tenant SaaS isolation

Single gateway, multiple tenants. Extract tenantId from the JWT claims, route to tenant-specific backends (different OAuth providers, different API endpoints, different DB connections), enforce isolation at the interceptor level. You don't deploy a gateway per tenant: you deploy one gateway and one interceptor, and the interceptor does the routing.

Audit and Chain-of-Custody

Last one, often forgotten: and the one your security team will care about most. AgentCore Observability exposes which agent acted on behalf of which user at each hop. Each OBO token carries an "Act: Agent" field that establishes the delegation chain explicitly. AWS CloudTrail captures InvokeAgentRuntime calls with the runtimeUserId. Wire all three into your SIEM and you have full chain-of-custody for every agentic action.

Security considerations and pitfalls

When deploying the pattern, be careful of the following things:
  • Never log Authorization headers in the interceptor. It's the most common mistake and the highest blast radius. Amazon CloudWatch is not where you want bearer tokens to live. Sanitize before logging.
  • Derive runtimeUserId from authenticated context, not from the client. "Don't trust the user", AgentCore Runtime provides a validated header (X-Amzn-Bedrock-AgentCore-Runtime-User-Id), use it!
  • Scope the gateway execution role tightly. Restrict it to specific interceptor Lambda ARNs. No wildcards. The gateway is a high-privilege component, treat its role accordingly.
  • Interceptors must be idempotent. The gateway may retry on failure. If your interceptor has side effects (writes a DB row, sends a notification), retries cause duplicates. Keep interceptors purely functional ; extract, transform, return. If you need side effects, do them in the tool, not in the interceptor.
  • passRequestHeaders: false is your friend. Default to off. Only flip it on when the interceptor genuinely needs the inbound headers. The fewer things that touch the Authorization header, the smaller the audit surface.
  • OBO vs. impersonation ; make the choice once, document it. The point of OBO is that downstream services receive a token scoped to the call, not to the user's full session. If you ever find yourself "just forwarding the JWT for now," you're back at square one. Forwarding the JWT defeats the entire pattern.
  • Customer-managed AWS KMS keys for regulated workloads. Default vault encryption uses AWS-managed AWS KMS keys. For workloads with key-ownership requirements (financial, healthcare, public sector), bind a customer-managed KMS key at runtime creation time.

Clean-up

Warning: Deleting these resources will permanently remove all stored OAuth tokens and credentials from the Token Vault. Ensure no active agent workflows depend on these resources before proceeding. This action cannot be undone.
Delete the following resources to avoid ongoing charges:
  1. AgentCore Gateway (created in the CDK setup section),
  2. Lambda interceptor function (myLambdaFn),
  3. AgentCore Runtime (my-agent),
  4. Resource credential providers (github-provider),
  5. Any customer-managed AWS KMS keys if you created them for the token vault
  6. Verify the Token Vault was deleted with AgentCore Runtime and no orphan is remaining.

Conclusion

The identity pattern in AgentCore is not really a feature. It's a design philosophy: every hop in an agentic workflow should carry the minimum identity needed for that hop, and no more. Workload identity says who the agent is. The token vault keeps user-scoped credentials encrypted and per-pair. OBO does the scoped exchange. The Lambda interceptor at the gateway level is where you enforce all of it at the tool boundary.
Decoupling identity logic from agent code is the part that makes this practical. Your agent doesn't know about token exchange; it calls a function decorated with @requires_access_token and gets a usable token. Your gateway interceptor doesn't know about agent prompts; it sees a JWT and an MCP request and applies a policy. Both sides stay focused, both sides stay testable.

Resources

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article