Skip to main content

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".

s3-deploy-policy.json
{
"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:

ec2-trust-policy.json
{
"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