Let's Connect

A locked metal padlock on a circuit board, representing AWS IAM least-privilege roles scoped to the exact permissions a workload needs

AWS IAM least privilege means starting from zero permissions and adding back only the exact actions a workload needs, scoped to the exact resources it touches. The anti-pattern I keep finding in audits is an EC2 instance or a CI user carrying a policy with "s3:*" on "Resource": "*", or worse, the full "Action": "*" on "Resource": "*". One leaked credential with that attached is not an incident, it is a takeover: read every bucket, delete every object, spin up instances for crypto mining on your bill. The fix is mechanical and I will walk through it: prefer roles over long-lived keys, write a policy that names specific actions and specific resource ARNs, then use IAM Access Analyzer and last-accessed data to prove nothing is over-granted.

Why is a wildcard policy so dangerous in practice?

A wildcard policy is dangerous because it turns a small leak into a full compromise. I have seen an access key committed to a public GitHub repo by a junior dev; the key had AmazonS3FullAccess attached "to make the deploy work". Within hours an automated scanner found it, and because the policy was "s3:*" on every resource, the attacker enumerated every bucket in the account, exfiltrated a customer-data bucket, and left a ransom note object in another. The radius of that breach was decided months earlier, the day someone attached a wildcard instead of scoping the policy.

Least privilege does not mean guessing a tight policy up front and hoping. It means denying by default (IAM is deny-by-default already, so this is just not adding broad grants), then widening only when a real, observed action fails. The mental model is an allow-list, not a block-list. You never write "everything except"; you write "these three actions on this one resource" and nothing else.

Roles or users: which should hold the credential?

Prefer IAM roles over IAM users with long-lived access keys, every time you can. A user's access key is a static secret: it sits in a file or an env var, it does not expire, and if it leaks it stays valid until a human notices and rotates it. A role issues temporary credentials that AWS rotates automatically and that expire on their own, usually within hours. The difference in blast radius is enormous. A leaked role credential is a window; a leaked access key is a permanent door.

For anything running on EC2, this is settled: attach a role to the instance through an instance profile, so no static keys ever live on the box. The instance retrieves short-lived credentials from the Instance Metadata Service and the SDK or CLI picks them up automatically. First, create the role with a trust policy that only the EC2 service can assume.

ec2-trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
create-and-attach-role.sh
# 1. Create the role with the EC2 trust policy
aws iam create-role \
  --role-name app-s3-uploader \
  --assume-role-policy-document file://ec2-trust-policy.json

# 2. Attach the tight permissions policy (defined below)
aws iam put-role-policy \
  --role-name app-s3-uploader \
  --policy-name s3-uploads-prefix-rw \
  --policy-document file://s3-uploads-policy.json

# 3. Wrap the role in an instance profile and add the role to it
aws iam create-instance-profile --instance-profile-name app-s3-uploader
aws iam add-role-to-instance-profile \
  --instance-profile-name app-s3-uploader \
  --role-name app-s3-uploader

# 4. Associate the instance profile with a running instance
aws ec2 associate-iam-instance-profile \
  --instance-id i-0abc123def4567890 \
  --iam-instance-profile Name=app-s3-uploader

The gotcha that costs people an afternoon: the instance profile and the role are two different objects, and the CLI makes you create both. The console hides this by creating the profile silently when you attach a role in the EC2 UI, so when you script it you forget step 3 and step 4 fails with a confusing NoSuchEntity. Once associated, the instance pulls credentials from IMDSv2 and you never write a key to disk. If you are still standing up your first instance, my AWS EC2 for beginners guide covers the launch and IMDSv2 setup before you get here.

verify-creds-on-instance (run on the EC2 box)
# IMDSv2 requires a token first - this is the secure default
TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

# The role name appears here only once a profile is associated
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

# These creds are temporary and rotated by AWS - never copy them out
aws sts get-caller-identity

How do I write a policy that grants exactly one thing?

Name the actions, name the resource ARNs, and use a condition where it tightens the grant for free. The policy below lets the role read and write objects under one prefix in one bucket, and nothing else. Note three deliberate choices: the actions are an explicit short list (not "s3:*"), the object Resource is scoped to the uploads/ prefix (not the whole bucket), and ListBucket is split into its own statement because it acts on the bucket ARN, not the object ARN, with a condition restricting the listing to that same prefix.

s3-uploads-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadWriteUploadsPrefixOnly",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::acme-app-prod/uploads/*"
    },
    {
      "Sid": "ListOnlyUploadsPrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::acme-app-prod",
      "Condition": {
        "StringLike": { "s3:prefix": "uploads/*" }
      }
    }
  ]
}

The split between the two statements trips up almost everyone the first time. Object actions like GetObject take the object ARN with the prefix and the trailing /*. ListBucket is a bucket-level action, so its Resource is the bucket ARN with no /* and no prefix in the ARN itself; you constrain what it can list with the s3:prefix condition instead. Put ListBucket against the object ARN and it silently never matches, so your app can read a known key but cannot list the folder, and you waste an hour on a phantom permissions bug. This bucket-versus-object distinction is the same one that separates bucket policies from IAM policies; I unpack when to reach for which in S3 bucket policies vs IAM policies.

A wall of network switch ports with one cable connected, illustrating a scoped IAM policy that opens exactly one path instead of all of them
A least-privilege policy is one cable in the patch panel, not the whole rack. Name the action, name the resource, stop there.

How do I find over-broad access I have already shipped?

Two AWS-native tools do this without guesswork. IAM Access Analyzer surfaces resources shared outside your account or organisation and flags external access you did not intend. Create an account-level analyzer once, then read its findings; each finding is a resource your policies expose beyond the trust boundary you expected.

access-analyzer.sh
# Create one account-scoped analyzer (do this once per region)
aws accessanalyzer create-analyzer \
  --analyzer-name account-external-access \
  --type ACCOUNT

# List active findings - external/public access you may not have intended
aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:123456789012:analyzer/account-external-access \
  --filter '{"status":{"eq":["ACTIVE"]}}'

# Lint a policy BEFORE you attach it - catches over-broad grants and errors
aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://s3-uploads-policy.json

The second tool is last-accessed data, which is how you prune a policy that is already too wide. IAM records which services (and optionally which actions) a principal has actually used, and over what window. Generate a report for a role or policy ARN, then pull the result with the returned job id. If a service shows it has never been accessed, the grant for it is dead weight you can remove.

last-accessed.sh
# Kick off the report for a role's ARN; returns a JobId
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/app-s3-uploader \
  --granularity ACTION_LEVEL

# Fetch the result using the JobId from the previous command
aws iam get-service-last-accessed-details \
  --job-id 78b6c2ba-d09e-4abc-9f8a-EXAMPLEjobid

Use these together on a cadence. Access Analyzer answers "is anything exposed wider than I think?" and last-accessed answers "is this grant even used?" A role that has read/write/delete but the report shows it never deleted anything is a role that should lose s3:DeleteObject. AWS's own guidance is to refine permissions continuously based on this data rather than treating a policy as write-once; the canonical reference is the AWS IAM security best practices page (docs.aws.amazon.com).

Every wildcard in an IAM policy is a decision to trust a future leaked credential with more than it should ever hold. You are not granting convenience, you are pre-authorising the breach.Md Raihan Hasan

What about the access keys I cannot get rid of yet?

Some keys are unavoidable for now: an on-prem box, a third-party SaaS that only takes an access key, a legacy CI runner. For those, the discipline is non-negotiable, and it is the same discipline every server-security checklist repeats.

  • Rotate on a schedule. Create a new key, deploy it, confirm traffic moves to it, then deactivate and delete the old one. Treat a key older than 90 days as a finding.
  • Never commit a key. Keep them out of the repo and out of Dockerfiles; use a secrets manager or instance role, and add a pre-commit secret scanner so a key cannot reach git history in the first place.
  • Scope the user behind the key as tightly as a role. A long-lived key on a wildcard policy is the worst of both worlds.
  • Audit with the same last-accessed report - a user whose key has not been used in 90 days is a key to delete, not rotate.
  • Turn on MFA for any human user and never attach programmatic keys to a console user that a person also logs in with.

If you run application servers, fold all of this into your standing hardening routine rather than treating it as a one-off; the Laravel security checklist I keep covers the credential-hygiene side from the application's point of view, and it pairs directly with the IAM side here.

Least privilege is not a setup step you do once and forget. It is a posture: start every role at zero, add only the action and the ARN you can justify, prefer a temporary role credential over a static key, and let Access Analyzer and last-accessed data keep pulling the policy tighter as the workload's real usage becomes clear. The wildcard policy always feels faster on the day you write it. It is the most expensive shortcut in AWS, and you only find out the price the day a credential leaks.