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 (Model Context Protocol) 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.
The interceptor validates the token again rather than trusting that the Gateway did, because it is the component that turns a claim into a permission and it cannot see the Gateway’s configuration. Signature, issuer and expiry establish that a token is authentic, and they say nothing about which kind of token it is. In a Cognito pool the ID token and the access token share the same signing keys (the JWKS), the same issuer, and the same cognito:groups claim, so nothing else tells them apart. The interceptor therefore also requires token_use to be access and the client_id claim to be one it knows. Both checks fail closed: a missing or unexpected value refuses the request instead of letting it through. An allow-list that was never configured refuses it too.
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.
That session policy is one lock, and on its own it is one line of code away from being skipped: a future caller, a refactor, an exception path that assumes the role without building the policy, and the credentials come back with the role’s full reach. So the scope is pinned a second time, in a place the caller does not control. The interceptor passes the scope as a session tag on the AssumeRole, the role’s own identity policy requires dynamodb:LeadingKeys to match ${aws:PrincipalTag/scope}, and the trust policy allows sts:AssumeRole and sts:TagSession only when that tag is present. An assume that carries no tag is refused at the trust boundary, before any credential exists. The two conditions have to agree, and either one alone still confines the query.
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.
Passing Scoped Credentials to the Tool as Gateway Request Headers
Passing the credentials to the tool means adding them to the request on its way through the Gateway. 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, requests credentials from STS with a session policy pinned to that scope’s partition, and returns them to the Gateway as request headers. The body of the request stays exactly as the model wrote it.
# the tenant comes from the token, not from the request
served_scope = resolve_single_scope(claims["cognito:groups"])
# session_policy as shown above: one partition, one minute
raw = sts.assume_role(
RoleArn=READ_ROLE_ARN,
Policy=json.dumps(session_policy),
DurationSeconds=900,
Tags=[{"Key": "scope", "Value": served_scope}],
)["Credentials"]
# the scope and the three credential fields travel as headers, never in the body
headers = {
"x-tvm-served-scope": served_scope,
"x-tvm-access-key-id": raw["AccessKeyId"],
"x-tvm-secret-access-key": raw["SecretAccessKey"],
"x-tvm-session-token": raw["SessionToken"],
}
# a copy of the body, forwarded unchanged
body = copy.deepcopy(event["mcp"]["gatewayRequest"]["body"])
return {
"interceptorOutputVersion": "1.0",
"mcp": {"transformedGatewayRequest": {"body": body, "headers": headers}},
}
The Gateway forwards a header to a target only when the target lists the header name in metadataConfiguration.allowedRequestHeaders, and it drops every other header. Each of the three tool targets lists the same four names. When the client and the interceptor send a header with the same name, the interceptor’s value wins.
The interceptor vends credentials only for a tools/call that names one of the three tools. Other MCP messages, such as initialize or tools/list, need no credential, and the interceptor forwards them unchanged.
That precedence protects a header only on a request where the interceptor writes it. The client of the Gateway is the agent. If the agent sends a tools/call that the interceptor cannot match to one of the three tools, the interceptor writes no header, and a header the agent added would be the only value for an allowed name. The interceptor therefore refuses that request instead of forwarding it.
A header value can be at most 4096 bytes. The session token from this AssumeRole measured 1168 bytes, but AWS documents no maximum size for it, so the interceptor fails closed on a value over the limit instead of sending a truncated credential.
The interceptor writes nothing into the body, so the body the Gateway forwards is the one the model wrote. The code copies the body anyway, as a guard: a future write lands on a copy, not on the event that the handler or an error path might log.
On the other side of the translation, a Lambda target receives the contents of params.arguments as its event, one key per argument, so the event of read_document contains only doc_id. The propagated headers arrive in the Lambda client context, under context.client_context.custom["bedrockAgentCorePropagatedHeaders"], as a map of header name to value. The Lambda target reference lists six bedrockAgentCore* fields in the client context and does not mention this one, so I confirmed the key with a test request before using it in the tools.
PROPAGATED = "bedrockAgentCorePropagatedHeaders"
def lambda_handler(event, ctx):
# the tool's own role has no access; these headers are the only credentials it gets
headers = ctx.client_context.custom[PROPAGATED]
ddb = boto3.client(
"dynamodb",
aws_access_key_id=headers["x-tvm-access-key-id"],
aws_secret_access_key=headers["x-tvm-secret-access-key"],
aws_session_token=headers["x-tvm-session-token"],
)
return ddb.get_item(
TableName=TABLE,
Key={"scope": headers["x-tvm-served-scope"], "doc_id": event["doc_id"]},
)
The tool has no fallback to the event, because a credential found in the request body would be one the model could have written. In the reference implementation the header names are matched without regard to case, and a missing header makes the tool return a generic error.
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.
It is worth being exact about how far that reach goes, because the write path shows where it stops. The reply tool takes the document id from the model, the same way the read tool does, and the partition key still comes from the interceptor, so a reply aimed at another team lands nowhere. Inside the served partition, though, the credential no longer distinguishes: dynamodb:LeadingKeys constrains the partition key, and IAM has no condition key for the sort key, so no session policy can say “this document and no other”. One half of that is closed where the read path is closed, in the engine rather than in a branch: UpdateItem is an upsert, so an invented id would have created a document, and a condition requiring the target to exist refuses the write outright. The other half stays open. An injection that reaches the reply tool can append to any document that already exists in its own team’s partition, and that is outside what this design claims.
One thing this handler must not do is move the credentialed 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 rule that decides this is about tenant identity rather than module level as such: cache nothing that carries tenant identity. The credentials carry it, and so does the client built from them, so both are constructed inside each invocation. Objects that carry none can be built once per container and reused, and doing so costs the isolation nothing. Two of them matter enough to name, because between them they were the largest cost in the whole design: the SDK’s own cache of service definitions, which is identical for every tenant, and the interceptor’s STS client, which authenticates as the interceptor’s own role and is the thing that asks for tenant credentials rather than holding any.
Credential and Tenant Data Exposure in Tool Responses and Gateway Logs
The credentials never share an object with anything the model writes. The body that reaches the tool is the one the model wrote, and the credentials travel next to it in headers that only the Gateway and the tool see. Trace what each side receives:
| Stage | Sees the credentials |
|---|---|
| Model’s outgoing request | No, it wrote the request before the interceptor ran |
| Request body, Gateway to tool | No, the body is forwarded unchanged |
| Propagated headers, Gateway to tool | Yes |
| Tool’s response, which is what returns to the model | Only if the tool copies its client context into the response |
The last row is the only way back to the model. A tool that echoes its own arguments in an error message exposes nothing, because the arguments contain only what the model sent. The rule that matters is about the client context: a tool never returns or logs the propagated headers, in an error or anywhere else.
The Gateway can deliver its own application logs to CloudWatch. I enabled that delivery on the deployed stack, with real credentials in the headers, and searched the records for the credential header names and for the known prefixes of STS keys and tokens. None of them appeared, on successful requests, on tools/list, on wrong parameters, on an unknown tool, or on the two authorization failures. The records do not contain request headers at all. The one record kind I did not test is the error message for a misconfigured target.
Even so, the application logs stay off, because of what the records do contain. Each request produces a record with the full request body, and each successful response produces a record with the full response body. For read_document that is the whole document, including any instruction an attacker planted in it, and for search_documents it is the id and title of every match. The text the agent writes with the reply tool lands in the request body of the reply request.
Requests from every team pass through the same Gateway, and the Gateway writes to one log group. A CloudWatch log group has no partition by tenant, so a single logs:FilterLogEvents permission reads everything in it. The table keeps each team in its own partition, but the log group would be a second copy of the same documents with no partition at all.
A delivery can choose which fields it writes, through the recordFields parameter of CreateDelivery. For the Gateway’s application logs, the field to choose is body as a whole, and body contains the log message, the policy decision, the request and the response together. No field selection keeps the messages and drops the documents.
IAM has no condition key for the log type of a delivery source, so no policy and no service control policy can deny application logs while permitting traces. The deploy role can create only one named delivery source, and a test checks the synthesized template for any application-log delivery. A CloudTrail rule raises an alert when someone creates a delivery source for the Gateway that is not for traces. Anyone with console access can still enable the delivery, so this control detects the change but does not prevent it.
The short expiry matters for any credential that leaves the header channel, for example through a tool that returns its client context. STS credentials cannot be revoked once issued, and neither STS nor IAM has a RevokeSession request. 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 contains no credentials, because the tool never puts them there. A tool returns the document body and its scope, or a generic error. The credentials reach the tool in its client context and never in its arguments, so even a tool that returned its own arguments would return no credential. 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 copies its client context 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. It can test equality, set membership, ordering, and simple string patterns. What it cannot do is look anything up.
// supported: compare values already in the request
when { context.input.doc_id == "PAY-001" }
// not supported: there is no way to query or look anything up
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 interceptor re-validates the token and requires token_use to be access and a known client_id | The scope comes from a signed claim of the expected token type, 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 write carries a condition requiring the target document to already exist | The only path to the table, held by no tool; Scan is absent so nothing can read across partitions, and a reply cannot create a document from an id the model invented |
| Role trust policy | Names the interceptor’s role as the sole principal, and allows sts:AssumeRole and sts:TagSession only when a scope session tag is present | Only the interceptor can obtain credentials from the role, and an untagged assume is refused before any credential exists |
| Session policy, plus session-tag ABAC (attribute-based access control) on the role | dynamodb:LeadingKeys set to the served scope, DateLessThan on aws:CurrentTime, and a Null guard on LeadingKeys. The role’s own identity policy independently requires LeadingKeys to match ${aws:PrincipalTag/scope} | Credentials reach one partition only and stop working after one minute, and the confinement does not rest on the session policy alone |
| REQUEST interceptor | Reads the claim, assumes the role with the session policy, and returns the scope and the credentials as four request headers, with the body forwarded unchanged. Refuses any tools/call it cannot match to one of the three tools | The scope is set from the token, and the credentials stay out of the request body, the logs, and the model’s context |
| Gateway tool targets | Each Lambda target lists the four header names in metadataConfiguration.allowedRequestHeaders | The credential headers reach the tool, and the Gateway drops any header outside the list |
| The three tools | No data-plane permission on their own role; each builds its client only from the propagated headers in the Lambda client context, with no fallback to the event | A tool can reach the table only with credentials it was handed, never on its own authority and never with a value from the request body |
| Per-invocation state | Anything carrying tenant identity, the scope, the credentials, and the client built from them, is constructed inside each invocation. Objects carrying no identity, the SDK’s model cache and the interceptor’s STS client, are built once per container | A warm container reused across tenants cannot serve one tenant through 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, asserted against the synthesized template, with an alarm on any other delivery source being created | Request and response bodies, which contain whole documents, are not copied into a log group with no tenant partition. Detected rather than prevented: see the note above |
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.
Latency Overhead of Per-Request Scoped Credentials
Isolation of this kind is not free. The baseline it is measured against reads the table directly with a broad static role and no interceptor at all.
Measured on the deployed stack, Lambda on arm64 with 3008 MB, boto3 1.42.97, at one and ten concurrent requests.
In the snippets below, tenant_credentials is the dict of the three boto3 keyword arguments that the tool builds from the propagated headers.
The baseline answers in 3.7 ms. Written the obvious way, with everything built inside the request because nothing may be cached, the tool builds its client like this:
session = boto3.Session(**tenant_credentials)
table = session.resource("dynamodb").Table(TABLE)
That version answers in 182.5 ms, so the isolation costs 179 ms on every request.
| Segment | p50 (ms) | Share |
|---|---|---|
| Building the tool’s AWS client | 97.4 | 54% |
The AssumeRole request to STS | 42.4 | 24% |
| Opening a connection for the read | 31.5 | 18% |
| Everything else in the interceptor | 6.8 | 4% |
The network call to STS was under a quarter of it. More than half was the SDK reading its own service definitions and assembling its machinery, once per request, because I had told it to keep nothing between requests.
It would be easy to read those 97 ms as the price of building a client on every request. The same run rules that out: the interceptor builds a client on every request too, its own client for calling STS, and that one costs 2.1 ms on the same containers. Building per request is cheap; the cost sits in which SDK call does the building.
This is where the caching rule earns its precise wording: cache nothing that carries tenant identity. The Session carries none, so it moves out of the request and the credentials stay in it:
_SESSION = boto3.Session() # once per container, no credentials
table = _SESSION.resource("dynamodb", **tenant_credentials).Table(TABLE)
_SESSION holds the parsed service definitions and nothing else, and those are identical for every tenant. The credentials still arrive per request and still bind to the client that performs the read.
The interceptor gets the same treatment. boto3.client("sts") moves out of the vending function and up to module level, because that client authenticates as the interceptor’s own execution role: it is the thing that asks for tenant credentials, and it never holds any.
Everything that touches a tenant is still built per request. That takes the overhead from 179 ms to 51 ms at p50, and from 314 ms to 61 ms at p95. It stays flat from one to ten concurrent requests.
| Segment | p50 (ms) | Share |
|---|---|---|
| Opening a connection for the read | 29.5 | 58% |
The AssumeRole request to STS | 14.3 | 28% |
| Building the tool’s AWS client | 5.3 | 10% |
| Everything else in the interceptor | 1.5 | 3% |
The largest remaining cost cannot be removed. The client that reads the table is bound to one tenant’s credentials, so it cannot reuse a connection from a request that served someone else, and it pays a new handshake every time. That connection setup is the price of the property itself; the rest was engineering.
One operational finding comes with the speed. The slower version never triggered STS throttling. The faster one does, because the same concurrency now offers roughly three times the request rate: throttling appears at around 977 AssumeRole requests per second, above the documented default of 600 per second per account per Region. Almost all of those were absorbed by SDK retries. This is an account quota rather than a limit of the design, and it can be raised, but it is worth knowing where the ceiling sits before you meet it in production.

Comments ·
Loading comments…
Sign in to leave a comment.