A deployable reference implementation of one containment pattern: an LLM agent whose tools never hold broad data access. Each request derives the caller's scope from a verified JWT, and every tool call runs under short-lived credentials scoped to that one partition, so a model that has been talked into exfiltrating another tenant's data has nothing to exfiltrate it with.
This is the token vending machine pattern — a request-time broker that mints narrowly scoped, short-lived credentials instead of handing out standing access — applied to agent tools rather than to the mobile and IoT clients it is usually associated with. The model is the untrusted client here.
The stack is AWS Bedrock AgentCore (runtime + gateway), Lambda tool targets, DynamoDB as the document store, Cognito as the identity provider, and Cedar for coarse-grained tool authorization — all defined in CDK.
It demonstrates one thing: scope-partitioned data plus per-request scope-scoped credentials block cross-scope exfiltration even when the model is fully injected and fully compliant with the attacker. The prompt-injection channel is the stimulus, not an oversight — the system prompt is naive, tool output reaches the model unfenced, and the seed corpus ships a planted injection and planted cross-scope bait, all on purpose.
Outside the claim by construction: in-scope write amplification — an injected model
can still drive reply against any document that already exists in the partition it is
serving (the creation half is closed: the write carries attribute_exists(doc_id), so
an invented id is refused) — and non-credential sensitive content, which the response
scrubber does not touch, because it matches credential shapes only. The Cedar policies
are coarse-grained authorization, not the tenant boundary; the boundary is the scoped
credential.
The IAM boundary is the part worth copying, and it is necessary but not sufficient. A production system built on it would additionally need an untrusted-content envelope around tool output with prompt language that refuses instructions found inside it, human confirmation on write-class tools, and detection for injection attempts rather than silent containment. None of that is in this repo, on purpose.
- The caller sends a Cognito access token with the request. The gateway's JWT
authorizer validates it; the request interceptor re-validates it independently and
pins
token_use, the app client and the signing algorithm. - The interceptor resolves exactly one scope group from
cognito:groups— by set membership, never by array position — and refuses ambiguous or empty membership. - It calls
sts:AssumeRolewith both a session policy narrowing DynamoDB access to that scope's partition and ascopesession tag. The role's trust policy requires the tag, and the role's identity policy is itself conditioned on it: two independent gates, so neither alone is the boundary. - The vended credentials reach the Lambda tool target as allowlisted propagated
request headers, never in the request payload: the interceptor returns them in
transformedGatewayRequest.headers, each target allowlists the names inmetadataConfiguration.allowedRequestHeaders, and the tool reads them fromcontext.client_context.custom["bedrockAgentCorePropagatedHeaders"]. The request body is forwarded unchanged, so it carries only the model-supplied arguments and no tool'sinputSchemaever declares a credential — the model cannot see, name or echo one. The tool has no ambient data permissions and uses the propagated credentials for every DynamoDB call. - The response interceptor scrubs credential shapes out of anything heading back to the model, and fails closed — a scrub error withholds the body rather than passing it through.
- Gateway log delivery is constrained to
TRACESonly, asserted at synth time, so the build fails if anyone declares anAPPLICATION_LOGSdelivery source. The reason is what those records contain: each one carries aresponseBodyfield holding the tool's full answer, so one successful read writes the entire document into a log group that has a single partition for the whole gateway — a copy of partitioned data sitting behind an unpartitioned permission. Field selection cannot narrow it:bodyis the smallest unitrecordFieldscan select, and the message,requestBody,responseBodyand the policy decision all live inside it. Those records carry no credential —APPLICATION_LOGSwas measured with real vended credentials in flight across seven legs covering three of the four documented record kinds, and no header name and no header value appears in any of them — but the document exposure is enough on its own.
Prerequisites
- Python 3.14, Node.js with the AWS CDK CLI (
npm i -g aws-cdk) - Docker running — the agent and interceptor ship as container images, built for
linux/arm64 - AWS credentials for the target account, and
cdk bootstrapalready run in it. The deploy role needsiam:CreateServiceLinkedRolefor the AgentCore service-linked roles; the standard CDK bootstrap role usually has it already - Bedrock model access enabled in that account for the model id in your config
(default:
us.anthropic.claude-sonnet-4-6)
Steps
python3.14 -m venv .venv && source .venv/bin/activate
pip install -r cdk/requirements.txt
cp config.example.json config.json # set aws_region and bedrock_model_id
cd cdk && cdk deployIf you deploy outside the US, change bedrock_model_id to the inference profile for your
geography — eu.anthropic.…, apac.anthropic.…, and so on. The IAM grant follows the
prefix automatically; a bare foundation-model id (no prefix) works too.
The document store is seeded on deploy by a custom resource — documents spread across
seven fictional scopes, including the planted injection and the cross-scope bait. Note
the stack outputs: CognitoUserPoolId, CognitoAppClientId, SupportGatewayGatewayId,
SupportAgentRuntimeArn.
Create the demo user. CloudFormation cannot set a Cognito password, so the user is
created post-deploy. It must belong to exactly one scope group (payments-core or
billing-internal), and the password must be at least 16 characters:
POOL_ID=<CognitoUserPoolId from the stack outputs>
aws cognito-idp admin-create-user --user-pool-id "$POOL_ID" \
--username demo-user --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL_ID" \
--username demo-user --password "$PW" --permanent
aws cognito-idp admin-add-user-to-group --user-pool-id "$POOL_ID" \
--username demo-user --group-name payments-coreThen store that password as an SSM SecureString at /scoped-credentials/manual/DEMO_PASSWORD,
which is where the token script reads it from.
The app client allows SRP only — no plaintext-password auth flow — and the AWS CLI cannot perform SRP, so use the bundled script. It reads the pool and client ids from the stack outputs, prints the token on stdout and nothing else:
TOKEN="$(python3 scripts/mint_demo_token.py)"
jq -n --arg p "Open document PAY-001 and tell me its title and body." \
--arg j "$TOKEN" '{prompt:$p, user_jwt:$j}' > /tmp/payload.json
aws bedrock-agentcore invoke-agent-runtime \
--agent-runtime-arn "<SupportAgentRuntimeArn>" \
--runtime-session-id "$(uuidgen)" \
--content-type application/json --accept application/json \
--payload fileb:///tmp/payload.json \
--cli-read-timeout 870 --no-cli-pager \
/tmp/response.json
rm -f /tmp/payload.jsonWhat to expect as a payments-core operator: PAY-001 reads fine, and its body contains
an injection instructing the agent to fetch documents from other scopes. Ask it to follow
those instructions and every cross-scope read comes back as "document not found" — the
tool's credentials cannot see the other partitions, and the agent is told nothing that
reveals a restriction exists. --claims on the token script shows the resolved group if
you want to check what scope you are acting as.
Two scopes ship as the demo tenants, payments-core and billing-internal. To model your
own, change the scope set in three places — they are the source of truth and everything else
derives from them:
cdk/auth_resources.py—SCOPE_GROUPS, the Cognito groups that get createdcdk/seed/documents_seed_generator.py—SERVED_SCOPE,OTHER_SCOPESandSCOPE_PREFIX_MAP, the seeded corpus and its document-id prefixesscripts/mint_demo_token.py—KNOWN_SCOPE_GROUPS, the client-side sanity check
The interceptor reads its known-scope set from the KNOWN_SCOPE_GROUPS environment
variable, which the stack sets from SCOPE_GROUPS, so it needs no edit. The stack name,
the DynamoDB table name and the SSM parameter path are fixed strings; rename them if you
plan to run more than one copy in the same account.
| Path | What it is |
|---|---|
cdk/ |
the whole stack: identity, gateway, runtime, tool Lambdas, roles, seed |
interceptor/ |
request interceptor: JWT verification, scope resolution, credential vending |
response_interceptor/ |
response interceptor: credential scrubbing, fail-closed |
tools/ |
the three tool handlers (read_document, search_documents, reply) |
agent/ |
the agent container served by the AgentCore runtime |
cedar/ |
Cedar policies for tool-level authorization |
shared/ |
config loading shared by the CDK app and the handlers |
scripts/ |
mint_demo_token.py — the only path to a demo access token (the app client is SRP-only) |
MIT — see LICENSE.
