Skip to content

Governing Amazon Bedrock Costs: Team and User-Level Tracking, Budgets, and Hard Spend Controls for Claude Code

11 minute read
Content level: Advanced
0

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:

  1. Tag-based cost allocation, so the organization knows which team (and which user) spent what.
  2. Budget thresholds with automated alerts.
  3. 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

  1. Create the SNS topic the budget will notify:
    aws sns create-topic --name bedrock-budget-hardstop
  2. Create the Lambda execution role with the IAM policy above (trust policy allowing lambda.amazonaws.com), and note its ARN.
  3. 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}"
  4. 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
  5. 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"
    }
  6. 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-hardstop topic. Leave the 50%, 80%, and forecasted-100% notifications as email-only.
  7. 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 return AccessDeniedException. 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.