AWS Builder Center

Deep Dive on Managing Bedrock (In. Claude Code over bedrock) with AWS Budgets + Tags

A detailed manual that walks through setting up a complete per-developer cost attribution system for Amazon Bedrock, using AWS Cognito for authentication and IAM session tags for cost tracking. It is designed for teams who want to monitor how much each developer spends on Bedrock API calls (e.g., Claude model invocations), broken down by individual user identity.

AWS Cognito + Bedrock: Per-User Cost Tracking

Part 1 — Cognito · Step-by-step console guide · No CDK · No CLI scripting
Using a third part Identity provider? also look here  for Auth0 Example

By the end, you will have: - A Cognito User Pool with developers who can authenticate - An IAM role that developers assume with a cost allocation tag (their alias) - A Bedrock API call that shows up in CloudTrail within minutes (not hours) with the tag - Cost Explorer configured to break down Bedrock spend per developer

What we are building

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Developer authenticates via Cognito Hosted UI
|
v
Gets OIDC token (JWT with email claim)
|
v
Identity Pool exchanges token for base AWS credentials
|
v
Calls sts:AssumeRole on same role with session tag:
- aws:RequestTag/alias = "alice"
- RoleSessionName = "alias-alice"
|
v
Calls Bedrock (e.g. Claude) -> request succeeds
|
v
CloudTrail logs the call within minutes with the session name + tags
|
v
Cost Explorer shows per-developer Bedrock spend (after 24h activation)
The two-step flow (Identity Pool credentials then AssumeRole) is needed because GetCredentialsForIdentity does not support the Tags parameter directly.

Prerequisites

  • An AWS account with admin access
  • Bedrock model access enabled (at least one Claude model)
  • Python 3.10+ on the developer machine
  • A browser

Step 1: Create a Cognito User Pool

  1. Open the AWS Console -> search for Cognito -> click User pools
  2. Click Create user pool
  3. Configure sign-in experience:
  4. Authentication providers: Cognito user pool
  5. Cognito user pool sign-in options: check Email
  6. Click Next
  7. Configure security requirements:
  8. Password policy: keep defaults (or customize)
  9. MFA: No MFA (simplest for this guide; add later if needed)
  10. Click Next
  11. Configure sign-up experience:
  12. Self-registration: uncheck (admins create users)
  13. Required attributes: email should already be listed
  14. Click Next
Adding custom attributes (e.g.team) after pool creation: 1. Finish creating the user pool 2. In the left navigation bar -> click Authentication -> Sign-up 3. Scroll to Custom claims 4. Add your attribute: name team (write just team, not custom:team — Cognito adds the prefix automatically; it will appear as custom:team in the JWT) 5. Save changes
  1. Configure message delivery:
  2. Email provider: Send email with Cognito (simplest)
  3. Click Next
  4. Integrate your app:
  5. User pool name: bedrock-developers
  6. Application type: Single page application (creates a public client with PKCE — no secret required)
  7. App client name: bedrock-cli
  8. Click Next
  9. Review and click Create user pool
Note — Hosted UI, callback URL, and scopes are configured AFTER creation: Once the user pool is created, go to: User Pool - > Overview -> App configuration section -> Login pages tab -> Managed login pages configurationThere you will find: - Allowed callback URLs -> add http://localhost:8400/callback - OAuth 2.0 grant types -> check Authorization code grant - OpenID Connect scopes -> check OpenID , EmailSave the changes before continuing to Step 3.
After creation, note down: - User pool ID (format: us-east-1_xxxxxxxxx) — visible on the user pool overview page - Client ID — go to App integration tab -> scroll to App clients and analytics -> copy the Client ID

Step 2: Create test users

  1. In your user pool, go to the Users tab
  2. Click Create user
  3. Fill in:
  4. Invitation message: Send an email invitation
  5. Email address: alice@yourcompany.com
  6. Mark email address as verified: check
  7. Temporary password: Set a password -> enter TempPass123!
  8. Click Create user
  9. Repeat for bob@yourcompany.com
On first login, each user will be prompted to set a permanent password.

Step 3: Create a Cognito Identity Pool

The Identity Pool exchanges Cognito tokens for temporary AWS credentials without requiring a manual IAM OIDC provider registration.
  1. Open the AWS Console -> search for Cognito -> click Identity pools (not User pools)
  2. Click Create identity pool
  3. Configure:
  4. Identity pool name: bedrock-developers-identity
  5. Authentication providers -> Cognito tab:
    • User Pool ID: paste your User Pool ID (from Step 1)
    • App client ID: paste your Client ID (from Step 1)
  6. Click Create pool
  7. On the next screen, AWS will ask you to create an IAM role for authenticated users:
  8. For the authenticated role : name it BedrockCognitoRole
  9. Click Allow
  10. Note down the Identity Pool ID (format: us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
Important: Note the full ARN of the role that was created. The Identity Pool wizard creates roles under the /service-role/ path, so the ARN will be arn:aws:iam::<ACCOUNT_ID>:role/service-role/BedrockCognitoRole — not arn:aws:iam::<ACCOUNT_ID>:role/BedrockCognitoRole. Use the full ARN (with /service-role/) in all subsequent steps and in the login script.

Step 4: Configure the IAM Role

4a. Attach Bedrock permissions

  1. IAM -> Roles -> search for BedrockCognitoRole
  2. Click the role -> Permissions tab -> Add permissions -> Attach policies
  3. Search for and check AmazonBedrockFullAccess
  4. Make sure to add bedrock-mantle:CreateInference Action to the allow list to enable bedrock mantle
  5. Click Add permissions

4b. Edit the trust policy to allow self-assumption with tags

  1. Go to the role -> Trust relationships tab -> Edit trust policy
  2. Replace the entire policy with:
1
{
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "cognito-identity.amazonaws.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"cognito-identity.amazonaws.com:aud": "<IDENTITY_POOL_ID>"
},
"ForAnyValue:StringLike": {
"cognito-identity.amazonaws.com:amr": "authenticated"
}
}
},
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<ACCOUNT_ID>:root"
},
"Action": ["sts:AssumeRole", "sts:TagSession"]
}
]
}
Replace <ACCOUNT_ID> and <IDENTITY_POOL_ID> with your values.
Why:root instead of the role ARN? Using :root avoids the "Invalid principal" error when the role was just created. It allows any principal in your account to call AssumeRole on this role — but only the role itself has the inline AllowSelfAssume policy that grants the actual permission."Invalid principal in policy" error: If you just created the role, IAM needs 15-20 seconds to propagate. Wait and click Update policy again with the same JSON — do not change anything.
  1. Click Update policy

4c. Add inline policy for STS self-assume

  1. Go to the role -> Permissions tab -> Add permissions -> Create inline policy
  2. Switch to JSON and paste:
1
{
1
2
3
4
5
6
7
8
9
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["sts:AssumeRole", "sts:TagSession"],
"Resource": "arn:aws:iam::<ACCOUNT_ID>:role/service-role/BedrockCognitoRole"
}
]
}
Important: The resource ARN must include /service-role/ to match the actual role path created by the Identity Pool wizard. If you omit it, the self-assume will fail with AccessDenied.
  1. Name it AllowSelfAssume -> Create policy

Step 5: Activate the cost allocation tag

  1. Open the AWS Console -> Billing and Cost Management -> left nav -> Cost allocation tags
  2. Click the AWS-generated cost allocation tags tab
  3. Search for aws:PrincipalTag/alias
  4. If it does not appear yet, it will show up after the first tagged API call. Come back here after Step 7.
  5. Select it -> click Activate
Note: It takes up to 24 hours for a newly activated tag to start appearing in Cost Explorer reports. But CloudTrail shows the tags immediately.

Step 6: Developer login script

Save the following as ~/.aws/bedrock_login.py on the developer's machine and fill in the CONFIG section at the top — that is the only change needed.
To delete cached credentials and re-login: rm ~/.aws/.oidc_token_cache.json ~/.aws/.bedrock_creds_cache.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
bedrock_login.py - Cognito Identity Pool -> AWS credentials with alias tag.
Called automatically by AWS credential_process.

Save as: ~/.aws/bedrock_login.py

Add to ~/.aws/config:
[profile bedrock]
credential_process = /bin/sh -c 'python3 $HOME/.aws/bedrock_login.py'
region = us-east-1
"""


from botocore import UNSIGNED
from botocore.config import Config
import json, base64, re, sys, os, subprocess, time, hashlib
from urllib.parse import urlencode, urlparse, parse_qs
from http.server import HTTPServer, BaseHTTPRequestHandler

# -- Auto-install dependencies
DEPS_DIR = os.path.expanduser("~/.aws/.bedrock_deps")

def ensure_deps():
sys.path.insert(0, DEPS_DIR)
try:
import boto3, requests
except ImportError:
print("First run: installing dependencies...", file=sys.stderr)
subprocess.check_call([sys.executable, "-m", "pip", "install",
"--target", DEPS_DIR, "boto3", "requests"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
import boto3, requests

ensure_deps()
import boto3
import requests as req

# ============ CONFIGURATION — fill in your values ============
CLIENT_ID = "<CLIENT_ID>"
COGNITO_DOMAIN = "https://<YOUR_DOMAIN_PREFIX>.auth.us-east-1.amazoncognito.com"
ROLE_ARN = "arn:aws:iam::<ACCOUNT_ID>:role/service-role/BedrockCognitoRole"
REGION = "us-east-1"
IDENTITY_POOL_ID = "<IDENTITY_POOL_ID>" # e.g. us-east-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
USER_POOL_ID = "<USER_POOL_ID>" # e.g. us-east-1_xxxxxxxxx
# =============================================================

REDIRECT_URI = "http://localhost:8400/callback"
SCOPES = ["openid", "email"]
TOKEN_CACHE = os.path.expanduser("~/.aws/.oidc_token_cache.json")
AWS_CREDS_CACHE = os.path.expanduser("~/.aws/.bedrock_creds_cache.json")

def sanitize_tag(value):
return re.sub(r'[^\w_.:/=+\-@ ]', '-', str(value)).strip()[:256]

def decode_jwt(token):
payload = token.split(".")[1]
payload += "=" * (4 - len(payload) % 4)
return json.loads(base64.b64decode(payload))

def extract_alias(claims):
email = claims.get("email", "")
if email and "@" in email:
return email.split("@")[0].lower()
return claims.get("sub", "unknown")[:20].lower()

def try_cached_creds():
if not os.path.exists(AWS_CREDS_CACHE):
return
try:
with open(AWS_CREDS_CACHE) as f:
cached = json.load(f)
from datetime import datetime, timezone
exp = datetime.strptime(cached["Expiration"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
if (exp - datetime.now(timezone.utc)).total_seconds() > 300:
print(json.dumps(cached))
sys.exit(0)
except (json.JSONDecodeError, KeyError, ValueError):
pass

try_cached_creds()

def load_token_cache():
if not os.path.exists(TOKEN_CACHE):
return None
try:
with open(TOKEN_CACHE) as f:
return json.load(f)
except (json.JSONDecodeError, ValueError):
return None

def save_token_cache(data):
os.makedirs(os.path.dirname(TOKEN_CACHE), exist_ok=True)
with open(TOKEN_CACHE, "w") as f:
json.dump(data, f)

def try_refresh_token(cached):
refresh_token = cached.get("refresh_token")
if not refresh_token:
return None
resp = req.post(f"{COGNITO_DOMAIN}/oauth2/token", data={
"grant_type": "refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
}, timeout=10)
if resp.status_code != 200:
return None
token_data = resp.json()
if "id_token" not in token_data:
return None
token_data.setdefault("refresh_token", refresh_token)
save_token_cache(token_data)
return token_data

def do_authorization_code_flow():
code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode()

auth_url = f"{COGNITO_DOMAIN}/oauth2/authorize?" + urlencode({
"client_id": CLIENT_ID, "response_type": "code",
"redirect_uri": REDIRECT_URI, "scope": " ".join(SCOPES),
"state": state, "code_challenge": code_challenge,
"code_challenge_method": "S256",
})

auth_code = [None]
auth_error = [None]

class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
qs = parse_qs(urlparse(self.path).query)
if qs.get("state", [None])[0] != state:
auth_error[0] = "State mismatch"
elif "error" in qs:
auth_error[0] = qs.get("error_description", qs["error"])[0]
elif "code" in qs:
auth_code[0] = qs["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
msg = b"<h2>Login successful. You can close this window.</h2>" if auth_code[0] else f"<h2>Login failed: {auth_error[0]}</h2>".encode()
self.wfile.write(msg)
def log_message(self, *args): pass

server = HTTPServer(("localhost", 8400), CallbackHandler)
import webbrowser
webbrowser.open(auth_url)
server.timeout = 120
while auth_code[0] is None and auth_error[0] is None:
server.handle_request()
server.server_close()

if auth_error[0]:
print(f"Login error: {auth_error[0]}", file=sys.stderr); sys.exit(1)
if not auth_code[0]:
print("Login timed out", file=sys.stderr); sys.exit(1)

resp = req.post(f"{COGNITO_DOMAIN}/oauth2/token", data={
"grant_type": "authorization_code", "client_id": CLIENT_ID,
"code": auth_code[0], "redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier, "scope": " ".join(SCOPES),
}, timeout=10)
if resp.status_code != 200:
print(f"Token exchange failed: {resp.text}", file=sys.stderr); sys.exit(1)
token_data = resp.json()
if "id_token" not in token_data:
print(f"No id_token in response: {resp.text}", file=sys.stderr); sys.exit(1)
save_token_cache(token_data)
return token_data

# -- Main flow
token_data = None
cached = load_token_cache()
if cached:
token_data = try_refresh_token(cached)
if not token_data:
token_data = do_authorization_code_flow()

id_token = token_data["id_token"]
claims = decode_jwt(id_token)
alias = extract_alias(claims)
session_name = f"alias-{re.sub(r'[^a-zA-Z0-9=,.@_-]', '-', alias)[:58]}"

tags = [{"Key": "alias", "Value": sanitize_tag(alias)}]
transitive_keys = ["alias"]
email = claims.get("email")
if email:
tags.append({"Key": "Email", "Value": sanitize_tag(email)})
transitive_keys.append("Email")

# -- Identity Pool: exchange Cognito token for base AWS credentials
cognito_identity = boto3.client("cognito-identity", region_name=REGION,
config=Config(signature_version=UNSIGNED))
logins = {f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}": id_token}

identity_resp = cognito_identity.get_id(IdentityPoolId=IDENTITY_POOL_ID, Logins=logins)
creds_resp = cognito_identity.get_credentials_for_identity(
IdentityId=identity_resp["IdentityId"], Logins=logins)
initial = creds_resp["Credentials"]

# -- Re-assume same role WITH tags
sts_tagged = boto3.client("sts", region_name=REGION,
aws_access_key_id=initial["AccessKeyId"],
aws_secret_access_key=initial["SecretKey"], # Identity Pool uses SecretKey
aws_session_token=initial["SessionToken"],
)
final = sts_tagged.assume_role(
RoleArn=ROLE_ARN, RoleSessionName=session_name,
Tags=tags, TransitiveTagKeys=transitive_keys,
DurationSeconds=3600,
)
creds = final["Credentials"]

output = {
"Version": 1,
"AccessKeyId": creds["AccessKeyId"],
"SecretAccessKey": creds["SecretAccessKey"],
"SessionToken": creds["SessionToken"],
"Expiration": creds["Expiration"].strftime("%Y-%m-%dT%H:%M:%SZ"),
}
with open(AWS_CREDS_CACHE, "w") as f:
json.dump(output, f)
print(json.dumps(output))

Step 7: Configure the AWS profile and test

Add to ~/.aws/config:
1
2
3
[profile bedrock]
credential_process = /bin/sh -c 'python3 $HOME/.aws/bedrock_login.py'
region = us-east-1
Test:
1
aws sts get-caller-identity --profile bedrock
Expected:
1
2
3
4
5
{
"UserId": "AROAXXXXXXXXXXXX:alias-alice",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/service-role/BedrockCognitoRole/alias-alice"
}

Step 8: Make a Bedrock call

1
2
3
4
5
6
aws bedrock-runtime invoke-model \
--model-id us.anthropic.claude-sonnet-4-6 \
--body '{"anthropic_version":"bedrock-2023-05-31","max_tokens":50,"messages":[{"role":"user","content":"Say hello"}]}' \
--cli-binary-format raw-in-base64-out \
--profile bedrock \
/tmp/out.json && cat /tmp/out.json

Step 9: Verify in CloudTrail (immediate)

CloudTrail logs API calls within 5-15 minutes:
1
2
3
4
5
6
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=alias-alice \
--region us-east-1 \
--max-results 10 \
--query 'Events[*].{Event:EventName,Time:EventTime,User:Username}' \
--output table
Change alias-alice to match whichever user you tested with. If no results appear yet, wait 5-10 more minutes.

Step 10: Verify in Cost Explorer (after 24h)

  1. Billing and Cost Management -> Cost Explorer
  2. Filter by Service: Amazon Bedrock
  3. Group by: Tag -> select alias

Step 11: Set per-developer budget alerts (optional)

Use AWS Budgets to get an email alert when a developer's daily Bedrock spend crosses a threshold.
Note: AWS Budgets data has a 24-hour lag — alerts fire the day after the threshold is crossed, not in real-time. For same-hour alerting, see the BedrockBudgetManagement project.

Create a budget per developer

  1. Open the AWS Console -> Billing and Cost Management -> left nav -> Budgets
  2. Click Create budget
  3. Select Customize (advanced) -> Cost budget -> click Next
  4. Fill in:
  5. Budget name : alice-daily-bedrock
  6. Period : Daily
  7. Budgeted amount : e.g. $5
  8. Under Filters -> Add filter :
  9. Filter dimension: Tag
  10. Tag key: alias
  11. Tag value: alice
  12. Add a second filter: Service = Amazon Bedrock
  13. Click Next
  14. Under Set alert thresholds , click Add alert threshold :
  15. Alert type: Actual
  16. Trigger: 80% of budgeted amount (or 100%)
  17. Email recipients: alice@yourcompany.com
  18. Click Next -> Create budget
Repeat for each developer (bob, etc.) or create a single shared budget with only the Service = Bedrock filter to track total spend.

View budget status

Billing -> Budgets -> click on the budget name to see a graph of actual vs budgeted spend over time.

Summary

WhatWhereLatency
API call with user identityCloudTrail Event History5-15 minutes
Session tags on AssumeRoleCloudTrail Event History5-15 minutes
Per-user cost breakdownCost Explorer24 hours after tag activation

Troubleshooting

ErrorCauseFix
AccessDenied on AssumeRoleMissing /service-role/ in role ARNUse full ARN: arn:aws:iam::<ACCOUNT_ID>:role/service-role/BedrockCognitoRole
Invalid principal in policyRole just created, IAM propagation delayWait 15-20 seconds, click Update policy again
InvalidIdentityPoolConfigurationExceptionIdentity Pool misconfigured or Basic auth not enabledCheck Identity Pool has correct User Pool + Client ID; enable Basic auth if using classic flow

References

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article