
Safely operating AWS with AI. Creating sudo-like IAM privilege escalation with Claude Code × Bedrock
This page has been translated by machine translation. View original
AI agents that directly operate AWS are becoming increasingly common. In the previous article, I introduced a sudo-like IAM permission escalation mechanism using Kiro CLI's custom agent feature.
This time, I tried the same IAM three-tier separation architecture with "Claude Code + Amazon Bedrock."
Prerequisite: Bedrock Backend Configuration
In this article, I adopted a configuration that uses "Amazon Bedrock" as the LLM backend for Claude Code. When using Bedrock, Claude Code internally calls bedrock:InvokeModel, so the role specified with AWS_PROFILE needs Bedrock permissions. Therefore, I've granted Bedrock permissions to both ReadOnly and Elevated roles (but not to the EC2 role, for reasons explained in the architecture section).
Note that if you use Claude Max subscription or Anthropic API keys, LLM calls don't go through Bedrock. In that case, granting Bedrock permissions is unnecessary. Using this template as is might result in unintended Bedrock API charges, so please choose a configuration that matches your usage pattern.
Architecture
IAM roles are separated into three tiers, with permissions escalated gradually through AssumeRole chains.
| Role | Permissions | Purpose |
|---|---|---|
EC2 Role (claudecode-ec2-*) |
SSM + AssumeRole(ReadOnly) | Instance foundation |
ReadOnly (claudecode-readonly-*) |
ReadOnlyAccess + Bedrock + AssumeRole(Elevated) | Reference operations |
Elevated (claudecode-elevated-*) |
PowerUserAccess + Bedrock | Modification operations |
The EC2 role does not have Bedrock permissions. If you launch claude without specifying a profile, it will result in an error. This adheres to the principle of least privilege and also serves as a fail-safe against operational mistakes.
This design has a secondary security effect. Since Claude Code may use Docker when executing code, ec2-user is granted Docker execution permissions (the docker group). Being a member of the Docker group is equivalent to having root privileges on the host OS, but in this configuration, the EC2 IAM role itself does not have any access permissions to AWS resources (except for AssumeRole and SSM). Even if AI-generated code manages to escape from a Docker container to the host OS, damage to the AWS environment is blocked at the IAM level.
The difference from the previous Kiro CLI version is that Bedrock permissions (bedrock:InvokeModel, etc.) are granted to both ReadOnly and Elevated roles. This ensures that Claude Code works regardless of which profile it's launched with.
Mechanism
Permission Switching with AWS_PROFILE
The AWS profile used by Claude Code is determined by the AWS_PROFILE environment variable. By linking different IAM roles to each profile, you can select permissions at startup.
# Launch with ReadOnly
AWS_PROFILE=claudecode-readonly claude
# Launch with Elevated
AWS_PROFILE=claudecode-elevated claude
While Kiro CLI allowed switching profiles within a session with /agent swap, Claude Code requires restarting the session. This difference is explained later.
AWS CLI config
Profiles are defined in ~/.aws/config. This is automatically deployed by the CloudFormation template's UserData, so manual creation is not necessary.
[profile claudecode-readonly]
role_arn = arn:aws:iam::<ACCOUNT_ID>:role/claudecode-readonly-<STACK_NAME>
credential_source = Ec2InstanceMetadata
role_session_name = claudecode-readonly-session
[profile claudecode-elevated]
role_arn = arn:aws:iam::<ACCOUNT_ID>:role/claudecode-elevated-<STACK_NAME>
source_profile = claudecode-readonly
role_session_name = claudecode-elevated-session
credential_source = Ec2InstanceMetadata allows AssumeRole using the EC2 instance profile credentials as a starting point. source_profile = claudecode-readonly makes the elevated profile chain through the readonly one. Setting role_session_name causes CloudTrail logs to record sessions as claudecode-readonly-session or claudecode-elevated-session, making it immediately clear which profile was used.
Sharing Bedrock Permissions
Bedrock permissions are defined as a shared managed policy and attached to both ReadOnly and Elevated roles.
BedrockPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub claudecode-bedrock-${AWS::StackName}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
- bedrock:ListFoundationModels
- bedrock:ListInferenceProfiles
Resource: '*'
Avoiding IAM Role Circular References
As with the Kiro CLI version, circular references are avoided by using Principal: root + Condition: ArnEquals in the trust policy.
ReadOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-readonly-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-ec2-${AWS::StackName}
UserData
UserData performs the following four tasks:
- Installing Claude Code (
dnf install nodejs→npm install -g @anthropic-ai/claude-code) - Persisting Bedrock environment variables (placing them in
/etc/profile.d/to ensure they're loaded in login shells) - Deploying
~/.aws/config(defining 3 profiles) - Setting up Docker (as Claude Code may use it as a sandbox when executing code)
# Claude Code
dnf install -y unzip git jq nodejs docker
npm install -g @anthropic-ai/claude-code
# Bedrock environment variables (reliably reflected in login shells via /etc/profile.d/)
printf '#!/bin/bash\nexport CLAUDE_CODE_USE_BEDROCK=1\nexport ANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-6\nexport AWS_REGION=${AWS::Region}\n' > /etc/profile.d/claude-bedrock.sh
chmod 644 /etc/profile.d/claude-bedrock.sh
The Kiro CLI version required deploying agent JSON files, but Claude Code doesn't need them. Permissions are determined solely by the AWS_PROFILE environment variable.
The complete template is provided in the "Reference Information" section at the end of the article.
Deployment
aws cloudformation create-stack \
--stack-name claudecode-profile-test \
--template-body file://cfn-claudecode.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--region us-west-2
After stack creation is complete, connect via SSM.
aws ssm start-session --target <InstanceId> --region us-west-2
sudo su - ec2-user
Wait for UserData execution to complete. Even though the SSM connection may be available, processing might still be ongoing, so be sure to check completion with the following command:
cloud-init status --wait
$ cloud-init status --wait
..............................................................
status: done
Once status: done is displayed, first exit and then log back in with sudo su - ec2-user. This will ensure that aliases (claude-ro / claude-el) added to .bashrc by UserData and environment variables in /etc/profile.d/ are reflected in your shell.
exit
sudo su - ec2-user
<details>
<summary>Troubleshooting if cloud-init fails</summary>
# Check for normal termination ("Cloud-init ... finished" and elapsed time should be in the last line)
sudo tail -1 /var/log/cloud-init-output.log
Cloud-init v. 22.2.2 finished at ... Up 130.36 seconds
# Check that there are no errors
sudo grep -i error /var/log/cloud-init-output.log
</details>
Verification: ReadOnly Profile
I started Claude Code with the ReadOnly profile.
AWS_PROFILE=claudecode-readonly claude
Read Operations (Successful)
I requested a list of S3 buckets.
❯ Execute aws s3 ls
● Bash(aws s3 ls)
⎿ 2024-11-12 07:00:43 athena-123456789012
2024-11-12 07:00:43 athena-123456789012-apne1
2024-11-09 03:27:04 athena-temp-ap-northeast-1-000000000000
… +35 lines
Read operations work normally with the ReadOnly profile.
Modification Operations (Denied)
I requested creating an S3 bucket.
❯ Execute aws s3 mb s3://claudecode-test-0223-delete-me
● Bash(aws s3 mb s3://claudecode-test-0223-delete-me)
⎿ Error: Exit code 1
make_bucket failed: s3://claudecode-test-0223-delete-me An error occurred
(AccessDenied) when calling the CreateBucket operation: User:
arn:aws:sts::123456789012:assumed-role/claudecode-readonly-claudecode-profile-test/claudecode-readonly-session
is not authorized to perform: s3:CreateBucket on resource:
"arn:aws:s3:::claudecode-test-0223-delete-me" because no identity-based
policy allows the s3:CreateBucket action
Modification operations are denied by IAM when using the ReadOnly profile. The error message shows the session name claudecode-readonly-session, confirming that the role_session_name setting is working.
Verification: Elevated Profile
I exited Claude Code and restarted it with the Elevated profile.
AWS_PROFILE=claudecode-elevated claude
Modification Operations (Successful)
I requested the same S3 bucket creation.
❯ Execute aws s3 mb s3://claudecode-test-0223-delete-me
● Bash(aws s3 mb s3://claudecode-test-0223-delete-me)
⎿ make_bucket: claudecode-test-0223-delete-me
With the Elevated profile (PowerUserAccess), modification operations succeed. As cleanup, I deleted the bucket.
❯ Execute aws s3 rb s3://claudecode-test-0223-delete-me
● Bash(aws s3 rb s3://claudecode-test-0223-delete-me)
⎿ remove_bucket: claudecode-test-0223-delete-me
Verification: Starting with Aliases
I started Claude Code using aliases set in UserData and confirmed that responses return via Bedrock.
$ claude-ro -p "say hello"
Hello! How can I help you today?
$ claude-el -p "say hi"
Hi! How can I help you today?
Responses return normally via Bedrock (Sonnet 4.6) with both profiles. The aliases save the trouble of typing AWS_PROFILE=claudecode-readonly claude every time.
Verification Results Summary
| Operation | ReadOnly | Elevated |
|---|---|---|
aws s3 ls (Read) |
✅ Success | ✅ Success |
aws s3 mb (Modify) |
❌ AccessDenied | ✅ Success |
aws s3 rb (Delete) |
— | ✅ Success |
claude-ro / claude-el launch |
✅ Bedrock response OK | ✅ Bedrock response OK |
I confirmed that AWS operation permissions for Claude Code can be controlled at the IAM level simply by switching AWS_PROFILE.
Comparison with Kiro CLI
Comparing with Kiro CLI introduced in the previous article:
| Aspect | Kiro CLI | Claude Code |
|---|---|---|
| Permission switching | /agent swap elevated |
AWS_PROFILE=... claude |
| Session continuity | Maintained | Restart required |
| Context carryover | Automatic (same session) | Via --resume or file |
| Control layer | Prompt + profile_name |
Environment variables (OS level) |
| IAM structure | Three tiers (common) | Three tiers + Bedrock permissions |
| Agent definition | JSON file required | Not required |
Kiro CLI can switch profiles within a session using /agent swap, so context isn't lost. Claude Code, on the other hand, determines profiles through environment variables, giving it enforcement power independent of prompts.
Techniques for Context Carryover
Claude Code requires restarting the session to switch profiles. There are several ways to compensate for this context discontinuity.
Using claude --resume restores the conversation history from the previous session. Even if the profile changes, you can still carry over what was investigated in the previous session.
Placing CLAUDE.md in the project root causes Claude Code to automatically load it every time. If you write down your work approach and background information, you can maintain a common context even if the session is interrupted.
In practical operation, the workflow would look like this:
# 1. Investigate and plan with ReadOnly
claude-ro
# → Have it save investigation results to plan.md
# → /exit
# 2. Execute with Elevated
claude-el --resume
# → Read plan.md and execute
Summary
I confirmed that permission control through IAM's three-tier separation works properly with Claude Code + Bedrock as well. The environment can be built in one go with a CloudFormation template, and ReadOnly/Elevated can be switched simply by changing AWS_PROFILE.
The three-tier IAM role structure (EC2 → ReadOnly → Elevated) is a general pattern that can be used to mitigate risks of AI operating AWS. Together with the Kiro CLI version from the previous article, consider operations that suit your team and tools.
Reference Information: CloudFormation Template
Here is the CloudFormation template used for verification. You can deploy it with aws cloudformation create-stack and start verification immediately after connecting with SSM. Cleanup is simply aws cloudformation delete-stack.
cfn-claudecode.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Claude Code + Bedrock with IAM profile switching (ReadOnly / Elevated)
Parameters:
Subnet:
Type: AWS::EC2::Subnet::Id
SecurityGroup:
Type: AWS::EC2::SecurityGroup::Id
InstanceType:
Type: String
Default: t4g.small
VolumeSize:
Type: Number
Default: 10
AmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
Resources:
BedrockPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub claudecode-bedrock-${AWS::StackName}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
- bedrock:ListFoundationModels
- bedrock:ListInferenceProfiles
Resource: '*'
ReadOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-readonly-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-ec2-${AWS::StackName}
ManagedPolicyArns:
- arn:aws:iam::aws:policy/ReadOnlyAccess
- !Ref BedrockPolicy
Policies:
- PolicyName: AllowAssumeElevated
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: sts:AssumeRole
Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-elevated-${AWS::StackName}
ElevatedRole:
Type: AWS::IAM::Role
DependsOn: ReadOnlyRole
Properties:
RoleName: !Sub claudecode-elevated-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-readonly-${AWS::StackName}
ManagedPolicyArns:
- arn:aws:iam::aws:policy/PowerUserAccess
- !Ref BedrockPolicy
Ec2Role:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-ec2-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Policies:
- PolicyName: AllowAssumeReadOnly
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: sts:AssumeRole
Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-readonly-${AWS::StackName}
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
InstanceProfileName: !Sub claudecode-ec2-${AWS::StackName}
Roles:
- !Ref Ec2Role
Ec2Instance:
Type: AWS::EC2::Instance
DependsOn: InstanceProfile
Properties:
InstanceType: !Ref InstanceType
ImageId: !Ref AmiId
SubnetId: !Ref Subnet
SecurityGroupIds:
- !Ref SecurityGroup
IamInstanceProfile: !Ref InstanceProfile
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: !Ref VolumeSize
VolumeType: gp3
Tags:
- Key: Name
Value: !Sub claudecode-${AWS::StackName}
UserData:
Fn::Base64: !Sub |
#!/bin/bash
set -eux
dnf install -y unzip git jq nodejs docker
# Claude Code
npm install -g @anthropic-ai/claude-code
# Docker
usermod -aG docker ec2-user
systemctl enable docker
systemctl start docker
# swap 512MB
dd if=/dev/zero of=/swapfile bs=1M count=512
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile swap swap defaults 0 0' >> /etc/fstab
# Bedrock environment variables
printf '#!/bin/bash\nexport CLAUDE_CODE_USE_BEDROCK=1\nexport ANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-6\nexport AWS_REGION=${AWS::Region}\n' > /etc/profile.d/claude-bedrock.sh
chmod 644 /etc/profile.d/claude-bedrock.sh
# AWS CLI config
mkdir -p /home/ec2-user/.aws
printf '[default]\nregion = ${AWS::Region}\n\n[profile claudecode-readonly]\nrole_arn = ${ReadOnlyRole.Arn}\ncredential_source = Ec2InstanceMetadata\nrole_session_name = claudecode-readonly-session\nregion = ${AWS::Region}\n\n[profile claudecode-elevated]\nrole_arn = ${ElevatedRole.Arn}\nsource_profile = claudecode-readonly\nrole_session_name = claudecode-elevated-session\nregion = ${AWS::Region}\n' > /home/ec2-user/.aws/config
# Aliases
printf '\nalias claude-ro="AWS_PROFILE=claudecode-readonly claude"\nalias claude-el="AWS_PROFILE=claudecode-elevated claude"\n' >> /home/ec2-user/.bashrc
chown -R ec2-user:ec2-user /home/ec2-user/.aws
Outputs:
InstanceId:
Value: !Ref Ec2Instance
ReadOnlyRoleArn:
Value: !GetAtt ReadOnlyRole.Arn
ElevatedRoleArn:
Value: !GetAtt ElevatedRole.Arn
SsmConnectCommand:
Value: !Sub aws ssm start-session --target ${Ec2Instance} --region ${AWS::Region}
Deployment, Cleanup
Deployment command:
aws cloudformation create-stack \
--stack-name claudecode-profile-test \
--template-body file://cfn-claudecode.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameters \
ParameterKey=Subnet,ParameterValue=<YOUR_SUBNET_ID> \
ParameterKey=SecurityGroup,ParameterValue=<YOUR_SG_ID> \
--region us-west-2
Cleanup:
aws cloudformation delete-stack --stack-name claudecode-profile-test --region us-west-2