← Back to the Security Lab
Instructor Guide

Protected vs Vulnerable — what actually changes

The code and the AWS configuration behind the Lab's toggle — IAM for memory, Cedar for tools — plus a step-by-step run sheet for showing both on camera.

Demo prep · M2 + M4 + M5
SURFACE · IDENTITY · MEMORY · TOOLS

Two controls, two surfaces, one toggle

The toggle in the Lab picks between two Amazon Bedrock AgentCore Runtimes. They run the same container image, the same agent.py, the same model, the same Cognito authorizer, the same memory store, the same KMS key and the same tool Lambda. Keeping everything else identical is the point — it means a different outcome can only come from the things that changed.

The Lab now demonstrates two independent controls on two different surfaces, and it is worth naming them separately before you demo either:

M5 · Data access

Can the agent read this customer's memory?
  • Decided by IAM, on the credential the agent is holding.
  • Control: an actorId condition compared against a session tag.
  • Denial looks like a real AccessDeniedException.

M4 · Capability access

Is the agent allowed to call this tool at all?
  • Decided by Cedar, inside the Amazon Bedrock AgentCore Gateway.
  • Control: a graded policy set — permits for reads, a conditional permit for the deposit, forbid for the two high-risk writes.
  • Denial happens before the target runs. A forbid names itself; a conditional permit that misses falls to default deny.

Every card in the Lab's Security Trace is labelled IAM · Memory or Cedar · Gateway for exactly this reason. Crediting one control for the other's work is the easiest mistake to make on camera.

The protected runtime's execution role has no memory permission at all. It can only assume a second role, and it must attach the signed-in user's identity as a session tag to do it. That second role allows a memory read only when the actorId being requested equals the tag. The vulnerable runtime's execution role reads memory directly, with no condition.

For tools, both runtimes hold an identical bedrock-agentcore:InvokeGateway grant — each scoped to its own gateway — so both are equally authenticated. The protected gateway has a Cedar policy engine attached in ENFORCE mode; the vulnerable one has no policy engine at all. Same agent, same tool schema, same Lambda behind it. One of them authorizes the individual call. Authentication is not authorization — that sentence is the M4 demo.

What is identical, and what differs

ElementProtectedVulnerable
Runtime env var SCOPED_ROLE_ARN = the ABAC role set SCOPED_ROLE_ARN absent
Exec role inline policy capstone-protected
sts:AssumeRole + sts:TagSession on the ABAC role.
Zero bedrock-agentcore memory actions.
capstone-vulnerable
bedrock-agentcore:ListEvents on the memory, no Condition (Sid BroadMemoryReadFLAW).
Credentials the tool uses Temporary creds from sts:AssumeRole, tagged actorId = JWT identity The execution role itself
Runtime env var
GATEWAY_URL
agentcoresecDemoGw
Cedar-governed
agentcoresecDemoGwOpen
ungoverned
Gateway policy engine agentcoresecDemoEngine-q0p7ztg1xn
mode ENFORCE
none attached — no policyEngineConfiguration
Cedar policies in force 8 ACTIVE policies across three tiers — permits for the reads, PermitOpenFdUnderLimit (conditional), and a forbid for transfer_funds and execute_trade — none —
Tools the agent is offered
gateway tools/list
4 of 6: get_fd_rates, check_home_loan_eligibility, get_portfolio_balance, open_fixed_deposit
transfer_funds and execute_trade withheld
6 of 6 — every tool advertised
Agent source & containerIdentical — one agent.py, built twice
Modelus.anthropic.claude-haiku-4-5-20251001-v1:0
Inbound authcustomJWTAuthorizer → the same Cognito pool and app client
Header forwardingrequestHeaderAllowlist: ["Authorization"]
Memory storeAgentCoreSecDemoMemory-pt0tJa4pBh
EncryptionThe same customer-managed KMS key
Gateway inbound authAWS_IAM (SigV4) on both gateways
Gateway target & tool schemaSame target name agentcoresecDemoTools → identical tool ids, same Lambda agentcoresec-demo-tools
InvokeGateway grantIdentical statement, scoped to each mode's own gateway ARN
Network modePUBLIC — egress is Module 3, not this demo
Say this out loud Both runtimes use the same CMK. Encryption is not what stops the leak — if it were, the vulnerable runtime couldn't read anything either. Encryption (Security Hub BedrockAgentCore.3) protects data at rest from someone outside the authorization path. Isolation between customers is the actorId condition. Two different jobs.

The credential path, side by side

Protected

agentcoresec_demo_protected-wq1H7zCzzi
  • Cognito JWT arrives; the Runtime's JWT authorizer validates it and forwards the Authorization header.
  • The agent reads the username claim → the trusted actorId.
  • It calls sts:AssumeRole on agentcoresec-demo-agent-mem, passing Tags=[{actorId: <trusted>}].
  • It reads memory with those credentials.
  • The ABAC role allows ListEvents only where the requested actorId equals the tag → cross-customer read is denied by IAM.

Vulnerable

agentcoresec_demo_vulnerable-lcdP9XF2SZ
  • Same JWT, same validation, same forwarded header.
  • The agent still reads the trusted actorId — and then ignores it.
  • No role assumption. It uses the execution role directly.
  • That role allows ListEvents on the whole memory store with no condition.
  • Whatever customer the prompt names is returned → cross-customer leak.

The tool path, side by side

Protected

gateway agentcoresecDemoGw · engine ENFORCE
  • The agent SigV4-signs an MCP call with its execution-role credentials.
  • tools/list returns 4 of 6 tools — the two forbidden ones are not advertised.
  • Rates, a loan check and a 10,000 deposit all succeed. The product still works.
  • A 500,000 deposit is refused — same tool, same caller, only the argument changed. The conditional permit's when clause is false, so no policy applies and there is none to name.
  • A transfer is refused by an explicit forbid, which the Gateway names: -32002 … ForbidTransferFunds. The Lambda never ran.

Vulnerable

gateway agentcoresecDemoGwOpen · no engine
  • Same code, same signing, same tool ids.
  • tools/list returns all 6 tools.
  • Every call goes through — including the 500,000 deposit.
  • Nothing evaluates any call. The Gateway forwards them.
  • The transfer returns "status":"EXECUTED" → 25,000 SGD left the customer's account for a third party.
Say this out loud The forbidden tool is missing from the catalogue, not just refused. That is a second layer worth pointing at: the agent is never offered the capability in the first place, so a model that has been talked into wanting it still has nothing to call. And when our agent calls it regardless — because we hard-coded the tool — the Gateway refuses it anyway. Belt and braces, both outside the agent.
The honest framing Vulnerable mode is not "the model was tricked." The model behaves the same in both modes — it asks for the customer the prompt named. What differs is whether AWS was willing to answer — and for the trade, whether the Gateway was willing to forward the call. That is the lesson: put the boundary in IAM and Cedar, not in the prompt.

In the code — demos/capstone/agent/agent.py

One file, deployed twice. AGENT_MODE, SCOPED_ROLE_ARN and GATEWAY_URL come from the runtime's environment variables, so the behaviour is chosen at deploy time, not by anything the user can influence.

1 · The trusted identity comes from the token, never from the prompt

# The Runtime's JWT authorizer already validated the token; we only read the claim. def _decode_jwt_actor(headers): ... user = claims.get("username") or claims.get("cognito:username") or claims.get("sub") return user if str(user).startswith("customer-") else f"customer-{user}" # In the entrypoint — this is the Module 2 session-to-user binding. trusted = _decode_jwt_actor(headers) or payload.get("actorId") or "customer-unknown"

2 · The one branch that separates the two modes

@tool def get_customer_notes(customer_id: str) -> str: trusted = _REQ.get("actor") # who is signed in (from the JWT) if MODE == "protected" and SCOPED_ROLE_ARN: dp = _scoped_dp(trusted) # creds TAGGED with the signed-in identity else: dp = boto3.client("bedrock-agentcore", region_name=REGION) # broad exec role # NOTE: both paths ask for the REQUESTED customer, not the trusted one. r = dp.list_events(memoryId=MEMORY_ID, actorId=customer_id, sessionId=_session_for(customer_id))
Point at this line actorId=customer_id — the requested customer, in both modes. The application never compares customer-bob to customer-alice. There is no if statement protecting the data. That is deliberate: it proves the allow/deny is a genuine IAM decision, and it means a prompt-injection that changes customer_id cannot get past the control in protected mode.

3 · How the identity is attached to the credentials — sts:AssumeRole with a session tag

def _scoped_dp(tagged_actor): sts = boto3.client("sts", region_name=REGION) creds = sts.assume_role( RoleArn=SCOPED_ROLE_ARN, RoleSessionName="agent-mem", Tags=[{"Key": "actorId", "Value": tagged_actor}], # ← the whole control )["Credentials"] return boto3.client("bedrock-agentcore", region_name=REGION, aws_access_key_id=creds["AccessKeyId"], ...)

That session tag becomes aws:PrincipalTag/actorId in the request context, which is exactly what the ABAC role's condition compares against. Session tags require sts:TagSession in the target role's trust policy — without it the AssumeRole call itself fails.

4 · The verdict the UI shows is the real exception, caught and reported

except ClientError as e: code = e.response["Error"]["Code"] # AccessDeniedException msg = e.response["Error"]["Message"] # the full IAM message, shown verbatim trace.append({"result": "DENIED", "errorCode": code, "message": msg, ...})
Why the Security Trace panel exists The model's wording changes every run, which is a problem for a recorded demo. The trace entries are built from the API result, not from the model's text, so the ALLOW/DENIED verdict is deterministic even though the sentence around it is not. The panel streams these as they happen — the denial card appears before the model finishes its explanation.
Worth mentioning if asked The per-request state is a module-level dict, not threading.local. Strands runs tools on a separate thread, so thread-local state is invisible inside the tool. One invocation per microVM session makes a module global safe here.

In the console — what to open, in order

Five screens, roughly four minutes. Each step has a CLI equivalent; on camera the CLI is usually faster and it does not break when console layouts change. Set the account once:

ACCOUNT=$(aws sts get-caller-identity --query Account --output text) REGION=us-west-2 MEM=arn:aws:bedrock-agentcore:$REGION:$ACCOUNT:memory/AgentCoreSecDemoMemory-pt0tJa4pBh

Show that the two runtimes differ by one variable

Amazon Bedrock AgentCore console → Agent Runtime → each runtime → Environment variables

Protected has seven variables; vulnerable has six. The only missing key is SCOPED_ROLE_ARN. Two more differ in value: GATEWAY_URL and GATEWAY_NAME. MEMORY_ID, MODEL_ID and GATEWAY_TARGET are identical.

aws bedrock-agentcore-control get-agent-runtime --region $REGION \ --agent-runtime-id agentcoresec_demo_protected-wq1H7zCzzi --query environmentVariables aws bedrock-agentcore-control get-agent-runtime --region $REGION \ --agent-runtime-id agentcoresec_demo_vulnerable-lcdP9XF2SZ --query environmentVariables
Say: same image, same model, same authorizer. The protected one is told where to find a scoped role, and it's pointed at a governed gateway. The vulnerable one has neither, so it falls back to its own permissions and an unpoliced gateway.

Show the two gateways — one governed, one not

Amazon Bedrock AgentCore console → Gateways → each gateway

Both are MCP gateways with AWS_IAM inbound auth and the same Lambda target. The protected one has a policyEngineConfiguration in ENFORCE mode; on the vulnerable one that field is absent. That single field is the entire M4 difference.

aws bedrock-agentcore-control get-gateway --region $REGION \ --gateway-identifier agentcoresecdemogw-yiblmzj3lb \ --query '{name:name,auth:authorizerType,engine:policyEngineConfiguration}' aws bedrock-agentcore-control get-gateway --region $REGION \ --gateway-identifier agentcoresecdemogwopen-s2gvneko1g \ --query '{name:name,auth:authorizerType,engine:policyEngineConfiguration}'
Say: both gateways check who is calling — that's SigV4, and both agents pass. Only one of them checks what is being called.

Read the Cedar policy that does the blocking

Amazon Bedrock AgentCore console → Policy engines → agentcoresecDemoEngine → Policies

Eight ACTIVE policies, and the shape of the set is the lesson: authorization is graded by the risk of each capability, not by read-versus-write. Note that transfer_funds is explicitly permitted by one policy and forbidden by another — and it is denied. In Cedar forbid always wins over permit, so there is no precedence to argue about.

CapabilityTierCedar
get_fd_ratesreadpermit
check_home_loan_eligibilityreadpermit
get_portfolio_balancereadpermit
open_fixed_depositwrite — own funds, stays in the bankpermit when amount <= 50000
transfer_fundswrite — money leaves to a third partypermit + forbid
execute_tradewrite — market riskpermit + forbid

The conditional permit is the one to read out loud. The agent chose the amount; a policy outside the agent decided whether that choice was acceptable:

permit( principal, action == AgentCore::Action::"agentcoresecDemoTools___open_fixed_deposit", resource == AgentCore::Gateway::"arn:aws:bedrock-agentcore:...:gateway/agentcoresecdemogw-yiblmzj3lb" ) when { context.input.amount <= 50000 };
Why the amount is an integer The Gateway generates the Cedar schema from the tool schema. A JSON "number" becomes a Cedar decimal, and comparing it to a bare 50000 fails validation with "expected Long but saw decimal" — you would have to write context.input.amount.lessThanOrEqual(decimal("50000.0")). Declaring the parameter "integer" makes it a Cedar Long, so the policy reads as plain arithmetic.
forbid( principal, action == AgentCore::Action::"agentcoresecDemoTools___execute_trade", resource == AgentCore::Gateway::"arn:aws:bedrock-agentcore:...:gateway/agentcoresecdemogw-yiblmzj3lb" );
aws bedrock-agentcore-control list-policies --region $REGION \ --policy-engine-id agentcoresecDemoEngine-q0p7ztg1xn \ --query 'policies[].{name:name,status:status,cedar:definition.cedar.statement}'
Say: there is a permit for this exact tool sitting right next to the forbid. It still doesn't run. That property — deny wins, deterministically — is why authorization belongs here and not in a prompt.

Open the vulnerable execution role — find the flaw

IAM → Roles → AmazonBedrockAgentCoreSDKRuntime-us-west-2-38f5e54361 → Permissions → capstone-vulnerable

One statement, deliberately named BroadMemoryReadFLAW: bedrock-agentcore:ListEvents on the memory ARN, and no Condition block. Scroll slowly here; the absence is the thing to see.

aws iam get-role-policy --role-name AmazonBedrockAgentCoreSDKRuntime-us-west-2-38f5e54361 \ --policy-name capstone-vulnerable --query PolicyDocument
Say: this role can read every actor's memory in the store. Nothing here mentions who is asking. This is the kind of policy that passes review because it "only" grants a read.

Open the protected execution role — notice what is missing

IAM → Roles → AmazonBedrockAgentCoreSDKRuntime-us-west-2-9f0e3fa5c9 → Permissions → capstone-protected

Two statements: assume the ABAC role (with sts:TagSession), and invoke the model. Search the page for ListEvents — there are no matches. The protected agent cannot touch memory as itself at all.

aws iam get-role-policy --role-name AmazonBedrockAgentCoreSDKRuntime-us-west-2-9f0e3fa5c9 \ --policy-name capstone-protected --query PolicyDocument
Say: least privilege taken literally. The agent's own identity has no data access. It has to borrow an identity that carries the user with it.

Open the ABAC role — the actual control, in two parts

IAM → Roles → agentcoresec-demo-agent-mem

Permissions tab, policy abac-actor-read — the condition is the whole demo:

{ "Sid": "ListEventsForTaggedActorOnly", "Effect": "Allow", "Action": "bedrock-agentcore:ListEvents", "Resource": "arn:aws:bedrock-agentcore:...:memory/AgentCoreSecDemoMemory-...", "Condition": { "StringEquals": { "bedrock-agentcore:actorId": "${aws:PrincipalTag/actorId}" } } }

Trust relationships tab — note sts:TagSession alongside sts:AssumeRole. Without it, passing the session tag fails outright.

aws iam get-role-policy --role-name agentcoresec-demo-agent-mem \ --policy-name abac-actor-read --query 'PolicyDocument.Statement[0].Condition' aws iam get-role --role-name agentcoresec-demo-agent-mem \ --query 'Role.AssumeRolePolicyDocument.Statement[0].Action'
Say: left side is what the request asked for. Right side is who the caller proved they are. The read only happens when they match — and neither side is under the model's control.

Prove the verdict before you even run the agent — IAM policy simulator

IAM → Policy simulator, or the CLI below

This is the strongest pre-check available: it evaluates the real policies without calling the service, so it is fast and repeatable. Run it for both actor values against the ABAC role.

sim() { # $1 = role arn, $2 = requested actorId, $3 = session tag (optional) aws iam simulate-principal-policy --policy-source-arn "$1" \ --action-names bedrock-agentcore:ListEvents --resource-arns "$MEM" \ --context-entries "ContextKeyName=bedrock-agentcore:actorId,ContextKeyValues=$2,ContextKeyType=string" \ "ContextKeyName=aws:PrincipalTag/actorId,ContextKeyValues=$3,ContextKeyType=string" \ --query 'EvaluationResults[0].[EvalDecision,MatchedStatements[*].SourcePolicyId]' } sim arn:aws:iam::$ACCOUNT:role/agentcoresec-demo-agent-mem customer-bob customer-bob sim arn:aws:iam::$ACCOUNT:role/agentcoresec-demo-agent-mem customer-alice customer-bob

Observed results in this account:

Principal / requestDecisionMatched policy
ABAC role · tag bob · asks boballowedabac-actor-read
ABAC role · tag bob · asks aliceimplicitDeny— none —
Protected exec role · direct memory readimplicitDeny— none —
Vulnerable exec role · asks boballowedcapstone-vulnerable
Vulnerable exec role · asks aliceallowedcapstone-vulnerable
Say: the vulnerable role returns the same answer whoever it asks about — that is the bug in one line. And note the denial matched no policy at all; we'll come back to why that matters.
Be accurate about this tool AWS notes that simulator results can differ from a live environment, so treat it as a pre-check that explains the policy — the Lab is what proves the real API behaves the same way. Showing both is stronger than showing either alone.

Optional: the identity side, and the audit trail

Cognito → User pools → us-west-2_y41jdYgmH → Users

Two real users, bob and alice. Worth 20 seconds to show the login is real and the actorId is derived from it, not typed in. (Passwords are in demos/capstone/cognito_config.json — don't put them on screen.)

CloudWatch → Log groups → /aws/bedrock-agentcore/runtimes/<runtime-id>-DEFAULT

The agent's own logs for the turn. For the authorization record itself, CloudTrail shows the AssumeRole call with its session tag, which is the M6 thread — the evidence that the binding happened.

aws logs tail /aws/bedrock-agentcore/runtimes/agentcoresec_demo_protected-wq1H7zCzzi-DEFAULT \ --since 15m --region $REGION

Run sheet — the on-camera sequence

Start the local server, open the Lab, and sign in as bob before recording. Total run time about seven minutes: three beats on the memory surface (M5), then four on the tool surface (M4) — one that succeeds, one refused on its argument, one refused outright, then the vulnerable comparison.

python3 demos/api/local_server.py # → http://localhost:8000/security-lab.html (sign in as bob)

Establish the baseline — bob asks for his own balance

Leave the toggle on Protected. Click What's my balance?

Expected: the reply streams in, the tool chip appears, and the trace shows a green ALLOW card with requested customer-bob · signed-in customer-bob.

Say: the control is on and the agent works normally. Least privilege that breaks the product isn't a control anyone keeps.

Run the attack with the control ON

Still Protected. Click Another customer's notes.

Expected: a red ACCESS DENIED · AccessDeniedException card appears while the model is still talking, showing the verbatim IAM message. The header pill flips to BLOCKED BY IAM. Nothing from Alice's profile is disclosed.

Say: the agent genuinely tried. The tool ran, the API call went out, and AWS refused. Read the message: it names the assumed role and the action it wasn't allowed.

Show it isn't the model being careful — try prompt injection

Still Protected. Click Prompt injection.

Expected: same denial. The instruction to ignore its rules changes the model's wording, not the IAM outcome.

Say: this is why the boundary belongs in IAM. You cannot prompt your way past a policy condition.

Flip to Vulnerable and repeat the identical request

Switch the header toggle to Vulnerable and click Another customer's notes again — same user, same prompt, same words.

Expected: a green ALLOW · 4 record(s) card, and the agent cheerfully reads out Alice's whole profile to Bob — her balance, her income, the condo she is shopping for, and the joint account number she shares with her sister.

Say: nothing about my request changed. I'm still signed in as bob. The only difference is one IAM policy on the other side. And notice what actually leaked — this isn't a balance, it's her financial life.

Switch surfaces — show that the product still works

Back to Protected. Click FD rates, then Open 10k FD.

Expected: a blue TOOL CATALOG · 4 of 6 tools offered card, then green ALLOW · tool ran cards. The deposit is genuinely opened and the agent quotes a reference and a maturity value.

Say: the control is on, and the agent just moved real money into a term deposit. Authorization is not a synonym for "block the writes" — it let this one through, because this one is the customer's own money staying inside the bank.

The best beat — change only the amount

Still Protected. Click Open 500k FD.

Expected: POLICY DENIED · no matching permit, with default deny in the card body. Pill reads BLOCKED BY CEDAR. Note there is no policy name on this one.

Say: same tool, same signed-in user, same gateway. I changed one number. The policy permits this capability only up to fifty thousand, so above that the permit simply doesn't apply and Cedar falls to default deny — which is why there's no policy to name here. The model picked the amount; something outside the model decided it wasn't allowed.

The capability that is never available — a third-party transfer

Still Protected. Click Transfer to 3rd party.

Expected: POLICY DENIED · ForbidTransferFunds, and this time the card does name the policy, with the verbatim gateway message.

Say: two refusals, two different mechanisms. The deposit was refused because no permit covered it. This one was refused because a forbid matched — so Cedar can tell us exactly which policy decided. In production that difference is the first question you ask when a legitimate call gets blocked.

Flip to Vulnerable and repeat both

Switch to Vulnerable, then click Open 500k FD and Transfer to 3rd party again.

Expected: TOOL CATALOG · 6 of 6 tools offered, then green ALLOW · tool ran for both — a half-million-dollar deposit and 25,000 SGD leaving for an account the customer never confirmed.

Say: same agent, same sentences, same signed-in user. The gateway on this side has no policy engine, so nothing stood between the model's decision and real movement of someone's money. That is what an unauthorized tool looks like.

Land it on the trace panel

The trace now holds the whole argument: an IAM · Memory pair, and Cedar · Gateway cards showing an allow, a default deny and a named forbid. Point at the control badges as you say it.

Say: same agent, same questions, same user. Two different controls, on two different surfaces, each the difference between a normal answer and an incident — a data breach on one side, an unauthorized transaction on the other. Neither of them lives in the agent.

Reset before the next take

Click Reset in the presets row to clear both panels. Leave the toggle back on Protected so the next take starts from a safe state.

If something misbehaves 404 on /api/agent/stream — an older local_server.py is probably still holding port 8000; check with lsof -nP -iTCP:8000 -sTCP:LISTEN. "Session expired" — the Cognito access token is short-lived; sign out and back in. First turn is slow — a cold microVM session; send one throwaway prompt before recording. Cedar deny didn't appear — check the engine is still ENFORCE and the policies are ACTIVE: python demos/capstone/gateway_setup.py status and python demos/m4-cedar-gateway/deploy.py status. A tool came back "unknown tool" — the two gateways' tool schemas have drifted; re-run python demos/capstone/gateway_setup.py setup, which re-syncs the ungoverned gateway to the governed one. Want to verify headlessly.venv/bin/python demos/capstone/verify_runtimes.py --stream prints every event and asserts all four outcomes (memory and tools, both modes).

Talk track — "shouldn't each customer have their own memory?"

This is the most common question the Lab provokes, because it deliberately runs a single shared memory store. The full treatment now lives in the course itself — M5 · Memory Security → Store Topology — with the quotas, the pricing reality and the when-to-split table. What's below is only what to say, and when.

The one sentence to get right Separation is not isolation. If you remember nothing else from this exchange, it's that a broad role leaks across a hundred stores just as easily as across one, and a scoped role can't cross tenants inside a single store. The control is the policy, not the topology.

During M5 · Store Topology (~2 min)

Use this right after you've explained actorId and namespaces, while the idea of partitioning is fresh.

Set up the question "So memory is partitioned by actorId inside one store. Your instinct at this point is probably: why not just give every customer their own memory store? Smaller blast radius, obviously better. Let's take that seriously, because the answer is genuinely useful."
Land the principle "Here's the thing. If the agent's role says Resource: memory/*, it reads across a hundred stores exactly as easily as it reads across one. And if the role is constrained by the actorId condition, it can't cross tenants even inside a single shared store. So separation isn't what isolates you. The policy is."
Then the practical ceiling "There's also a hard limit. You get 150 memory resources per Region per account. It's adjustable, but a retail bank has millions of customers — you're not getting there. And creating stores is capped at three a second, so per-user provisioning lands on your sign-up path."
Correct the cost instinct before someone raises it "And notice I haven't said 'it's too expensive', because it isn't. Memory is billed by consumption — per event, per stored record, per retrieval. There's no per-store charge. The same traffic costs the same in one store or a hundred. This fails on scale and operations, not on price."
Give them the rule they can take home "So the rule is: split per tenant organisation when something about the store itself differs — its KMS key, its retention, its Region. Never split per end user. A B2B platform with forty enterprise customers can absolutely give each one its own store and its own key. A consumer bank can't, and shouldn't try."

During the lab demo (~40 s)

Say this as you show the Security Trace, ideally before anyone asks — it pre-empts the objection and makes the demo's design look deliberate rather than lazy.

While the DENIED card is on screen "One thing worth pointing out about this setup: both customers live in the same memory store. That's on purpose. If I'd given each of them their own store, you couldn't tell whether the denial came from the IAM condition or just from them being in different places. One store means the policy is the only variable."
If someone says separate stores would have fixed it "Partly — it would have prevented this version of it. But the underlying flaw is a credential with no notion of who's asking, and that survives the split. The moment that broad role touches any store holding more than one customer, you're right back here. Fix the policy first; then pick your topology for key management and retention."

Fast answers to the three follow-ups

They askYou say
"Isn't a store per user just too expensive?" "No — there's no per-store charge. It's billed per event, per stored record and per retrieval, so the cost is identical either way. The blocker is the 150-store quota and the operational load of a CMK grant and IAM policy per customer."
"What if a customer demands their own encryption key?" "Then they get their own store — that's a legitimate reason to split, because the key is configured per memory resource. It also buys you crypto-shredding: disable the key and that tenant's memory is unreadable."
"How do we get real separation for a big enterprise tenant?" "Cross-account. The store lives in one account and principals in another reach it through a resource-based policy on the memory. That's a genuine boundary — and it's per tenant account, not per user."

During the tool-authorization beat (~45 s)

Use this when you flip from the memory attack to the trade attack. The transition is the teaching moment — the audience has just learned that IAM stopped a read, and now a completely different mechanism stops an action.

Mark the surface change "Everything we just watched was about data — could this agent read that customer's memory. Now I want to ask a different question: is this agent allowed to do this at all? Because reading the wrong record is bad, and executing the wrong trade is worse."
Point at the catalogue card first "Look at the first card. The Gateway offered this agent four tools out of six. The transfer and the trade aren't refused here — they aren't even advertised. A model can't be talked into calling something it was never told exists."
Show that the product still works "And before anyone assumes this is just locking everything down — watch. It quotes our rates, it runs a loan eligibility check, and it opens a ten-thousand-dollar fixed deposit. That last one is a real state change, real money moving into a term deposit. The control is on and the agent is still useful. Least privilege that breaks your product is a control nobody keeps."
Then the beat that lands — change one number "Now the same request, five hundred thousand instead of ten. Same tool, same customer, same agent. Denied. The policy permits this capability only up to fifty thousand — so authorization here isn't just which tool, it's which call. The model chose that amount. Something outside the model decided it wasn't allowed."
Then the verdict, and the difference between the two denials "Now a transfer to a third party. Our agent calls it anyway, because I hard-coded the tool to make the point. Refused — and this time the Gateway tells us exactly which policy decided: ForbidTransferFunds. Compare that with the deposit a moment ago, which had no policy name. That one fell to default deny because no permit covered it; this one hit an explicit forbid. Either way the Lambda never ran — nothing was executed and rolled back, it was stopped before it started."
The Cedar property worth naming "And here's the part I like. There is a permit for this exact tool sitting right next to that forbid. It still doesn't run, because in Cedar forbid always wins. That means a forbid is a kill switch — you revoke one capability without auditing every permit that might have granted it."
Flip and close "Same agent, same sentences, vulnerable side. The half-million deposit goes through, and twenty-five thousand dollars leaves this customer's account for an account they never confirmed. The only difference is that this gateway has no policy engine attached. Both gateways checked who was calling — that's SigV4, and both agents passed. Only one checked what was being called. Authentication is not authorization."

Two things not to say

Don't claim "Dedicated memory per user would be too costly." It wouldn't — pricing is consumption-based with no per-store component. Someone in the room will know, and it undermines the rest of the session.
Don't imply "The leak happened because they share a store." The leak happened because the credential had no actorId condition. Sharing a store is what made it visible in 30 seconds.
📗

Detail, quota table, decision table and sources: M5 · Memory Security → Store Topology. Keep this tab for delivery; send anyone who wants the reasoning to the module.

Why it denies, and the questions you'll get

"Why does the error say no identity-based policy allows instead of explicitly denied?"

Because there is no Deny statement anywhere in this demo. The ABAC role has exactly one Allow for ListEvents, and it is guarded by a condition. When Bob's session asks for Alice, the condition evaluates false, so that statement simply doesn't apply — and nothing else grants the action. The request falls through to the default: deny.

This is default-deny doing the work, not a blocklist. It's also why the policy simulator reported implicitDeny with an empty matched-statements list. A control built this way fails closed: if you forget to grant something, you lose function, not data.

"Why does the Cedar denial name a policy, but the IAM denial names none?"

Because they are answering different questions. The IAM denial is a default deny — no statement matched, so there is nothing to name. The Cedar denial is an explicit forbid that matched, so the engine can tell you which policy decided and the Gateway returns it in the error: Policy evaluation denied due to ForbidTradeExec-…. Both are deterministic; only one has an author to point at.

That naming is an operational feature, not cosmetic. When a legitimate call is refused in production, "which policy did this" is the first question, and Cedar answers it in the response rather than in a log you have to go correlate.

"Why did the oversized deposit deny name no policy, when the transfer named one?"

Because they are different Cedar outcomes. The transfer hit an explicit forbid, so there is a deciding policy and the Gateway names it. The 500,000 deposit matched no permit at all — the only permit for that tool carries when { context.input.amount <= 50000 }, and that clause evaluated false — so Cedar fell through to its default: deny. Nothing decided it, so there is nothing to name.

This is the same shape as the IAM denial in the memory demo, which also reports "no identity-based policy allows" and matched no statement. Two different authorization engines, one shared principle: default deny is the substrate, and an explicit deny is the override you can attribute. Worth saying out loud — it ties M4 and M5 together.

"Can Cedar really see the tool's arguments?"

Yes — the Gateway exposes them as context.input.<param>, typed from the tool schema it generated the Cedar schema from. That is what makes per-call authorization possible rather than only per-capability: the agent proposes the amount, and a policy outside the agent ratifies it. It is also why the parameter is declared integer — a JSON number arrives as a Cedar decimal and would force lessThanOrEqual(decimal("50000.0")) instead of plain <= 50000.

"Isn't this just blocking writes?"

No, and the demo is built to disprove it. open_fixed_deposit is a write, it changes state, it moves real money — and it is permitted, because the customer's own funds stay inside the bank. transfer_funds is forbidden because the money leaves to a third party. The boundary is the risk of the specific capability and the size of the specific call, not the read/write distinction. If your policy set only ever says "reads yes, writes no", you have built a feature flag, not an authorization model.

"There's a permit for transfer_funds too — why doesn't it win?"

Cedar is deny-overrides by design: a request is allowed only if some permit matches and no forbid matches. The demo leaves PermitTransferFunds and PermitTradeExec in place deliberately so you can show this rather than assert it. It also makes forbid a usable kill switch — you can revoke one capability without auditing every permit that might have granted it.

"Why a second gateway instead of flipping the engine to LOG_ONLY?"

LOG_ONLY is the right answer in a real rollout — you observe what a policy would block before you enforce it. It's the wrong answer for a live demo: switching the mode is a control-plane change with propagation delay, and it would change both sides of the toggle at once. Two pre-built gateways make the comparison instant and deterministic on camera. Worth saying out loud, because LOG_ONLYENFORCE is the graduated-trust story from M4.

"Could the agent just call the other gateway?"

No. Each execution role's bedrock-agentcore:InvokeGateway grant is scoped to that mode's gateway ARN, so the protected agent cannot reach the ungoverned one even if its prompt talks it into trying. Worth mentioning because it pre-empts the obvious "just point somewhere else" objection — and because in production the equivalent risk is real: a Gateway's controls only apply to traffic that actually goes through it.

"Isn't the KMS key the thing protecting the data?"

No — and the demo proves it, because both runtimes use the same key. The CMK is an encryption-at-rest property that Security Hub BedrockAgentCore.3 checks; it protects the stored bytes and gives you key lifecycle control. It has nothing to say about which caller may read which actor's events. Conflating the two is the most common misread of this demo, which is why it's worth pre-empting.

"Why two runtimes instead of one with a flag?"

Because a flag inside the application would mean the application is the control, and the whole point is that it isn't. Two runtimes with two execution roles means the difference lives entirely in AWS configuration. It also mirrors reality: you don't toggle least privilege at runtime, you deploy with it or you don't.

"Could the agent just lie and claim it was Bob?"

The actorId used for the session tag is read from the validated Cognito JWT that the Runtime's authorizer already checked, and only the Authorization header is on the forwarding allowlist. The agent can request any actorId it likes in the API call — that's exactly what the attack does — but it cannot change the tag on its own credentials, and the condition compares the two.

"Why can't you show session tags in the console?"

AWS documents that session tags cannot be passed using the Management Console — they're an API-level construct. That's precisely why the assume-role happens in the agent code, and why the policy simulator (where you can supply the context key by hand) is the console-friendly way to demonstrate the evaluation.

Mapping back to the modules

ModuleWhat this demo shows
M2 · IdentitySession-to-user binding: a real Cognito login becomes the actorId, carried into AWS as a session tag and enforced with ${aws:PrincipalTag/actorId}.
M5 · MemoryPer-actor isolation in Amazon Bedrock AgentCore Memory via the bedrock-agentcore:actorId condition key, with CMK encryption as a separate property.
M6 · ObservabilityThe deterministic Security Trace, and the CloudTrail record of the tagged AssumeRole. Deepens in Phase 3.
M4 · Policy & GatewayTool authorization graded by risk across six real banking operations: reads permitted, open_fixed_deposit permitted only when context.input.amount <= 50000, transfer_funds and execute_trade forbidden outright and withheld from tools/list. Shows forbid beating a co-existing permit, per-argument authorization, and Cedar's default deny.

Sources