Hands-On: Users, Policies, and an EC2 Role
What We Are Building
A deploy user that can touch exactly one S3 bucket and nothing else, plus a role that gives an EC2 instance the same access without any stored keys. Everything is done from the CLI and verified by trying both allowed and forbidden actions.
Prerequisites: admin-level CLI access (your own IAM user, not root) and a test bucket. I use my-app-uploads — substitute your own bucket name and account ID 123456789012 throughout.
Step 1: Create the User
aws iam create-user --user-name deploy-bot
aws iam create-access-key --user-name deploy-bot
The second command returns the credentials — the only time the secret is ever shown:
{
"AccessKey": {
"UserName": "deploy-bot",
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Status": "Active"
}
}
Store both values in a CLI profile immediately:
aws configure --profile deploy-bot
No console password, no create-login-profile — this identity is for scripts, and the less it can do, the better.
Step 2: Write the Least-Privilege S3 Policy
Two statements, because S3 splits its permissions across two ARN shapes: bucket-level actions like ListBucket target the bucket ARN, object-level actions target bucket/*. Mixing them up is the most common reason a policy "doesn't work".
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListTheBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": "arn:aws:s3:::my-app-uploads"
},
{
"Sid": "ReadWriteObjects",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-app-uploads/*"
}
]
}
No s3:*, no Resource: "*", no ability to create or delete buckets. Create it as a customer-managed policy and attach it:
aws iam create-policy \
--policy-name S3DeployAccess \
--policy-document file://s3-deploy-policy.json
aws iam attach-user-policy \
--user-name deploy-bot \
--policy-arn arn:aws:iam::123456789012:policy/S3DeployAccess
Step 3: Test the Boundary from the CLI
First confirm who the profile is:
aws sts get-caller-identity --profile deploy-bot
{
"UserId": "AIDAEXAMPLE123456789",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/deploy-bot"
}
Now the allowed path:
echo "hello" > test.txt
aws s3 cp test.txt s3://my-app-uploads/test.txt --profile deploy-bot
aws s3 ls s3://my-app-uploads/ --profile deploy-bot
Both succeed. Now prove the fence exists:
aws s3 ls --profile deploy-bot
aws ec2 describe-instances --profile deploy-bot --region us-east-1
Both fail with AccessDenied / UnauthorizedOperation. Listing all buckets requires s3:ListAllMyBuckets, which we deliberately did not grant, and EC2 was never mentioned in the policy. A least-privilege test is not complete until the denials are observed, not assumed.
Step 4: Create a Role for EC2
The role needs a trust policy — who may assume it — separate from the permission policy we already wrote. For EC2 the trusted party is the EC2 service itself:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Create the role, attach the same S3 policy, and wrap it in an instance profile (the container EC2 actually consumes):
aws iam create-role \
--role-name app-server-role \
--assume-role-policy-document file://ec2-trust-policy.json
aws iam attach-role-policy \
--role-name app-server-role \
--policy-arn arn:aws:iam::123456789012:policy/S3DeployAccess
aws iam create-instance-profile --instance-profile-name app-server-profile
aws iam add-role-to-instance-profile \
--instance-profile-name app-server-profile \
--role-name app-server-role
Attach it to a running instance:
aws iam list-instance-profiles-for-role --role-name app-server-role
aws ec2 associate-iam-instance-profile \
--instance-id i-0abc123def456789a \
--iam-instance-profile Name=app-server-profile
Step 5: Verify the Role from Inside the Instance
SSH in and check the identity with no configured credentials at all:
aws sts get-caller-identity
{
"Arn": "arn:aws:sts::123456789012:assumed-role/app-server-role/i-0abc123def456789a"
}
The assumed-role ARN is the proof: the CLI picked up temporary credentials from the instance metadata service, and they rotate automatically. Repeat the Step 3 tests from the instance — same allowed uploads, same denials. Any Node app on this box using the AWS SDK now reaches S3 with zero keys in code or env files.
Cleanup and Habits
To remove the user later, delete in dependency order — keys, then policy attachment, then user:
aws iam delete-access-key --user-name deploy-bot --access-key-id AKIAIOSFODNN7EXAMPLE
aws iam detach-user-policy --user-name deploy-bot \
--policy-arn arn:aws:iam::123456789012:policy/S3DeployAccess
aws iam delete-user --user-name deploy-bot
Habits worth keeping:
- One identity per purpose; never share keys between a laptop and a server
- Servers get roles, humans and CI get users or federation — keys never land on EC2
- Write the denial test alongside the allow test whenever you author a policy
- Rotate any long-lived access key that is more than 90 days old