Least privilege access is often implemented by giving users more permissions than they need, because it’s easier to start broad and then restrict later.

Let’s see how this actually looks in practice. Imagine we’re setting up a new application, "AwesomeApp," that needs to read from an S3 bucket called awesome-app-data.

Here’s a typical, non-least-privilege way to grant access:

aws iam create-role --role-name AwesomeAppS3Reader \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "ec2.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}'

aws iam attach-role-policy --role-name AwesomeAppS3Reader \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

This grants the AwesomeAppS3Reader role all read permissions for all S3 buckets in the account. The application might only need to read from awesome-app-data, but now it can see and download from any S3 bucket. This is the "easier to start broad" trap.

To implement least privilege, we need to be specific. This means creating a custom IAM policy that grants only the necessary permissions.

First, create the role that your application (or EC2 instance, Lambda function, etc.) will assume. The trust policy remains the same, allowing the service to assume this role:

aws iam create-role --role-name AwesomeAppSpecificS3Reader \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "ec2.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}'

Now, the crucial part: the custom policy. We’ll create a JSON file named awesome-app-s3-read-policy.json:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::awesome-app-data",
                "arn:aws:s3:::awesome-app-data/*"
            ]
        }
    ]
}

Notice a few key things here:

  • Action: We’re only allowing s3:GetObject (to read files) and s3:ListBucket (to see what’s in the bucket). We’re not including s3:DeleteObject, s3:PutObject, or any other write/delete operations.
  • Resource: This is where the specificity really shines.
    • arn:aws:s3:::awesome-app-data allows the ListBucket action on the bucket itself.
    • arn:aws:s3:::awesome-app-data/* allows the GetObject action on any object within that bucket.

Now, create this policy in IAM and attach it to the role:

aws iam create-policy --policy-name AwesomeAppSpecificS3ReadPolicy \
  --policy-document file://awesome-app-s3-read-policy.json

# Find the ARN of the policy you just created. It will look like:
# arn:aws:iam::<your-aws-account-id>:policy/AwesomeAppSpecificS3ReadPolicy
# Then attach it:
aws iam attach-role-policy --role-name AwesomeAppSpecificS3Reader \
  --policy-arn arn:aws:iam::<your-aws-account-id>:policy/AwesomeAppSpecificS3ReadPolicy

This policy grants only the permissions needed for the AwesomeAppSpecificS3Reader role to read objects from and list the contents of the awesome-app-data bucket. It cannot read from other buckets, nor can it modify or delete objects.

The mental model here is about defining explicit "allow" statements for the minimal set of actions on the specific resources required. Instead of saying "you can do anything read-related on any bucket," we say "you can GetObject and ListBucket on this specific bucket and its contents."

Role-Based Access Control (RBAC) is the organizational layer on top. Instead of assigning permissions directly to individual users, you group users into roles (e.g., "Developers," "Auditors," "Application Admins") and assign IAM policies to those roles. Then, you assign users to these roles. This makes management scalable: when a new developer joins, you add them to the "Developers" group, and they inherit all the permissions associated with that group. If a user changes roles, you simply move them between groups.

The real power comes from combining granular IAM policies with RBAC. For instance, you might have an ApplicationAdmins group. Instead of giving everyone in that group the AdministratorAccess managed policy, you create custom policies like AwesomeAppSpecificS3AdminPolicy (which might include GetObject, PutObject, DeleteObject for awesome-app-data) and attach that to the ApplicationAdmins role. This ensures that even your application admins are restricted to managing only the resources they are supposed to manage.

A common pitfall is forgetting that s3:ListBucket needs to be applied to the bucket ARN (arn:aws:s3:::your-bucket-name) while s3:GetObject (and other object-level actions) needs to be applied to the object ARN (arn:aws:s3:::your-bucket-name/*). If you only specify the bucket ARN for GetObject, it won’t work. If you only specify the object ARN for ListBucket, it won’t work. This is because AWS IAM permissions are hierarchical and context-dependent based on the action.

The next step is to understand how to audit these permissions effectively.

Want structured learning?

Take the full Infrastructure Security course →