Every Bedrock setup I've inherited starts the same way. One Lambda execution role, one IAM policy, and bedrock:InvokeModel scoped to Resource: "*". It works on day one. Every function in the account can call every model in every region, and nobody thinks twice about it because the demo runs fine.
I got burned by this about two months ago. A staging Lambda that was supposed to just tokenize incoming support tickets somehow ended up calling our production Claude endpoint with a prompt built from live customer records. Nobody wired that up on purpose. The function shared an execution role with three other Lambdas, one of which did legitimately need Bedrock access, and the wildcard resource let every function in that role ride along for free. I found out from a CloudTrail log, not from a test failing.
That's the thing about Bedrock and IAM. Bedrock itself doesn't leak data. Your IAM policy does, by being too generous about who gets to ask it questions.
This post walks through how I actually fixed it. Scoped IAM policies per function, a permission boundary that blocks privilege escalation, and the CloudTrail query that caught the wrong function mid-call. Then a gitleaks rule for hardcoded model ARNs and a CI check that fails the build before any of this ships again.
Why "It Has bedrock:InvokeModel" Isn't Good Enough
Most Bedrock IAM policies I see look like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "*"
}
]
}
It's not wrong, exactly. It's just way too much trust for one function. Resource: "*" means the Lambda can call Claude, Nova, Titan, Llama, whatever model exists in whatever region the account has enabled. Anthropic's models aren't cheap per token, and if a compromised dependency or a bad prompt injection gets code execution inside that function, it now has a paid API to every foundation model AWS offers. That's not a hypothetical. It's the exact blast radius every pentest report on LLM apps flags first.
The fix isn't complicated. It's just tedious, which is probably why most people skip it. Every Lambda that talks to Bedrock gets its own role, and that role gets a policy scoped to exactly one model ARN, one region, and one action. Nothing shared, nothing inherited.
One Function, One Model, One Region
Here's the actual policy I run for a summarization Lambda that only ever needs Claude Sonnet:
resource "aws_iam_policy" "hrr_bedrock_invoke_claude_only" {
name = "hrr-bedrock-invoke-claude-scoped"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "InvokeClaudeSonnetOnly"
Effect = "Allow"
Action = "bedrock:InvokeModel"
Resource = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0"
Condition = {
StringEquals = {
"aws:RequestedRegion" = "us-east-1"
}
}
}
]
})
}
resource "aws_iam_role" "hrr_summarizer_lambda" {
name = "hrr-summarizer-lambda-role"
assume_role_policy = data.aws_iam_policy_document.hrr_lambda_assume.json
}
resource "aws_iam_role_policy_attachment" "hrr_summarizer_bedrock" {
role = aws_iam_role.hrr_summarizer_lambda.name
policy_arn = aws_iam_policy.hrr_bedrock_invoke_claude_only.arn
}
No InvokeModelWithResponseStream unless the function actually streams. No second model "just in case we swap providers later." If a function needs Nova Micro for a cheaper, lower-stakes task, it gets its own role with its own scoped policy pointing at the Nova ARN. Yes, that means more Terraform. It also means a compromised summarizer Lambda can ask Claude Sonnet exactly one thing and nothing else, in exactly one region, and every other model in the account is simply unreachable from that credential.
The region condition matters more than people assume. Bedrock model access is enabled per region, and I've seen teams accidentally leave a model enabled in a region nobody uses for anything except cost surprises and a wider attack surface. Pinning aws:RequestedRegion closes that off even if the model gets enabled somewhere else later.
The Permission Boundary That Stops Privilege Escalation
Scoped policies handle the Bedrock side. They don't stop someone, or something, from attaching a second, more permissive policy to that same role later. That's what a permission boundary is for. It's a ceiling that caps what any policy attached to the role can ever grant, no matter how the role gets modified down the line.
resource "aws_iam_policy" "hrr_lambda_bedrock_boundary" {
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyNonApprovedModels"
Effect = "Deny"
Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
NotResource = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6-v1:0"
},
{
Sid = "DenyIamSelfEscalation"
Effect = "Deny"
Action = [
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePermissionsBoundary"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role" "hrr_summarizer_lambda" {
name = "hrr-summarizer-lambda-role"
permissions_boundary = aws_iam_policy.hrr_lambda_bedrock_boundary.arn
assume_role_policy = data.aws_iam_policy_document.hrr_lambda_assume.json
}
That last statement is the one people forget. Without iam:DeleteRolePermissionsBoundary denied, an attacker (or an overly helpful AI coding agent, which is honestly the more likely case in my day to day) with iam:* access could just remove the boundary and grant itself whatever it wants. Deny that action explicitly, on every role that carries a boundary, or the boundary is decorative.
I'll admit the boundary felt like overkill the first time I wrote it. Then I watched an agent try to widen a Bedrock policy to Resource: "*" in a PR because a test was failing and the wildcard was the fastest way to make it pass. The boundary caught it at apply time instead of at 2am in production. That's when it stopped feeling like overkill.
The CloudTrail Query That Caught the Wrong Function
Scoped policies only work going forward. To catch what's already happening, I run this against CloudTrail via CloudWatch Logs Insights, filtered to the log group CloudTrail writes to:
fields @timestamp, userIdentity.arn, requestParameters.modelId, sourceIPAddress
| filter eventSource = "bedrock.amazonaws.com"
| filter eventName = "InvokeModel"
| filter userIdentity.arn not like /hrr-summarizer-lambda-role/
| filter requestParameters.modelId = "anthropic.claude-sonnet-4-6-v1:0"
| sort @timestamp desc
| limit 50
This is the query that actually caught the staging Lambda I mentioned earlier. The role in the results wasn't hrr-summarizer-lambda-role, it was a shared hrr-tickets-shared-role that three unrelated functions were all using. That role had bedrock:InvokeModel on Resource: "*" left over from an early prototype, and a staging function nobody had scoped down was quietly calling production Claude with staging data that included real customer emails.
I turned that query into a scheduled EventBridge rule that runs it every fifteen minutes and posts to a Slack channel if it returns anything. Nothing fancy, just a Lambda that runs the Logs Insights query via the SDK and checks if the result set is non-empty. It's caught two more misconfigurations since, both from roles that were shared across functions for no better reason than "it was already there."
The gitleaks Rule for Hardcoded Model ARNs
The IAM side is solved. The other leak I kept finding was in test code, of all places. Someone writing an integration test would hardcode a foundation model ARN directly into a fixture instead of pulling it from a Terraform output or an env var, and six months later that ARN is stale, pointing at a model version that got deprecated, or worse, it's pointing at a more expensive model than the test actually needs because someone copy-pasted it from a different function's config.
I added a custom rule to our .gitleaks.toml to flag it:
[[rules]]
id = "hardcoded-bedrock-model-arn"
description = "Bedrock foundation model ARN hardcoded instead of sourced from Terraform output or env var"
regex = '''arn:aws:bedrock:[a-z0-9-]+::foundation-model/[a-zA-Z0-9\.\-:]+'''
[[rules.allowlist]]
paths = ['''infra/.*\.tf''']
The allowlist matters here. Terraform files are allowed to reference model ARNs directly, that's the source of truth. Everything else, test fixtures, seed scripts, notebooks someone left in the repo, gets flagged. It's a small rule, but it's caught more issues than I expected, mostly agent-generated test code that hardcodes whatever ARN it saw in a nearby file rather than importing the actual constant.
The CI Check That Blocks the Merge
None of the above matters if a wildcard Bedrock policy can still slip through in a PR. So the last piece is a GitHub Actions job that runs on every PR touching infra/, parses the Terraform plan as JSON, and fails the build if any Bedrock IAM statement grants a wildcard resource.
name: bedrock-iam-guard
on:
pull_request:
paths:
jobs:
validate-bedrock-policies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform plan as JSON
- name: Fail on wildcard Bedrock resources
And the guard script itself, which walks every aws_iam_policy resource change in the plan and checks for wildcard resources on Bedrock actions:
import json
plan = json.load(open(sys.argv[1]))
violations = []
for change in plan.get("resource_changes", []):
if change["type"] != "aws_iam_policy":
continue
after = change["change"].get("after") or {}
raw_policy = after.get("policy")
if not raw_policy:
continue
policy = json.loads(raw_policy)
for stmt in policy.get("Statement", []):
actions = stmt.get("Action", [])
actions = [actions] if isinstance(actions, str) else actions
resources = stmt.get("Resource", [])
resources = [resources] if isinstance(resources, str) else resources
is_bedrock = any(a.startswith("bedrock:") for a in actions)
has_wildcard = "*" in resources
if is_bedrock and has_wildcard:
violations.append(
f"{change['address']}: wildcard Resource on a bedrock:* action"
)
if violations:
print("Bedrock IAM guard failed:")
for v in violations:
print(f" - {v}")
sys.exit(1)
print("Bedrock IAM guard passed, no wildcard resources found.")
This is the check that would have stopped the agent-generated wildcard I mentioned earlier before it ever reached a Terraform apply. It has no idea whether a scoped policy points at the right model, and it doesn't need to. All it does is turn "Resource: *" on a Bedrock action into a hard failure instead of something a reviewer has to spot by eye in a 300-line diff, which, if I'm honest, is exactly the kind of thing I miss when I'm reviewing my fifth PR of the day.
What This Actually Buys You
None of this stops a determined attacker with full account access. That's not the threat model. The threat model is the much more common one: a shared role that grew too permissive over a few sprints, a test fixture with a stale ARN, an agent that widened a policy to unblock itself, a staging function that should never have touched production data. Scoped policies, a permission boundary, a CloudTrail alert, and a CI gate close off almost all of that, and they cost nothing extra to run since Bedrock's per-token pricing doesn't change based on how tightly you've scoped the IAM around it.
The tedious part is doing this per function instead of per account. I won't pretend that's fun. But the alternative is finding out about a misconfigured role from a CloudTrail log after the fact, which is a much worse way to spend a Tuesday.
Hope this saves you the debugging session it cost me. If you're locking down Bedrock access on your own stack, I'd like to hear what you found, come tell me on Twitter at @harundotdev.
On this post
Comments
A reply stays under the note it answers.
No comments yet.
If you have a note on How I Lock Down Bedrock Access So My Lambda Functions Can't Leak Data Through the LLM, sign in and leave it.