Skip to content
AWS aws iam 5 min read

The Least-Privilege Principle

Least privilege is the single most important rule in cloud security: every user, role, and application should have only the permissions it needs to do its job, and nothing more. In AWS this is enforced through IAM (Identity and Access Management, the service that controls who can do what). The idea sounds obvious, but in practice it is easy to get wrong, because the fast way to “make something work” is to hand out broad permissions and never tighten them. This page shows you how to start from zero and add only what is required, and how to use AWS tools to right-size your policies from real usage.

What least privilege actually means

By default in AWS, a brand-new IAM user or role can do nothing. There is an implicit “deny everything” baseline. You then add permissions through policies (JSON documents that list allowed actions). Least privilege means you keep that list as small as possible.

The opposite approach is to attach a broad managed policy like AdministratorAccess (which allows every action on every resource) and move on. That works instantly, but it means a leaked access key, a compromised laptop, or a buggy script can now delete your databases, spin up expensive resources, or read every secret in the account.

ApproachSetup effortBlast radius if compromisedWhen to use
Broad admin (* actions)LowestEntire accountAlmost never in production. Maybe a throwaway sandbox.
Service-scoped managed policyLowOne service (e.g. all of S3)Early development, low-risk roles
Least-privilege custom policyHigherJust the specific actions/resourcesProduction, anything touching real data or money

When NOT to over-think it: in a personal sandbox account with no real data and a tight billing alarm, broad permissions are an acceptable tradeoff for speed. The moment real data, customers, or cost are involved, switch to least privilege.

The core gotcha: wildcards that never get tightened

This is how almost every over-permissioned account is born. A developer writes a policy with "Action": "*" or "Action": "s3:*" and "Resource": "*" just to get past an “Access Denied” error. The feature ships, the deadline passes, and nobody ever comes back to narrow it. Months later the role can do far more than it ever needed.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "*",
      "Resource": "*"
    }
  ]
}

The fix is not to guess the right permissions by hand. It is to let AWS generate a least-privilege policy from what the role actually did. Two tools make this easy: CloudTrail (which records every API call in your account) and IAM Access Analyzer (which can read those records and write a tight policy for you).

Right-sizing a policy with IAM Access Analyzer

IAM Access Analyzer can look at a role’s recent CloudTrail history and generate a policy containing only the actions it actually used. You start the role with somewhat-broad access, let it run normally for a week or two, then generate and apply the tightened policy.

CloudTrail is on by default for management events, so the data is usually already there.

Console steps

  1. Open the IAM console and choose Roles in the left navigation.
  2. Click the role you want to tighten, then open the Generate policy based on CloudTrail events panel (under the Permissions tab, choose Generate policy).
  3. Pick the time range (for example the last 30 days) and the CloudTrail trail that holds the events.
  4. Choose or create a service-linked role that lets Access Analyzer read CloudTrail, then click Generate policy.
  5. Wait for it to finish (it can take a few minutes), review the generated JSON, and click Next.
  6. Attach the generated policy and remove the old broad policy from the role.

CLI steps

Start the generation job, pointing at the role’s ARN (Amazon Resource Name, the unique ID of the role) and a CloudTrail trail.

aws accessanalyzer start-policy-generation \
  --policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/app-worker-role"}' \
  --cloud-trail-details '{
    "accessRole":"arn:aws:iam::123456789012:role/AccessAnalyzerCloudTrailRole",
    "startTime":"2026-05-15T00:00:00Z",
    "endTime":"2026-06-14T00:00:00Z",
    "trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:123456789012:trail/management-trail","allRegions":true}]
  }'

Output:

{
    "jobId": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111"
}

Then fetch the generated policy once the job status is SUCCEEDED:

aws accessanalyzer get-generated-policy \
  --job-id a1b2c3d4-5678-90ab-cdef-EXAMPLE11111

Output:

{
    "jobDetails": { "status": "SUCCEEDED" },
    "generatedPolicyResult": {
        "generatedPolicies": [
            {
                "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":\"arn:aws:s3:::app-uploads/*\"}]}"
            }
        ]
    }
}

Notice how the generated policy lists only s3:GetObject and s3:PutObject on one bucket, instead of s3:* on everything. That is the whole point.

Reading CloudTrail directly

If you only need to check which actions a principal called (instead of generating a full policy), you can query CloudTrail’s event history yourself.

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=app-worker-role \
  --max-results 5 \
  --query 'Events[].{Time:EventTime,Action:EventName}'

Output:

[
    { "Time": "2026-06-14T09:12:03+00:00", "Action": "GetObject" },
    { "Time": "2026-06-14T09:12:01+00:00", "Action": "PutObject" },
    { "Time": "2026-06-13T18:44:55+00:00", "Action": "ListBucket" }
]

This tells you the role never called any write-DynamoDB or delete-bucket actions, so those do not belong in its policy.

Cost note: CloudTrail management events and a 90-day event history are free. IAM Access Analyzer’s external-access and unused-access findings are also free; only the more advanced “unused access” analyzer (continuous monitoring) is billed per resource analyzed per month. For most accounts, right-sizing policies this way costs nothing.

Best practices

  • Start every new role or user with no permissions and add only what a documented task requires.
  • Use Access Analyzer’s CloudTrail-based policy generation to replace guesswork with evidence.
  • Scope Resource to specific ARNs (a single bucket, table, or prefix) instead of * wherever possible.
  • Treat any policy containing "Action": "*" or "*:*" as a temporary exception with a ticket to fix it, never a permanent state.
  • Re-run policy generation periodically; permissions a workload no longer uses should be removed.
  • Layer on permission boundaries and SCPs (Service Control Policies) so even a mistaken broad policy cannot exceed an account-wide ceiling.
  • Test changes with the IAM policy simulator before removing access, so you do not break a running workload.
Last updated June 15, 2026
Was this helpful?