Skip to content

InsightsSecurity

Least-privilege IAM in practice: start broad, then tighten

A workable process for AWS IAM least privilege using Access Analyzer policy generation, last-accessed data, permission boundaries and policy checks in CI.

Published October 22, 20245 min read

Everyone agrees with least privilege. Few teams achieve it, because writing a minimal IAM policy before a workload runs requires knowing every API call it will make, including the ones buried in SDK defaults and third-party libraries. Engineers try, the deploy fails with AccessDenied, someone adds s3:* to unblock it, and the broad policy stays forever.

A process that works in practice accepts this. Grant scoped but broad permissions to begin with, observe what the workload actually uses, then replace the policy with one generated from that evidence. AWS provides the tools for each step.

The loop

  1. Start with a bounded broad policy. Broad within one service and one set of resources, never account-wide.
  2. Run the workload through its real usage, including batch jobs and failure paths, for long enough to see them.
  3. Generate a policy from CloudTrail activity with IAM Access Analyzer.
  4. Review and tighten resource ARNs and conditions, which the generator cannot always infer.
  5. Keep checking with last-accessed data and unused access findings, so permissions do not accumulate again.

The important constraint in step 1 is "bounded". A starting policy of s3:* on arn:aws:s3:::orders-exports-* is a reasonable place to begin. s3:* on * is not.

Generating a policy from CloudTrail

IAM Access Analyzer can read up to 90 days of CloudTrail events for a role or user and produce a policy covering the actions it used. In the console, it is the "Generate policy" button on the role's page. From the CLI:

generate a policy for the orders-api role
aws accessanalyzer start-policy-generation \
  --policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/orders-api"}' \
  --cloud-trail-details '{
      "trails": [{"cloudTrailArn":"arn:aws:cloudtrail:eu-west-1:123456789012:trail/org-trail","allRegions":true}],
      "accessRole": "arn:aws:iam::123456789012:role/service-role/AccessAnalyzerMonitorServiceRole",
      "startTime": "2024-07-15T00:00:00Z",
      "endTime": "2024-10-15T00:00:00Z"
    }'
 
aws accessanalyzer get-generated-policy \
  --job-id 4f6a1c2e-1b7d-4a0e-9c3a-2d8b5e7f9a10 \
  --include-resource-placeholders

The output is a starting point, not a finished policy:

  • Actions are accurate for what was logged. Each action that appeared in the trail is listed.
  • Resources are often wildcards or placeholders. For many services the generator cannot tell which specific resources were used. Replace placeholders with real ARNs before applying.
  • Data events are only visible if they were logged. S3 object reads and writes, DynamoDB item operations and Lambda invocations are CloudTrail data events, which trails do not record by default. Without them, the generated policy will be missing those actions.
  • Rare paths are missing if they did not run during the window. Quarterly reports, disaster recovery procedures and error handling need to be exercised, or added by hand with a comment saying why.

Using last-accessed data

For principals that already have broad policies, IAM's last-accessed information shows which services, and for many services which individual actions, were used and when:

find unused services and actions
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/orders-api \
  --granularity ACTION_LEVEL
 
aws iam get-service-last-accessed-details \
  --job-id 9d1f7b3c-2a4e-4c8f-b6d5-1e0a3f7c8b92

Services with no access in the tracking period can usually be removed outright. This is a fast first pass across many roles, before doing the more detailed per-role policy generation.

The same data is available at the organization level through AWS Organizations, which helps when deciding which services a service control policy can deny for a whole account or organizational unit.

Access Analyzer's unused access findings, introduced in late 2023, automate this monitoring. They report roles that have not been used, unused access keys and passwords, and unused permissions on active roles, across an account or organization. It is a paid feature, but it replaces a lot of scripting.

Permission boundaries for delegation

Least privilege also applies to who can create IAM roles. Platform teams often want product teams to create roles for their own workloads, without letting those roles exceed what the team itself should be able to do. Permission boundaries solve this.

A boundary is a managed policy attached to a role that sets the maximum permissions it can have. The effective permissions are the intersection of the role's own policies and the boundary. Then the team's ability to create roles is conditioned on attaching that boundary:

allow role creation only with the boundary attached
{
  "Sid": "CreateRolesWithBoundary",
  "Effect": "Allow",
  "Action": ["iam:CreateRole", "iam:PutRolePermissionsBoundary", "iam:AttachRolePolicy", "iam:PutRolePolicy"],
  "Resource": "arn:aws:iam::123456789012:role/orders/*",
  "Condition": {
    "StringEquals": {
      "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/orders-workload-boundary"
    }
  }
}

Pair it with statements that deny deleting or changing the boundary policy itself, and deny iam:DeleteRolePermissionsBoundary. Otherwise a team could create a compliant role and then remove the boundary from it.

Boundaries do not grant anything on their own. They are a ceiling, so a role with a boundary still needs its own identity policy, ideally the generated one.

Different tools for different ceilings

ControlApplies toUse it for
Identity policyOne role or userWhat the workload actually needs
Permission boundaryRoles created by a delegated teamThe most a team's roles can ever do
Service control policyAccounts or OUs in an organizationGuardrails nobody should cross, such as disabling regions or leaving the organization
Resource policyA bucket, key or queueWho outside the owning account may access it

Least privilege comes mostly from the first row. The others limit the damage when the first row is wrong.

Keeping policies tight in CI

Once policies are tight, the risk is that every change makes them a little broader again. Access Analyzer can check policies before they are deployed:

  • aws accessanalyzer validate-policy reports errors, security warnings and suggestions, such as overly broad wildcards.
  • aws accessanalyzer check-no-new-access compares a proposed policy with the current one and fails if it grants anything new. Use it to require explicit review for permission increases.
  • aws accessanalyzer check-access-not-granted fails if a policy grants any action from a list you define, such as iam:PassRole or kms:Decrypt on sensitive keys.

Running these against the policies in a Terraform plan turns "someone added a wildcard" from something found in an audit into a failed pull request check.

Where to start

  1. Make sure CloudTrail is enabled across the organization, with data events for the buckets and tables that matter most.
  2. Pull last-accessed data for every role and remove services that have not been used in 90 days.
  3. Pick the most privileged workload role and run policy generation against it. Replace placeholders with real ARNs and apply.
  4. Introduce a permission boundary for each team that creates its own roles.
  5. Add validate-policy and check-no-new-access to the pipeline that deploys IAM changes.
  6. Turn on unused access findings, or schedule a monthly last-accessed review, so drift is caught.