Most teams turn on IAM Access Analyzer, glance at the external-access findings once, and forget it exists. That leaves the three genuinely valuable capabilities on the table: continuous detection of unused access (dormant roles, stale keys, granted-but-never-used permissions), CloudTrail-driven policy generation that bootstraps least privilege from real activity, and the policy-check APIs that let you fail a pull request the moment someone widens a trust policy or adds iam:PassRole to the wrong principal. This is the build I reach for when a platform team has to make least privilege a default rather than an audit finding.
A note before the commands: Access Analyzer ships three distinct analyzer resources plus a set of stateless check APIs. They share a service namespace but solve different problems, and conflating them is the most common reason these projects stall. Keep the taxonomy straight and the rest follows.
The three analyzer types, plus the check APIs
| Capability | Resource / API | What it answers | Cost |
|---|---|---|---|
| External access | ORGANIZATION / ACCOUNT analyzer |
“Which resources are reachable by a principal outside my zone of trust?” | Free |
| Unused access | ORGANIZATION_UNUSED_ACCESS / ACCOUNT_UNUSED_ACCESS analyzer |
“Which roles, keys, and permissions are granted but never used?” | Per analyzed role/user, monthly |
| Internal access | ORGANIZATION_INTERNAL_ACCESS / ACCOUNT_INTERNAL_ACCESS analyzer |
“Which principals inside my org can reach critical resources?” | Paid tier |
| Policy generation | start-policy-generation |
“What policy would grant exactly what this role actually did?” | Free |
| Policy validation | validate-policy |
“Does this JSON have errors or security warnings?” | Free, stateless |
| Custom checks | check-no-new-access, check-access-not-granted, check-no-public-access |
“Does this change grant new or forbidden access?” | Per check, stateless |
The external/unused/internal analyzers are stateful resources that continuously evaluate your account or org and emit findings. The validate-policy and check-* APIs are stateless functions you call against a policy document in a pipeline — no analyzer required, no findings stored. Almost everything below leans on those two halves working together.
The valid --type values, straight from the service model, are exactly: ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS. There is no combined analyzer; external and unused are separate resources and you pay for unused per analyzed entity, so deploy it deliberately.
1. Deploy an organization analyzer with delegated administration
Run Access Analyzer from a delegated administrator account (your security/audit account), not the management account. That account then sees external-access findings for every member account in one place. First, register the delegated admin from the management account:
# From the Organizations management account, once.
aws organizations register-delegated-administrator \
--service-principal access-analyzer.amazonaws.com \
--account-id 222222222222 # your security/audit account
Now, in the delegated admin account, create the organization-scoped external-access analyzer. An ORGANIZATION analyzer automatically covers existing and future member accounts — you do not enumerate them.
aws accessanalyzer create-analyzer \
--analyzer-name org-external-access \
--type ORGANIZATION \
--tags Team=security,ManagedBy=terraform
External-access findings are free, so leave that analyzer on permanently. The unused-access analyzer is billed per analyzed IAM role and user per month, so its configuration matters. Set the tracking window (unusedAccessAge, in days) and use exclusions to skip break-glass roles and service-linked roles you never want flagged:
aws accessanalyzer create-analyzer \
--analyzer-name org-unused-access \
--type ORGANIZATION_UNUSED_ACCESS \
--configuration '{
"unusedAccess": {
"unusedAccessAge": 90,
"analysisRule": {
"exclusions": [
{ "accountIds": ["333333333333"] },
{ "resourceTags": [ { "break-glass": "true" } ] }
]
}
}
}'
unusedAccessAge: 90 means an entity must be idle for 90 days before it is reported, which kills the noise from quarterly jobs and on-call roles. The exclusions block is your cost and signal lever: exclude a whole sandbox account by accountIds, or exclude tagged roles by resourceTags. Codify all of this in Terraform so the org analyzer is reproducible:
resource "aws_accessanalyzer_analyzer" "org_external" {
analyzer_name = "org-external-access"
type = "ORGANIZATION"
}
resource "aws_accessanalyzer_analyzer" "org_unused" {
analyzer_name = "org-unused-access"
type = "ORGANIZATION_UNUSED_ACCESS"
configuration {
unused_access {
unused_access_age = 90
analysis_rule {
exclusion {
account_ids = ["333333333333"]
}
exclusion {
resource_tags = [{ "break-glass" = "true" }]
}
}
}
}
}
2. Triage external-access findings with archive rules
List the live, unresolved external-access findings. Use list-findings-v2, which is the current API and the one that understands all six finding types (ExternalAccess, UnusedIAMRole, UnusedIAMUserAccessKey, UnusedIAMUserPassword, UnusedPermission, InternalAccess):
ANALYZER_ARN=$(aws accessanalyzer list-analyzers \
--query "analyzers[?name=='org-external-access'].arn" --output text)
aws accessanalyzer list-findings-v2 \
--analyzer-arn "$ANALYZER_ARN" \
--filter '{"status":{"eq":["ACTIVE"]},"findingType":{"eq":["ExternalAccess"]}}' \
--query 'findings[].{Resource:resource,Type:resourceType,Principal:principal,Action:action}' \
--output table
A finding is not automatically a problem. A bucket policy that grants a partner account access is expected external access; Access Analyzer flags it because it crosses your zone of trust, and your job is to mark it intended. Do that with archive rules so the finding never pages anyone again. An archive rule auto-archives matching findings — current and future — that you have decided are acceptable:
# Auto-archive any external access granted to our known partner account.
aws accessanalyzer create-archive-rule \
--analyzer-name org-external-access \
--rule-name trusted-partner-444 \
--filter '{"principal.AWS":{"contains":["444444444444"]}}'
For a one-off finding you have reviewed, archive it directly instead of writing a rule:
aws accessanalyzer update-findings \
--analyzer-name org-external-access \
--status ARCHIVED \
--ids "ab-1234abcd-..."
Route everything else to your SOC. Access Analyzer emits findings to EventBridge and, if enabled, Security Hub. An EventBridge rule that fires only on new active findings keeps the noise down:
{
"source": ["aws.access-analyzer"],
"detail-type": ["Access Analyzer Finding"],
"detail": {
"status": ["ACTIVE"]
}
}
Wire that rule to SNS or a Lambda that opens a ticket. The pattern that works: archive rules absorb the known-good, EventBridge escalates the genuinely new, and nobody triages the same partner-bucket finding twice.
3. Surface dormant roles and unused permissions at scale
The unused-access analyzer is where the real cleanup lives. Pull its findings the same way, filtered to the unused types:
UNUSED_ARN=$(aws accessanalyzer list-analyzers \
--query "analyzers[?name=='org-unused-access'].arn" --output text)
aws accessanalyzer list-findings-v2 \
--analyzer-arn "$UNUSED_ARN" \
--filter '{"status":{"eq":["ACTIVE"]},
"findingType":{"eq":["UnusedIAMRole","UnusedPermission","UnusedIAMUserAccessKey"]}}' \
--max-results 50
Three buckets come back and each has a different remediation:
UnusedIAMRole— a role nobody has assumed in your window. Candidate for deletion. Confirm with the role’slast_usedbefore you pull the trigger.UnusedIAMUserAccessKey/UnusedIAMUserPassword— stale long-lived credentials. These should not exist in a modern account; deactivate then delete.UnusedPermission— a role that is used, but with actions or services it has never touched. This is your right-sizing signal and the most common type at scale.
For an UnusedPermission finding, get-finding-v2 returns the specific unused services and actions so you can trim the policy precisely:
aws accessanalyzer get-finding-v2 \
--analyzer-arn "$UNUSED_ARN" \
--id "ab-5678efgh-..." \
--query 'findingDetails'
At fleet scale you do not click through these. Export findings on a schedule, join them against your IaC, and open one PR per owning team that strips the unused actions. The measurable outcome is the count of UnusedPermission findings trending toward zero release over release — that is your privilege-reduction KPI, and it is a number you can put on a slide.
4. Generate a least-privilege policy from CloudTrail activity
When you are building a policy rather than trimming one, do not guess the actions. Have Access Analyzer read the role’s CloudTrail history and write the policy for you. You need a CloudTrail trail (the service reads from it) and a service role it can assume to do so.
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::111111111111:role/data-pipeline"}' \
--cloud-trail-details '{
"trails": [
{ "cloudTrailArn":"arn:aws:cloudtrail:us-east-1:111111111111:trail/org-trail",
"regions":["us-east-1","eu-west-1"] }
],
"accessRole":"arn:aws:iam::111111111111:role/AccessAnalyzerCloudTrailReader",
"startTime":"2026-03-01T00:00:00Z",
"endTime":"2026-06-01T00:00:00Z"
}'
The call returns a jobId. Generation is asynchronous; poll it, then fetch the result once it succeeds:
JOB=ab-1111-2222-... # jobId from the start call
aws accessanalyzer get-generated-policy --job-id "$JOB" \
--query 'jobDetails.status' # IN_PROGRESS | SUCCEEDED | FAILED | CANCELED
aws accessanalyzer get-generated-policy --job-id "$JOB" \
--query 'generatedPolicyResult.generatedPolicies[0].policy' \
--output text > data-pipeline-generated.json
Treat the output as a starting point, not a finished artifact. Access Analyzer can only see actions that appear in CloudTrail, so a three-month window misses anything that runs less often — and it omits actions CloudTrail does not log at all. Use the generated policy to replace a wildcard, then watch for new UnusedPermission findings (which tell you it is still too broad) and for access-denied errors in the workload (which tell you it is too narrow). The generated document plus the unused-access feedback loop converges far faster than authoring by hand.
5. Validate policies in CI/CD and fail on security warnings
validate-policy is stateless — no analyzer, no findings store — so it belongs directly in your pipeline. It returns four findingType values: ERROR, SECURITY_WARNING, SUGGESTION, and WARNING. The gate you want is: fail the build on any ERROR or SECURITY_WARNING, surface the rest as annotations.
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document file://policy.json \
--query "findings[?findingType=='ERROR' || findingType=='SECURITY_WARNING']" \
--output json
SECURITY_WARNING is the one that earns its keep: it flags things like a policy that allows a principal to escalate its own privileges (PassRole paired with a service that can assume any role), or a resource policy that is effectively public. Set --policy-type correctly — IDENTITY_POLICY, RESOURCE_POLICY, SERVICE_CONTROL_POLICY, or RESOURCE_CONTROL_POLICY — because the rule set differs per type. A GitHub Actions step that blocks the merge:
- name: Validate IAM policies
run: |
fail=0
for f in $(git diff --name-only origin/main... | grep 'policies/.*\.json$'); do
echo "::group::validate $f"
findings=$(aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY \
--policy-document "file://$f" \
--query "findings[?findingType=='ERROR' || findingType=='SECURITY_WARNING']" \
--output json)
echo "$findings"
[ "$(echo "$findings" | jq 'length')" -gt 0 ] && fail=1
echo "::endgroup::"
done
exit $fail
This catches the malformed and the dangerous before either reaches an account. It does not, on its own, stop a valid policy that simply grants too much — that is the next section.
6. Custom policy checks as automated guardrails
Two stateless APIs let you encode organizational intent as a build gate. Both return a plain PASS or FAIL.
check-no-new-access compares a proposed policy against the current one and fails if the change grants any access that did not exist before. You express your guardrail as a reference policy describing the access you are watching for. This is the check for “trust policies must not widen” and “nobody adds a new wildcard.” You supply the existing document, the new document, and a reference of access deltas you refuse to allow:
aws accessanalyzer check-no-new-access \
--policy-type IDENTITY_POLICY \
--existing-policy-document file://existing.json \
--new-policy-document file://proposed.json \
--query '{Result:result, Reasons:reasons}'
# Result: FAIL -> the proposed policy grants access the existing one did not
check-access-not-granted is an absolute assertion, independent of any “before” state: it fails if the policy grants a specific action or resource you have declared off-limits. This is how you enforce “no policy in this repo may ever grant iam:CreateAccessKey” or “nothing may delete the audit bucket.” You pass the access list inline:
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file://proposed.json \
--access '[{"actions":["iam:CreateAccessKey","iam:DeleteRolePermissionsBoundary","organizations:LeaveOrganization"]}]' \
--query '{Result:result, Message:message}'
# Result: PASS -> none of the forbidden actions are granted
You can scope --access to actions, resources, or both: actions-only checks whether the policy permits any of those actions on any resource; adding resources narrows it to those ARNs. Maintain one shared list of forbidden actions (the IAM privilege-escalation set, plus your own crown-jewel resources) and run check-access-not-granted against every policy in every pipeline. check-no-new-access is the relative guardrail; check-access-not-granted is the absolute one. Together they are a far stronger gate than validate-policy alone, because they reason about what the policy grants, not just whether it is well-formed.
There is also check-no-public-access, which evaluates a resource policy for an S3 bucket, SQS queue, KMS key, and similar resource types and fails if it would be public — drop it into the same pipeline for any resource-policy change.
7. Embed the checks in Terraform/CloudFormation pipelines
The leverage is running these against the policy your IaC is about to apply, before apply. With Terraform, render the plan to JSON, extract the policy documents, and check each one. A pre-apply gate:
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
# Pull every IAM policy document the plan will create or update.
jq -r '
.resource_changes[]
| select(.type=="aws_iam_policy" or .type=="aws_iam_role_policy")
| select(.change.actions | index("create") or index("update"))
| .change.after.policy
' plan.json > /tmp/policies.ndjson
while IFS= read -r doc; do
echo "$doc" > /tmp/p.json
res=$(aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file:///tmp/p.json \
--access "$(cat forbidden-actions.json)" \
--query 'result' --output text)
echo "check-access-not-granted => $res"
[ "$res" = "FAIL" ] && exit 1
done < /tmp/policies.ndjson
For CloudFormation, run the same checks against the rendered template’s policy resources in the change-set stage, before execute-change-set. The principle holds across both: the check runs on the exact document about to be deployed, and a FAIL stops the deploy. Keep forbidden-actions.json in a shared repo so every pipeline enforces one definition of “never.”
Finally, close the loop on measurement. Snapshot the unused-access findings count on a schedule and chart it. get-findings-statistics gives you the aggregates without paging through individual findings:
aws accessanalyzer get-findings-statistics --analyzer-arn "$UNUSED_ARN"
A downward trend in UnusedPermission and UnusedIAMRole counts is the proof that the policy-generation and right-sizing loop is working — and it is the metric to report up, not the raw finding list.
Enterprise scenario
A fintech platform team I worked with had a 60-account organization and a recurring audit finding: dozens of CI/CD deployment roles carried Action: "*" or broad service wildcards “to unblock pipelines,” and the count only grew. Hand-trimming was hopeless — every trim risked breaking a deploy, so nobody trimmed.
The constraint was hard: they could not break a single production pipeline, and they had a SOC 2 deadline. The fix combined the generation and check halves of Access Analyzer. They ran start-policy-generation against each deployment role over a 90-day CloudTrail window to get a real action list, replaced the wildcards with the generated documents in a staging account first, and let the ORGANIZATION_UNUSED_ACCESS analyzer confirm the trim by watching UnusedPermission findings drop. Then they added one non-negotiable gate to every IaC pipeline so the wildcards could never come back:
# Pipeline guardrail: deployment-role policies may never grant a bare service wildcard
# on IAM or Organizations, and may never grant privilege-escalation actions.
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY \
--policy-document file://rendered-role-policy.json \
--access '[
{"actions":["iam:*"]},
{"actions":["organizations:*"]},
{"actions":["iam:CreateAccessKey","iam:PassRole","iam:PutRolePolicy",
"iam:AttachRolePolicy","iam:UpdateAssumeRolePolicy"]}
]' \
--query 'result' --output text # FAIL blocks the merge
Over a quarter the UnusedPermission finding count fell by roughly 80%, the audit finding closed, and — the part the team cared about — not one pipeline broke, because every trim was backed by observed activity and every regression was blocked at the PR. The generation API gave them a safe starting policy; the check API made the improvement permanent.
Verify
Prove each layer is actually working, not just configured:
# 1. Both org analyzers exist and are ACTIVE in the delegated admin account.
aws accessanalyzer list-analyzers \
--query "analyzers[].{Name:name,Type:type,Status:status}" --output table
# Expect: org-external-access (ORGANIZATION) and org-unused-access
# (ORGANIZATION_UNUSED_ACCESS), both ACTIVE.
# 2. The custom-check gate FAILS on a forbidden action (negative test).
echo '{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"iam:CreateAccessKey","Resource":"*"}]}' > /tmp/bad.json
aws accessanalyzer check-access-not-granted \
--policy-type IDENTITY_POLICY --policy-document file:///tmp/bad.json \
--access '[{"actions":["iam:CreateAccessKey"]}]' \
--query 'result' --output text
# Expect: FAIL (if this returns PASS, your gate is not wired correctly)
# 3. validate-policy flags a known security warning.
echo '{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"*","Resource":"*"}]}' > /tmp/star.json
aws accessanalyzer validate-policy \
--policy-type IDENTITY_POLICY --policy-document file:///tmp/star.json \
--query "findings[].findingType" --output text
# Expect: at least one SECURITY_WARNING or WARNING.
# 4. No unaddressed external-access findings remain.
aws accessanalyzer list-findings-v2 --analyzer-arn "$ANALYZER_ARN" \
--filter '{"status":{"eq":["ACTIVE"]},"findingType":{"eq":["ExternalAccess"]}}' \
--query 'length(findings)'
# Expect: 0 (everything reviewed is either remediated or archived).
If the negative test in step 2 returns PASS, stop and fix the gate before trusting it — a guardrail that never fails is not a guardrail.