Governing Amazon Bedrock Costs: Team and User-Level Tracking, Budgets, and Hard Spend Controls for Claude Code
This article shows how to put cost governance around Amazon Bedrock so enterprises can safely give developers access to Claude and Claude Code. It walks through a field-tested pattern: team and user-level cost tracking with Bedrock application inference profiles and cost allocation tags, tag-based AWS Budgets with tiered alerts, and a Lambda for hard spend limits that stop usage at the cap. It solves the FinOps blocker that keeps many enterprises from rolling out GenAI to their teams.
The challenge
AWS customers are eager to put GenAI models such as Claude and Claude Code (on Amazon Bedrock) in the hands of their developers, but many cannot authorize a broad rollout until their FinOps and leadership teams have real cost governance in place. In practice, that means three capabilities before the first developer is onboarded:
- Tag-based cost allocation, so the organization knows which team (and which user) spent what.
- Budget thresholds with automated alerts.
- Hard spend controls, so usage actually stops when a team or profile exhausts its budget, rather than merely sending a notification.
Amazon Bedrock, AWS Budgets, and a small amount of automation cover all three. This article walks through the pattern, and calls out the one behavior that most often gets misread as a bug.
The solution, in two phases
Phase 1: Per-team cost tracking
Use Amazon Bedrock application inference profiles with custom cost allocation tags to measure and allocate spend at the team level.
- Create one application inference profile per team (and per model, for example a dedicated profile for Opus).
- Apply custom cost allocation tags to each profile.
- Activate the tags in the Billing console so they become filterable in Cost Explorer.
Further reading: Track, allocate, and manage your generative AI cost and usage with Amazon Bedrock
Phase 2: User-level cost tracking
When you need to attribute spend to individual users, add IAM principal cost allocation tags and report on them through Cost and Usage Reports (CUR).
Further reading: IAM Principal Cost Allocation Tags
Budgets and alerts
Configure tag-based AWS Budgets per inference profile, with tiered alert thresholds at 50%, 80%, 100%, and forecasted 100%. This gives FinOps early warning well before a team reaches its cap.
Enforcement: hard spend controls
Alerts alone do not stop spend. To enforce a hard limit, pair each budget with an automated AWS Lambda function that terminates inference access when the profile's budget is exhausted. Once the limit is hit, Claude Code stops working for that profile.
Further reading:
Implementing the hard spend control (Lambda)
Alerts tell you a team is approaching its cap; they do not stop spend. The piece that actually enforces the limit is a small Lambda wired to the budget. The flow is:
AWS Budgets (per inference profile, tag-based)
| budget hits 100% threshold
v
Amazon SNS topic
| triggers
v
AWS Lambda --> Attaches an explicit Deny IAM policy
(deny bedrock:InvokeModel* for that team's role)
|
v
Claude Code calls to Bedrock are blocked
Keep the 50%, 80%, and forecasted-100% notifications as email-only alerts. Wire only the 100% actual-spend budget to this Lambda, so enforcement fires exactly at the cap.
The Lambda function (Python 3.12)
import json import os import boto3 from datetime import datetime, timezone iam = boto3.client("iam") # ---- Configuration (set via Lambda environment variables) ---- # TARGET_ROLE_NAME : the IAM role used by the team whose spend is capped # DENY_POLICY_NAME : name of the inline deny policy this function manages # INFERENCE_PROFILE_ARN : ARN of the application inference profile to block TARGET_ROLE_NAME = os.environ["TARGET_ROLE_NAME"] DENY_POLICY_NAME = os.environ.get("DENY_POLICY_NAME", "BedrockBudgetHardStop") INFERENCE_PROFILE_ARN = os.environ["INFERENCE_PROFILE_ARN"] def _deny_policy_document(profile_arn: str) -> dict: """Explicit deny for Bedrock invocation on the capped inference profile. An explicit Deny always overrides any Allow, so this reliably stops invocation regardless of the team's other permissions. """ return { "Version": "2012-10-17", "Statement": [ { "Sid": "BudgetHardStopDenyInvoke", "Effect": "Deny", "Action": [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:Converse", "bedrock:ConverseStream", ], "Resource": [ profile_arn, "arn:aws:bedrock:*::foundation-model/*", ], } ], } def lambda_handler(event, context): """Invoked by SNS when the tag-based budget reaches 100%. Attaches (or refreshes) an inline Deny policy on the target role. Idempotent: safe to run repeatedly for the same budget breach. """ print("Received event:", json.dumps(event)[:2000]) policy_doc = _deny_policy_document(INFERENCE_PROFILE_ARN) iam.put_role_policy( RoleName=TARGET_ROLE_NAME, PolicyName=DENY_POLICY_NAME, PolicyDocument=json.dumps(policy_doc), ) stamp = datetime.now(timezone.utc).isoformat() msg = ( f"[{stamp}] Hard stop applied: attached '{DENY_POLICY_NAME}' deny " f"policy to role '{TARGET_ROLE_NAME}' for profile " f"{INFERENCE_PROFILE_ARN}. Bedrock invocation is now blocked." ) print(msg) return {"statusCode": 200, "body": msg}
Re-enabling access (lifting the hard stop)
When the budget resets for a new period or the limit is raised, remove the deny so the team can invoke Bedrock again. Deploy this as a separate Lambda (or run it manually), triggered by a budget-reset notification or an operator action:
import os import boto3 iam = boto3.client("iam") TARGET_ROLE_NAME = os.environ["TARGET_ROLE_NAME"] DENY_POLICY_NAME = os.environ.get("DENY_POLICY_NAME", "BedrockBudgetHardStop") def lambda_handler(event, context): try: iam.delete_role_policy( RoleName=TARGET_ROLE_NAME, PolicyName=DENY_POLICY_NAME, ) msg = f"Removed deny policy '{DENY_POLICY_NAME}' from '{TARGET_ROLE_NAME}'. Bedrock access restored." except iam.exceptions.NoSuchEntityException: msg = f"No deny policy '{DENY_POLICY_NAME}' on '{TARGET_ROLE_NAME}'. Nothing to remove." print(msg) return {"statusCode": 200, "body": msg}
IAM execution role for the Lambda
The Lambda's own execution role needs permission to modify the target role's inline policy. Scope the resource to the specific role ARN for least privilege:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ManageTeamRoleDenyPolicy", "Effect": "Allow", "Action": ["iam:PutRolePolicy", "iam:DeleteRolePolicy", "iam:GetRolePolicy"], "Resource": "arn:aws:iam::<ACCOUNT_ID>:role/<TARGET_ROLE_NAME>" }, { "Sid": "Logs", "Effect": "Allow", "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"], "Resource": "arn:aws:logs:*:<ACCOUNT_ID>:*" } ] }
Deployment steps
- Create the SNS topic the budget will notify:
aws sns create-topic --name bedrock-budget-hardstop - Create the Lambda execution role with the IAM policy above (trust policy allowing
lambda.amazonaws.com), and note its ARN. - Package and create the enforcement Lambda:
zip function.zip lambda_function.py aws lambda create-function \ --function-name bedrock-budget-hardstop \ --runtime python3.12 \ --role arn:aws:iam::<ACCOUNT_ID>:role/<LAMBDA_EXEC_ROLE> \ --handler lambda_function.lambda_handler \ --timeout 30 \ --zip-file fileb://function.zip \ --environment "Variables={TARGET_ROLE_NAME=<TEAM_ROLE>,INFERENCE_PROFILE_ARN=<PROFILE_ARN>,DENY_POLICY_NAME=BedrockBudgetHardStop}" - Subscribe the Lambda to the SNS topic and grant SNS permission to invoke it:
aws sns subscribe \ --topic-arn arn:aws:sns:<REGION>:<ACCOUNT_ID>:bedrock-budget-hardstop \ --protocol lambda \ --notification-endpoint arn:aws:lambda:<REGION>:<ACCOUNT_ID>:function:bedrock-budget-hardstop aws lambda add-permission \ --function-name bedrock-budget-hardstop \ --statement-id sns-invoke \ --action lambda:InvokeFunction \ --principal sns.amazonaws.com \ --source-arn arn:aws:sns:<REGION>:<ACCOUNT_ID>:bedrock-budget-hardstop - Allow AWS Budgets to publish to the topic by adding this statement to the topic's access policy:
{ "Sid": "AllowBudgetsPublish", "Effect": "Allow", "Principal": { "Service": "budgets.amazonaws.com" }, "Action": "SNS:Publish", "Resource": "arn:aws:sns:<REGION>:<ACCOUNT_ID>:bedrock-budget-hardstop" } - Wire the budget to the topic: on the tag-based budget for the inference profile, add a notification at 100% of actual cost targeting the
bedrock-budget-hardstoptopic. Leave the 50%, 80%, and forecasted-100% notifications as email-only. - Test the loop: set a very low limit (for example
$1) on a test profile, generate a little tagged Bedrock usage, and confirm the deny policy is attached and Claude Code calls returnAccessDeniedException. Then run the re-enable Lambda and restore the real limit.
Notes and gotchas
- Explicit Deny is intentional. It overrides any Allow the team already has, so enforcement is reliable regardless of how broad their Bedrock permissions are.
- Budget data lag applies here too. AWS Budgets evaluates on the usual billing-data cadence, so the 100% trigger fires when the data catches up, not the instant the spend occurs. Size the limit with a small buffer if you need a firm ceiling.
- Scope per team. Use one target role, budget, and profile per team so one team hitting its cap does not block others.
- Alternatives. For larger environments, attach/detach a standalone managed policy or gate access with a Service Control Policy instead of an inline role policy.
One-step deployment (CloudFormation / SAM)
If you would rather not run the CLI steps by hand, the template below provisions the whole enforcement path in one stack: the SNS topic, its access policy allowing AWS Budgets to publish, the Lambda execution role, and the enforcement Lambda subscribed to the topic. You still create the tag-based budget separately (budgets are usually owned by FinOps), then point its 100% notification at the stack's SNS topic output.
Save as template.yaml and deploy with:
aws cloudformation deploy \ --template-file template.yaml \ --stack-name bedrock-budget-hardstop \ --capabilities CAPABILITY_NAMED_IAM \ --parameter-overrides \ TargetRoleName=<TEAM_ROLE> \ InferenceProfileArn=<PROFILE_ARN>
AWSTemplateFormatVersion: "2010-09-09" Description: > Hard spend control for Amazon Bedrock. When a tag-based AWS Budget reaches 100%, an SNS notification invokes a Lambda that attaches an explicit Deny policy to the team's role, blocking bedrock:InvokeModel until lifted. Parameters: TargetRoleName: Type: String Description: IAM role used by the team whose Bedrock spend is capped. InferenceProfileArn: Type: String Description: ARN of the application inference profile to block. DenyPolicyName: Type: String Default: BedrockBudgetHardStop Description: Name of the inline deny policy the Lambda manages. Resources: HardStopTopic: Type: AWS::SNS::Topic Properties: TopicName: bedrock-budget-hardstop # Allow AWS Budgets to publish to the topic. HardStopTopicPolicy: Type: AWS::SNS::TopicPolicy Properties: Topics: - !Ref HardStopTopic PolicyDocument: Version: "2012-10-17" Statement: - Sid: AllowBudgetsPublish Effect: Allow Principal: Service: budgets.amazonaws.com Action: "SNS:Publish" Resource: !Ref HardStopTopic EnforcementFunctionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: "sts:AssumeRole" Policies: - PolicyName: ManageTeamRoleDenyPolicy PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - "iam:PutRolePolicy" - "iam:DeleteRolePolicy" - "iam:GetRolePolicy" Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/${TargetRoleName}" - Effect: Allow Action: - "logs:CreateLogGroup" - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: !Sub "arn:aws:logs:*:${AWS::AccountId}:*" EnforcementFunction: Type: AWS::Lambda::Function Properties: FunctionName: bedrock-budget-hardstop Runtime: python3.12 Handler: index.lambda_handler Timeout: 30 Role: !GetAtt EnforcementFunctionRole.Arn Environment: Variables: TARGET_ROLE_NAME: !Ref TargetRoleName INFERENCE_PROFILE_ARN: !Ref InferenceProfileArn DENY_POLICY_NAME: !Ref DenyPolicyName Code: ZipFile: | import json import os import boto3 from datetime import datetime, timezone iam = boto3.client("iam") TARGET_ROLE_NAME = os.environ["TARGET_ROLE_NAME"] DENY_POLICY_NAME = os.environ.get("DENY_POLICY_NAME", "BedrockBudgetHardStop") INFERENCE_PROFILE_ARN = os.environ["INFERENCE_PROFILE_ARN"] def _deny_policy_document(profile_arn): return { "Version": "2012-10-17", "Statement": [ { "Sid": "BudgetHardStopDenyInvoke", "Effect": "Deny", "Action": [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream", "bedrock:Converse", "bedrock:ConverseStream", ], "Resource": [ profile_arn, "arn:aws:bedrock:*::foundation-model/*", ], } ], } def lambda_handler(event, context): print("Received event:", json.dumps(event)[:2000]) iam.put_role_policy( RoleName=TARGET_ROLE_NAME, PolicyName=DENY_POLICY_NAME, PolicyDocument=json.dumps(_deny_policy_document(INFERENCE_PROFILE_ARN)), ) stamp = datetime.now(timezone.utc).isoformat() msg = f"[{stamp}] Hard stop applied to role {TARGET_ROLE_NAME} for {INFERENCE_PROFILE_ARN}." print(msg) return {"statusCode": 200, "body": msg} # Subscribe the Lambda to the topic. HardStopSubscription: Type: AWS::SNS::Subscription Properties: TopicArn: !Ref HardStopTopic Protocol: lambda Endpoint: !GetAtt EnforcementFunction.Arn # Allow SNS to invoke the Lambda. HardStopInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref EnforcementFunction Action: "lambda:InvokeFunction" Principal: sns.amazonaws.com SourceArn: !Ref HardStopTopic Outputs: TopicArn: Description: Point your tag-based budget's 100% notification at this SNS topic. Value: !Ref HardStopTopic FunctionArn: Description: The enforcement Lambda. Value: !GetAtt EnforcementFunction.Arn
After the stack deploys, take the TopicArn output and add it as the target of a 100% actual-cost notification on your tag-based budget for the inference profile. Keep the re-enable Lambda from the previous section as a separate function (or a manual runbook) for lifting the deny when the budget resets.
The gotcha that looks like a bug
The single most common point of confusion: a newly activated cost allocation tag does not appear in Cost Explorer or AWS Budgets until a resource carrying that tag actually incurs charges.
If no resource with that specific tag key/value has generated any billing activity yet, the tag simply will not show up as a filterable dimension in Cost Explorer, and therefore cannot be selected in a budget. This is expected AWS billing behavior, not a misconfiguration. Teams frequently interpret it as "the budgets and alerts aren't working."
The fix is to set the expectation up front, and to validate the setup by generating a small amount of real tagged usage. Once the tagged inference profile incurs genuine spend, the tag surfaces and alerts fire correctly.
Related: there is a normal billing-data lag before usage appears in Cost Explorer and Budgets, so alerts will not trigger the instant after the first invocation.
Best practices and lessons learned
- Decide your tagging scheme before onboarding developers. Retrofitting tags after usage begins is messy and leaves gaps in historical allocation.
- Use one inference profile per team, per model. This keeps allocation clean and makes per-profile budgets and hard limits straightforward.
- Validate the full loop with a small real workload. Incur a few dollars of tagged spend and confirm the tag is visible end to end before declaring the budgets and alerts complete.
- Pilot, then expand. Prove the pattern on one or two teams or profiles before rolling out organization-wide.
- Confirm the enforcement path. Test that the Lambda actually blocks inference when the budget is exhausted, rather than assuming the alert alone stops spend.
Relevant content
- Accepted Answer
asked 5 months ago
AWS OFFICIALUpdated 10 months ago