An agent decides at runtime which tool to call, with which arguments, and in which order, and it decides each step as it goes, from the text it has just read. The sequence of calls does not exist until the model produces it, so you cannot review it in advance. At the same time, the model has to be given some authority, because an agent that can reach no data is useless, which leaves the question of where that authority comes from.
The failure to prevent is the agent reaching one team’s data while it is working for another. Once that data is in the model’s context, it leaves through any reply the model writes, so no later check recovers it. Prompt injection is the sharpest version: an attacker plants an instruction inside content the agent will later read, and the agent follows it, because to the model there is no difference between text it read and text you wrote. A confused model or a badly chosen argument does the same damage, so the perimeter has to hold whatever the cause. At each stage on the way to the data, the question is the same: can the agent reach another team’s data here, and if not, what stops it.
I will work through this with one example: a support agent deployed on Amazon Bedrock AgentCore (I have covered how to deploy it with CDK here), with three tools the model is allowed to call: one reads a document by its id, one searches documents by keyword, and one appends a reply. Each document belongs to exactly one team, and I call a team a scope. This is the pool model of tenant isolation: every tenant shares one datastore, and what keeps them apart is access control on the rows of that shared data, not a separate copy of the infrastructure per tenant. AWS’s guidance for the pool is access control keyed on the tenant claim in the token, through row-level security or storage policies.
Token Validation and Tenant Scope in the AgentCore Gateway
The Gateway sits between the agent and the tools, and every request the agent makes passes through it before reaching a tool. It exposes an MCP endpoint to the agent, whatever sits behind it: Lambda functions, OpenAPI endpoints, and MCP servers all become tools an agent can call over MCP. The agent sends a JSON-RPC message naming the tool and its arguments. The Gateway translates that message before invoking the target, so a Lambda tool is never handed MCP: it gets its arguments as an ordinary Lambda event, one key per argument, and the tool’s name arrives separately in the client context. Whatever runs on the Gateway’s side of that translation sees the JSON-RPC, and the tool sees the event.
The checks inside run in a fixed order: the JWT authorizer validates the token, a REQUEST interceptor can rewrite the request, the Cedar policy engine allows or denies it, the target runs, and a RESPONSE interceptor can transform the answer on the way back. The interceptor running before the policy engine is what lets one add facts to a request that the other then decides on. A gateway takes at most one interceptor of each kind.
When a request reaches the Gateway, its JWT authorizer validates the token: it checks the signature against the Cognito pool’s keys, the issuer, and the expiry, and it turns away any client that is not on its allowlist. This blocks the plain cases, a forged token, an expired one, an anonymous request, a client that has no business here.
From here on, the token is valid and the issuer is trusted (the cases where they are not sit outside this design). The case that remains is an authenticated operator, working legitimately on one team, who can still emit a request that names a document from another team, because the model writes the request’s arguments from whatever text it just read.
The scope the operator serves is stated in their signed token: each team is a Cognito group, and the token lists it in the cognito:groups claim. Interceptors are hook points where the Gateway runs code you write around each request (Lambda functions, wired so the Gateway is their only caller), one before the tool runs and one after it answers. The REQUEST interceptor, the one that runs before the tool, reads that claim and writes the scope into the request.
Act-on-Behalf Credentials with a Token Vending Machine on DynamoDB
The documents live in DynamoDB, keyed by (scope, document id), so PAY-001 in payments-core lives at the key (payments-core, PAY-001). Every piece of code in AWS runs with a role that carries permissions, and the obvious design gives the read tool’s Lambda a role with read permission on the documents table. That design lets the tool read any partition of any team, and an injection that steers the tool then reaches all of them. So the tool’s Lambda does not get that role.
Its own role carries no permission on the documents table, and none to assume any other role, so on its own the tool reaches nothing. The permission to read the table lives in a separate role that only the interceptor can assume, because its trust policy names the interceptor’s role and nothing else, not the account root and nothing account-wide. That read role is one static role, deployed once and shared by every tenant; nothing is created per request.
On each request, the interceptor assumes it through STS and, on the same AssumeRole call, attaches a session policy that narrows the returned credentials to one partition, the served scope taken from the token in the previous layer. The role stays fixed across every request; only the session it hands back changes. STS returns credentials good for the intersection of the role and the session policy: read the table, but only that partition. The condition that pins the partition is dynamodb:LeadingKeys, which constrains the partition key of the request.
Narrowing one broad role per request this way is the Token Vending Machine pattern (I have worked through it on its own, outside the agent case). This is an act-on-behalf mechanism: each downstream target receives a separate, least-privileged token scoped specifically for that service, and nothing in the chain ever holds a credential broader than the request it serves.
The session policy the interceptor attaches to that AssumeRole is where the narrowing happens:
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:...:table/Documents",
"Condition": {
"ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["payments-core"] },
"DateLessThan": { "aws:CurrentTime": "2026-07-15T10:32:07Z" }
}
}
The two conditions bound the credentials in space and in time. dynamodb:LeadingKeys pins them to one partition. The DateLessThan on aws:CurrentTime pins them to one minute, and it is there because AssumeRole will not issue credentials for less than 900 seconds while the tool invocation they exist for takes a fraction of a second. Without it the interceptor hands out a fifteen-minute key for a job that lasts one. A live credential sitting around for fifteen minutes after its one use is over makes me uncomfortable, so out of extra caution I wanted a way to bound how long it can actually be used, which is what the DateLessThan gives. The interceptor computes that timestamp as it builds the policy, taking the current time and adding sixty seconds (an example rather than a rule: the window has to cover the tool’s own execution). The credentials still carry their fifteen minutes, but after the timestamp the Allow stops matching, so every request they make is denied, with an AccessDeniedException, not an ExpiredToken: the STS session is still valid, the token has not expired, and the only thing refusing the request is the DateLessThan in the session policy.
GetItem covers the read. Query covers the search, because a search here always runs inside one tenant: the partition is the served scope, so there is always a partition key to query on, and the keyword is matched as a filter on top of that partition. Scan exists for the case where you do not know which partition to look in, and that case never arises, because reaching across partitions is the failure this whole design prevents.
Keep
Scanout of the actions.dynamodb:LeadingKeysconstrains the partition key that a request names, and a Scan names none, so the condition has nothing to check and passes. The Allow then applies in full, the Scan reads every tenant, and the policy still looks correct on the page. IfScanhas to be in there, add"Null": { "dynamodb:LeadingKeys": "false" }, which requires the key to be present and so denies the Scan. A scoped Scan cannot exist here, and theNullonly makes the Scan fail. AWS advises the pairing whereverForAllValuessits under anAllow.A secondary index keyed on the keyword breaks the same condition from the other side. A Query against it names a partition key, so
ForAllValueshas something to check, but the key it names is the keyword rather than the tenant, and the search crosses scopes. Keep such an index out of the role’s resources.
This holds under injection. The tool’s code cannot widen its own access: it can assume no stronger role and has no permissions of its own, so it reaches the database only through the credentials it was handed. Whatever an injection makes it run, that code still reaches one partition and no other.
Credential Injection Through the REQUEST Interceptor
Handing the credentials over means rewriting the request on its way to the tool. The model writes that request with the arguments in the tool’s published schema:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "support___read_document",
"arguments": { "doc_id": "BIL-002" }
}
}
The Gateway validates the JWT and then invokes the interceptor Lambda with that request wrapped in an event. The interceptor runs on the Gateway’s MCP side, before the translation to the target, so it sees the JSON-RPC whatever the target happens to be. The message arrives under mcp.gatewayRequest.body.
The headers reach the interceptor only if
passRequestHeadersis set, and it is off by default. AWS advises caution about turning it on, since request headers carry authentication tokens and credentials.
{
"interceptorInputVersion": "1.0",
"mcp": {
"rawGatewayRequest": { "body": "<raw_request_body>" },
"gatewayRequest": {
"path": "/mcp",
"httpMethod": "POST",
"headers": { "authorization": "Bearer ..." },
"body": {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "support___read_document",
"arguments": { "doc_id": "BIL-002" }
}
}
}
}
}
The interceptor reads the token out of that header, and one detail is worth knowing: the Gateway negotiates HTTP/2, which forces header names to lowercase, so Authorization arrives as authorization. A case-sensitive headers.get("Authorization") finds nothing and the interceptor fails closed on a valid token, so match the header name without regard to case.
Decoded, the token carries the claim the design turns on:
{
"sub": "9f8c2b1a-3d4e-5f60-7a8b-9c0d1e2f3a4b",
"cognito:groups": ["payments-core"],
"token_use": "access",
"client_id": "7g8h9i0j1k2l3m4n5o6p",
"exp": 1784073600
}
cognito:groups holds the operator’s team. The token sits in a header, and the model writes only the request’s arguments, so it cannot forge this value. The interceptor takes that value, calls sts:AssumeRole with a session policy pinned to that scope’s partition, and writes the credentials it gets back into the request’s arguments, replacing anything the model put there. AWS sets out the same route in its post on Policy and Lambda interceptors.
# the tenant comes from the token, not from the call
served_scope = resolve_single_scope(claims["cognito:groups"])
# scope pins the partition, expiry pins the window: one partition, one minute
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=60)).strftime("%Y-%m-%dT%H:%M:%SZ")
session_policy = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query"],
"Resource": TABLE_ARN,
"Condition": {
"ForAllValues:StringEquals": {"dynamodb:LeadingKeys": [served_scope]},
"DateLessThan": {"aws:CurrentTime": expires_at},
},
}],
}
raw = sts.assume_role(
RoleArn=READ_ROLE_ARN,
Policy=json.dumps(session_policy),
DurationSeconds=900,
)["Credentials"] # AccessKeyId, SecretAccessKey, SessionToken, Expiration
# map the three fields the tool needs, by name. The STS response also carries an
# Expiration, which is a datetime and would break json.dumps, so it is left out,
# and the keys are renamed to the snake_case the tool reads.
creds = {
"access_key_id": raw["AccessKeyId"],
"secret_access_key": raw["SecretAccessKey"],
"session_token": raw["SessionToken"],
}
# a copy, not the event's own object
body = copy.deepcopy(event["mcp"]["gatewayRequest"]["body"])
# injected into the call the Gateway forwards to the tool
body["params"]["arguments"]["context"] = {
"served_scope": served_scope,
"tenant_credentials": creds,
}
return {
"interceptorOutputVersion": "1.0",
"mcp": {"transformedGatewayRequest": {"body": body}},
}
event["mcp"]["gatewayRequest"]["body"] gives back a reference to the object sitting inside event, not a new one, so writing the credentials into it makes them part of event itself. Logging the incoming event is the first line of most Lambda handlers, and error paths dump it too, so from that point the credentials go wherever the event goes: CloudWatch, traces, alerts. Copying first keeps them confined to the object that goes to the tool.
The body the Gateway forwards to the tool is the same message with one key added, context, sitting inside params.arguments next to the argument the model wrote:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "support___read_document",
"arguments": {
"doc_id": "BIL-002",
"context": {
"served_scope": "payments-core",
"tenant_credentials": {
"access_key_id": "ASIA...",
"secret_access_key": "...",
"session_token": "..."
}
}
}
}
}
On the other side of the translation, a Lambda target receives the contents of params.arguments as its event, one key per argument, with the tool’s own name in context.client_context.custom['bedrockAgentCoreToolName']. The injected context is an argument like any other, so it lands at event["context"]. The tool builds its client from what it was given:
def lambda_handler(event, _ctx):
# the tool's own role has no access; these are the only credentials it holds.
# the field names ride as snake_case and map onto boto3's kwargs by hand,
# because boto3 expects aws_access_key_id, not access_key_id.
creds = event["context"]["tenant_credentials"]
ddb = boto3.client(
"dynamodb",
aws_access_key_id=creds["access_key_id"],
aws_secret_access_key=creds["secret_access_key"],
aws_session_token=creds["session_token"],
)
return ddb.get_item(
TableName=TABLE,
Key={"scope": event["context"]["served_scope"], "doc_id": event["doc_id"]},
)
There is no if in that function comparing the document’s team to the caller’s. The credentials are pinned to payments-core, BIL-002 lives in the billing partition, and the key simply is not there, so the request returns nothing found. The refusal is a property of the credentials rather than a branch in the code, which is why an injection that reaches this code changes nothing.
One thing this handler must not do is move the client to module level to save a cold start. Lambda reuses a warm process, so the same container serves one tenant and then another; a client built once from the first request’s credentials would still be there for the next request, reading the wrong partition with no error. The client is built inside the handler, from event["context"], on every invocation.
Credential Exposure Paths: Tool Responses and Gateway Logs
Those credentials landed in params.arguments, which is the same object the model writes. They are not in a private side channel. The direction of travel is what keeps them away from the model: the model wrote its request, the interceptor added the credentials afterwards, and the enriched body flows only onward to the tool. Trace what each side holds and the asymmetry is the whole protection:
| Stage | Sees the credentials |
|---|---|
| Model’s outgoing request | No, it wrote it before the interceptor ran |
| Transformed request (Gateway to tool) | Yes |
| Tool’s response, which is what returns to the model | Only if the tool puts them there |
That last row is the only way back to the model, and the logs are a second way out. Two things you must AVOID:
- A tool echoing its own arguments. Error handlers do it constantly: a
return {"error": f"bad arguments: {arguments}"}on a malformed request sends the credentials back through the Gateway and into the model’s context, and from there into whatever reply the model writes. A tool never returns its argument object, in an error or anywhere else; build the message from named fields instead. - Logging the body. The interceptor and the tool log what you tell them to, so the rule there is that nothing logs a request without stripping
contextfirst. The harder case is the Gateway itself: it can vend request and response bodies to CloudWatch when you turn that delivery on, and the body it vends is the rewritten one, credentials included. Since nothing scrubs that capture, the mitigation is to keep application-log delivery off, so the request body is never recorded in the first place.
Killing the credentials the moment the tool answers would be better still, but it is not on offer. STS credentials cannot be revoked once issued, and neither STS nor IAM has a RevokeSession call. The expiry in the session policy is the mitigation: a leaked set of these credentials is worth one tenant’s partition for one minute.
The RESPONSE Interceptor Stays Optional
The response that returns to the model carries no credentials, because the tool never puts them there. A tool returns the document body and its scope, or a generic error, and never its own arguments, so the context that holds the credentials does not travel back. This is the direction of travel again: credentials flow forward into the tool and the tool does not hand them back. There is nothing in the normal response to remove.
A RESPONSE interceptor scrubbing the reply is therefore not part of the protection, only insurance for the day someone writes a tool that leaks its arguments into its own output. If you do add one, it is more work than it looks. The natural approach is to walk the response as structured data and drop the credential fields by name, but the Gateway has already flattened that structure: it serializes a tool’s object into a single JSON string at result.content[0].text, so by the time a scrubber runs the credentials would be text inside that string, with no fields left to match on. Opening the string to reach them trades one failure for another, missing a credential buried in prose, corrupting a legitimate document that happens to match a pattern, or throwing on a reply that is not JSON. It is a filter to write carefully or not at all, and nothing the isolation depends on.
The Limits of Cedar Policies for Tenant Data Isolation
Amazon Bedrock AgentCore has an authorization mechanism of its own, AgentCore Policy. Its rules are written in Cedar, an open-source policy language, and the Gateway evaluates them on every tool invocation before the target runs.
Cedar decides one request at a time, comparing values already in front of it: the principal from the token, the action, the resource, and the arguments the model chose plus anything an interceptor added. It can test equality, set membership, ordering, and simple string patterns. What it cannot do is look anything up.
// supported: compare values already carried in the request
when { context.input.served_scope == "payments-core" }
// not supported: there is no way to query or call out
when { context.input.owner == lookup(context.input.doc_id) }
This limit of Cedar is the reason I have not used it so far. To stay with the example, when the agent on payments-core asks to read BIL-002, a billing document, Cedar sees an authenticated operator calling read_document with an id. It cannot tell that BIL-002 belongs to billing, because telling would mean looking the document up. A valid read and a cross-team read arrive as the same shape, and on its own Cedar permits both. The rule that would stop it needs the owning team in the request, and the model never sent it. You can hand Cedar the fact from an interceptor and have it decide, which is the composition AWS sets out. But it adds nothing to this design: the scoped credentials already close this read, and the item is simply not at the key. A forbid would also tell the caller that a control exists and that the document is real, where the credentials return a plain not-found that gives an attacker nothing.
The search is sharper still. search_documents("refund") names no resource at all, and the result set does not exist until the query runs, so Cedar has nothing to judge even in principle and permits every search.
None of this makes Cedar useless. Deciding which groups may call which tools is exactly its job: declarative, auditable, no lookups needed. That is a different question from which rows a request may touch.
Configuration Checklist for Multi-Tenant Data Isolation
To sum up: the isolation is not one control but a set of them, each on a different component, and it holds only if every one is in place. Every control below sits on a different piece of the system; the table gives what each one is configured to be, and the job it does.
| Component | How it is configured | What it guarantees |
|---|---|---|
| DynamoDB table | Composite key (scope, doc_id), one partition per team | Each team’s documents sit in their own partition, addressable by scope |
| Cognito | One group per scope; the operator’s team lands in the cognito:groups claim of the signed token | The scope comes from a signed claim, out of the model’s reach |
| Read role (and write role) | One static role, actions limited to dynamodb:GetItem and dynamodb:Query (read) or dynamodb:UpdateItem (write), never Scan | The only path to the table, held by no tool; Scan is absent so nothing can read across partitions |
| Role trust policy | Names the interceptor’s role as the sole principal allowed to assume it | Only the interceptor can obtain credentials from the role |
| Session policy | dynamodb:LeadingKeys set to the served scope, DateLessThan on aws:CurrentTime, plus a Null guard on LeadingKeys | Credentials reach one partition only, and stop working after one minute |
| REQUEST interceptor | Reads the claim, assumes the role with the session policy, injects the credentials into the request under context on a deep copy of the body | The scope is set from the token, and the credentials stay out of the event, the logs, and the model’s context |
| The three tools | No data-plane permission on their own role; each builds its client only from the injected context | A tool can reach the table only with credentials it was handed, never on its own authority |
| Per-invocation state | Both the interceptor and the tools build their scope, credentials, and clients inside each invocation, never cached at module level | A warm container reused across tenants cannot serve one tenant with another’s leftover scope or client |
| Cedar permits | One permit per tool, deciding which groups may invoke which tool | Each group can invoke only the tools it is meant to |
| Log delivery | Application-log delivery off | The rewritten request body, credentials and all, never lands in CloudWatch |
The protection does not live in one component. Every row in the table above carries part of it, and the guarantee holds only as long as all of them hold together. This is why the components cannot be designed separately: a weakness in any one undoes the others, even when every other piece is correct.
These pieces are one security boundary, and they have to be built as one.
