AWS IAM least privilege with Access Analyzer

Generate tight IAM policies from real CloudTrail activity and find resources shared outside your account.

Jan 8, 2026·Updated ·7 min readIntermediate·By SecOpsLog · documentation-verified

The reason "Action": "s3:" survives three years in a CI role is not laziness. Nobody knows which of the two hundred S3 actions the pipeline calls, guessing wrong breaks a deploy at the worst time, and there is no cheap way to find out. IAM Access Analyzer removes the finding out* part. It can read CloudTrail for a principal and draft a policy containing only the actions and resources that principal actually invoked, it can list permissions a role has not used in a set number of days, and it can find resource policies that grant access from outside the account.

None of those three outputs is a finished policy. Generated policies describe history rather than intent and contain no conditions; unused-access findings say what to remove but not what the role will need next quarter; external-access findings need a human to decide whether the vendor account should still be there. The value is that every review starts from evidence instead of from a wildcard.

From wildcard to evidence-based policy

The CloudTrail window is the sensitive parameter: a quiet week under-generates, a peak week over-fits, and the maximum is 90 days.

1CloudTrailtrail covers the role’s regions2Service roleAccess Analyzer reads the trail3start-policy-generationprincipal + trail + window4Generated policyactions and resources used5Reviewadd conditions, remove one-offs6Deployattach, watch AccessDenied7Unused-accessfindingsthe same loop, continuously

Two analyzers, two questions

An analyzer of type ACCOUNT (or ORGANIZATION, from the delegated security account) answers the outside-in question: which S3 buckets, roles, KMS keys, queues and so on can be reached from outside the zone of trust. An analyzer of type ACCOUNT_UNUSED_ACCESS answers the inside-out question: which roles, users and permissions have not been used for unusedAccessAge days, between 1 and 365. External-access analysis is regional, so it goes in every region with resources; unused-access analysis looks at IAM, which is global. One of each per account is the usual shape.

bash — create both analyzers
aws accessanalyzer create-analyzer --analyzer-name external --type ACCOUNT
arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/external
aws accessanalyzer create-analyzer --analyzer-name unused --type ACCOUNT_UNUSED_ACCESS \
--configuration '{"unusedAccess":{"unusedAccessAge":90}}'
arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/unused
aws accessanalyzer list-analyzers --query "analyzers[].[name,type,status]" --output text
external ACCOUNT ACTIVE
unused ACCOUNT_UNUSED_ACCESS ACTIVE

Generate a policy from what the role really called

Policy generation needs three inputs, and examples that show only the first do not run: the principal ARN, the CloudTrail trail to read (with the regions or allRegions), and a service role that Access Analyzer assumes to read that trail. The startTime opens the window and endTime defaults to now; the window cannot exceed 90 days. The job runs asynchronously, and get-generated-policy returns the draft together with the list of services the trail recorded for the principal.

trail.json
{
"accessRole": "arn:aws:iam::111122223333:role/service-role/AccessAnalyzerMonitorServiceRole",
"startTime": "2026-07-15T00:00:00Z",
"trails": [
{ "cloudTrailArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/org-trail", "allRegions": true }
]
}
bash — policy generation job
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::111122223333:role/ci-deploy"}' \
--cloud-trail-details file://trail.json
{ "jobId": "2b57a4e8-6a1d-4f0e-9c2a-8c1f3f0f7a11" }
aws accessanalyzer get-generated-policy --job-id 2b57a4e8-… \
--query "generatedPolicyResult.generatedPolicies[0].policy" --output text | jq ".Statement[].Action"
["s3:GetObject","s3:PutObject","s3:ListBucket","ecr:GetAuthorizationToken","ecr:BatchGetImage","sts:GetCallerIdentity"]
was s3:* and ecr:* on *; the trail shows six actions over 59 days

Read the draft with the window in mind. An action the role calls once a year (a certificate renewal, a quarterly export) will be missing if the window did not include it, and an action a colleague ran as the role during an incident will be present although it is not part of the job. The draft also arrives with Resource entries that are as specific as CloudTrail could make them, which for some services is *; those are the lines to narrow by hand.

What the generator will not add

Conditions. A generated statement says the role wrote objects to a bucket; it does not say the writes must be encrypted, must arrive over TLS, or must come from the VPC endpoint. Those are policy decisions, and they go in when the draft is reviewed. The same review is the moment to split read from write into separate statements: a condition on the encryption header only makes sense on PutObject, and a statement that applies it to GetObject as well denies every read, because the key is absent from read requests. Separate statements also let a later Deny target one without the other. Any role a human can assume gets a permissions boundary in the same pass.

tightened-statements.json
[
{
"Sid": "ReadArtifacts",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-app-artifacts/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"StringEquals": { "aws:SourceVpce": "vpce-0a1b2c3d4e5f67890" }
}
},
{
"Sid": "WriteArtifactsEncrypted",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::acme-app-artifacts/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"StringEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
}
}
]
Resource: "*" is the wildcard that survives review
Reviews concentrate on the Action list because that is what the generator changed. The Resource field it produced is often still "*", and an Allow on s3:GetObject for every bucket in the account is not least privilege. Scope every statement to ARNs before attaching it, and treat any remaining "*" as an exception that needs a written reason.

Findings: what is reachable from outside

External-access findings are the backup bucket a vendor was given read access to in 2023, the role whose trust policy names an account that no longer belongs to you, the KMS key with a grant to a principal nobody recognises. Each finding names the resource, the external principal and the actions. Resolving one means changing the resource policy (S3 bucket controls covers the bucket side); archiving one is only appropriate when the access is intended and documented, because an archived finding is the one nobody looks at again.

bash — triage external-access findings
aws accessanalyzer list-findings-v2 --analyzer-arn arn:aws:access-analyzer:eu-west-1:111122223333:analyzer/external \
--filter '{"status":{"eq":["ACTIVE"]}}' --query "findings[].[resourceType,resource]" --output text
AWS::S3::Bucket arn:aws:s3:::acme-backups
AWS::IAM::Role arn:aws:iam::111122223333:role/deploy
aws accessanalyzer get-finding-v2 --analyzer-arn … --id 6c1e…
principal: {"AWS":"999988887777"} action: ["s3:GetObject","s3:ListBucket"] isPublic: false
the bucket policy still names the old vendor account: remove the statement, the finding resolves on the next scan
Which analyzer output answers which review
Policy generation
Best for machine roles with steady behaviour
History, not intent: check the window
No conditions; Resource often *
Re-run after the pipeline changes
Unused-access + external-access findings
Continuous, no job to start
Removes permissions nobody used
Catches cross-account and public grants
Needed for human roles too

For CI roles the generation input is only as good as the identity: a pipeline that authenticates with a static IAM user key produces a trail under that user, not under the role you want to tighten. Moving the pipeline to OIDC federation first means the generated policy describes the federated role's real behaviour. What keeps a tightened policy tight afterwards is organisational: an SCP that stops anyone re-attaching AdministratorAccess, a permissions boundary on the roles that can edit policies, and the unused-access analyzer running against the result.

Go deeper in a courseAWS security for DevOps engineersIAM that scales, OIDC federation, Access Analyzer and organisation guardrails.View course

Related posts