AWS Lesson 57 of 123

IAM Access Analyzer in Depth: Unused Access, Policy Generation, and Custom Policy Checks

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 — and it is the difference between a SOC 2 control you can demonstrate and a spreadsheet of IAM debt nobody dares touch.

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 single most common reason these projects stall. The stateful analyzers watch your org and emit findings; the stateless checks gate a policy document in a pipeline and store nothing. Keep that taxonomy straight and the rest follows; blur it and you will pay per-entity charges for an analyzer you meant to run as a free pipeline check, or wait for findings that a stateless API would have returned synchronously.

By the end you will be able to stand up an org-wide analyzer from a delegated administrator account, triage external access without paging the same partner-bucket finding twice, surface dormant roles and unused permissions at fleet scale, generate a tight policy from a role’s own CloudTrail history, and wire validate-policy plus the check-* APIs into Terraform and CloudFormation so a dangerous policy never reaches an account. Because this is a reference you will return to mid-incident and mid-review, the analyzer types, the finding types, the API limits, the error reasons and the remediation paths are all laid out as scannable tables — read the prose once, then keep the tables open.

What problem this solves

In a real organization, IAM permissions only ever ratchet up. A pipeline breaks, someone adds Action: "*" “to unblock the deploy,” the ticket closes, and the wildcard is now load-bearing. Multiply that across sixty accounts and a few years and you have hundreds of roles carrying access nobody can justify and nobody dares remove — because removing the wrong action breaks production, and there is no evidence of which actions are safe to remove. That is the pain: not that least privilege is unknown as a principle, but that retrofitting it onto a live estate is terrifying without data.

What breaks without this: audit findings that never close (the auditor flags broad roles; you cannot prove what is unused, so you cannot trim them); a blast radius nobody has measured (a leaked key for an over-permissioned role can reach far more than its job requires); and a review process that rubber-stamps IAM PRs because a human eyeball cannot tell whether a 200-line policy grants one new dangerous action. The wildcard that lets a deployment role call iam:* is invisible in code review and catastrophic in an incident.

Who hits this: every platform, security and SRE team running a multi-account AWS org; teams under SOC 2 / PCI / FedRAMP that must demonstrate least privilege, not merely assert it; and anyone who has inherited an estate where roles accreted permissions faster than anyone removed them. The fix is almost never “write tighter policies by hand” — it is “let the service read what each role actually did, generate the tight policy, and gate every future change so the wildcards can never come back.”

To frame the whole field before the deep dive, here is every capability this article covers, the question it answers, whether it is stateful or stateless, and what it costs:

Capability Resource / API What it answers Stateful? Cost
External access ORGANIZATION / ACCOUNT analyzer “Which resources are reachable by a principal outside my zone of trust?” Stateful Free
Unused access ORGANIZATION_UNUSED_ACCESS / ACCOUNT_UNUSED_ACCESS analyzer “Which roles, keys and permissions are granted but never used?” Stateful Per analyzed role/user, monthly
Internal access ORGANIZATION_INTERNAL_ACCESS / ACCOUNT_INTERNAL_ACCESS analyzer “Which principals inside my org can reach critical resources?” Stateful Paid tier
Policy generation start-policy-generation “What policy grants exactly what this role actually did?” Stateless job Free
Policy validation validate-policy “Does this JSON have errors or security warnings?” Stateless Free
Custom checks check-no-new-access, check-access-not-granted, check-no-public-access “Does this change grant new or forbidden access?” Stateless Per check

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand IAM policy evaluation: that a request is allowed only if an explicit Allow matches and no explicit Deny does, that identity policies, resource policies, permission boundaries and SCPs all participate, and that a trust policy is what makes a role assumable. If that chain is fuzzy, read IAM Fundamentals: Users, Roles, Policies & Evaluation first; the boundary/SCP ceiling mechanics live in IAM Least Privilege with Permission Boundaries. You should be comfortable running aws CLI v2, reading JSON output, and have an AWS Organization with a delegated administrator pattern — the management account should delegate security tooling to an audit account, as set up in AWS Organizations, SCPs & Delegated Admin.

This sits in the Identity & Governance track. It assumes the org foundation from AWS Control Tower Multi-Account Landing Zone and pairs tightly with CloudWatch & CloudTrail Observability, because policy generation reads from a CloudTrail trail and finding routing flows through EventBridge. Do not confuse this service with the similarly named VPC Network Access Analyzer / Reachability Analyzer covered in Network Reachability & Access Analyzer — that one reasons about network paths, this one reasons about IAM access.

A quick map of who owns what during a least-privilege program, so you escalate to the right person:

Layer What lives here Who usually owns it What it controls in this build
Management account Org root, delegated-admin registration Cloud platform / org admins Where you register-delegated-administrator
Delegated admin (audit) account The analyzers, archive rules, findings Security / GRC team All stateful findings land here
Member accounts The roles, keys, resource policies being analyzed App / workload teams The entities that generate findings
CI/CD pipelines Terraform/CFN, the check-* calls Platform / DevOps Where stateless gates run pre-apply
Trail + EventBridge CloudTrail history, finding events Observability / SecOps Generation source + finding routing
SOC / ticketing Triaged, escalated findings Security operations Consumes only genuinely-new findings

Core concepts

Five mental models make every later command obvious.

Stateful analyzers watch; stateless checks gate. An analyzer is a long-lived resource that continuously evaluates your account or org and stores findings. The validate-policy and check-* APIs are stateless functions: you call them against a JSON document, they return an answer, nothing is persisted. Almost everything below is these two halves working together — the analyzers tell you where you are (broad, unused access today), and the checks stop you from getting worse (a PR that widens access). Reach for an analyzer when you want continuous detection; reach for a check when you want a build gate.

External, unused and internal are three separate resources. There is no combined analyzer. External access answers “who outside my zone of trust can reach this?” and is free, so leave it on forever. Unused access answers “what is granted but idle?” and is billed per analyzed IAM role and user per month, so you deploy it deliberately and prune what it watches. Internal access answers “who inside my org can reach this critical resource?” and is a paid tier you enable for crown-jewel monitoring. Mixing them up is how you either pay for analysis you did not intend or wait for findings the wrong analyzer will never produce.

The valid --type values are a closed set. Straight from the service model, the only legal analyzer types are: ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS. An ORGANIZATION-scoped analyzer automatically covers existing and future member accounts — you never enumerate them. Account-scoped analyzers see only their own account. There is no --type for “unused + external”; deploy one of each.

A finding is a fact, not a verdict. Access Analyzer flags access that crosses your zone of trust or sits unused — it does not know your intent. A bucket policy granting a partner account access is expected external access; the service flags it because it is external, and your job is to mark it intended (an archive rule). The discipline is: archive rules absorb the known-good, EventBridge escalates the genuinely new, and nobody triages the same finding twice.

Generation is a floor, checks are a ceiling. start-policy-generation reads a role’s CloudTrail history and writes the smallest policy that would have permitted what the role did — a floor you build up from reality instead of guessing. The check-* APIs encode what a policy must never grant — a ceiling you enforce in CI. The generated floor plus the unused-access feedback loop converges far faster than authoring by hand; the ceiling makes the improvement permanent. Neither alone is enough: generation can undershoot (it only sees the trail window), and a valid policy can still be too broad.

The vocabulary in one table

Pin down every moving part before the deep sections; the glossary repeats these for lookup, this table is the model side by side:

Concept One-line definition Where it lives Why it matters
Analyzer Stateful resource that emits findings Delegated admin account The thing you pay for / leave on
Zone of trust Your account or org boundary Implicit per analyzer scope External = crosses it
Finding One detected access fact Stored on the analyzer Triaged, archived, or escalated
Archive rule Auto-archives matching findings On the analyzer Absorbs known-good; cuts noise
unusedAccessAge Idle days before an entity is flagged Unused-analyzer config Noise & relevance lever
exclusions Accounts/tags the unused analyzer skips Unused-analyzer analysisRule Cost & signal lever
validate-policy Stateless lint of a policy doc Called in CI Catches malformed + dangerous
check-no-new-access Fails if a policy grants new access Called in CI Relative guardrail
check-access-not-granted Fails if a policy grants forbidden access Called in CI Absolute guardrail
check-no-public-access Fails if a resource policy is public Called in CI Resource-policy guardrail
start-policy-generation Async job: CloudTrail → policy Triggered ad hoc Bootstraps least privilege
Delegated administrator The account that runs org analyzers Audit/security account Not the management account

The analyzer types and the check APIs, end to end

Before any setup, internalize the full surface area. The external/unused/internal analyzers are stateful resources that continuously evaluate and emit findings; the validate-policy and check-* APIs are stateless functions you call against a document in a pipeline — no analyzer required, no findings stored. Here is every analyzer type with its scope, what it produces, and the cost model:

--type value Scope Finding types it produces Cost model Leave on permanently?
ACCOUNT Single account ExternalAccess Free Yes
ORGANIZATION Whole org (auto future accts) ExternalAccess Free Yes (one per org)
ACCOUNT_UNUSED_ACCESS Single account UnusedIAMRole, UnusedIAMUserAccessKey, UnusedIAMUserPassword, UnusedPermission Per analyzed role/user / month Deliberately, with exclusions
ORGANIZATION_UNUSED_ACCESS Whole org Same four unused types Per analyzed role/user / month Deliberately, with exclusions
ACCOUNT_INTERNAL_ACCESS Single account InternalAccess Paid tier For crown-jewel resources
ORGANIZATION_INTERNAL_ACCESS Whole org InternalAccess Paid tier For crown-jewel resources

And here is the stateless half — the validate/check APIs, what each takes, what it returns, and the one job it owns:

API Input Returns The one job it owns Needs an analyzer?
validate-policy One policy doc + --policy-type List of findings (ERROR, SECURITY_WARNING, WARNING, SUGGESTION) Lint: malformed + dangerous patterns No
check-no-new-access Existing doc + new doc + reference PASS / FAIL + reasons Block any new access vs the old policy No
check-access-not-granted One doc + --access list PASS / FAIL + message Block specific forbidden actions/resources No
check-no-public-access One resource policy + resource type PASS / FAIL Block a resource policy that is public No
start-policy-generation Principal ARN + trail details jobId (async) Build a policy from real CloudTrail activity No (reads a trail)

When you have a policy document in hand, this is the lookup for which tool applies to it — match the document you’re reviewing to the analysis it deserves:

You have a… validate-policy type Relevant check(s) Relevant analyzer
Identity policy (role/user/group) IDENTITY_POLICY check-no-new-access, check-access-not-granted Unused-access (for the role)
Trust policy (AssumeRolePolicyDocument) RESOURCE_POLICY check-no-new-access (widening) External-access
S3 bucket / KMS key / SQS policy RESOURCE_POLICY check-no-public-access External-access, internal-access
Service Control Policy (SCP) SERVICE_CONTROL_POLICY — (validate syntax)
Resource Control Policy (RCP) RESOURCE_CONTROL_POLICY — (validate syntax)
Secrets Manager secret policy RESOURCE_POLICY check-no-public-access External-access

The six finding types you will filter on, mapped to the analyzer that produces each and the remediation it implies:

Finding type Produced by Means Default remediation
ExternalAccess External analyzer A principal outside your zone of trust can reach a resource Archive if intended; else scope the resource policy
UnusedIAMRole Unused analyzer A role unassumed within unusedAccessAge Confirm last_used, then delete
UnusedIAMUserAccessKey Unused analyzer A long-lived access key idle past the window Deactivate, then delete
UnusedIAMUserPassword Unused analyzer A console password idle past the window Disable console access
UnusedPermission Unused analyzer A used role with actions/services it never touched Trim the policy to the used set
InternalAccess Internal analyzer An in-org principal can reach a monitored critical resource Verify intent; tighten if unexpected

The service-level limits worth knowing before you design the rollout — these are the boundaries that shape how many analyzers you run and how you scope them:

Limit / quota Value Why it matters
Analyzers of one type per account/region 1 You can’t run two ORGANIZATION external analyzers — one covers the org
unusedAccessAge range 1–365 days Below ~30 floods you with on-call/quarterly noise
Org analyzer member coverage All current + future accounts No enumeration; new accounts auto-join
Generation source 1 CloudTrail trail per job The trail must already cover your window
Findings retention Persisted on the analyzer until resolved/archived Archive (don’t delete) to keep the audit trail
Resource types for check-no-public-access S3, S3 AP, SQS, KMS, Secrets, SNS, and more Identity policies are out of scope for this check
Billing unit (unused) Per analyzed role and user, per month Exclusions reduce both cost and noise

A recurring theme in the findings — especially external access — is the condition key that turns a “public” grant into an org-scoped one. The keys you’ll add to resource policies to clear false external findings and close the real exposure:

Condition key Scopes access to… Clears which finding Typical placement
aws:PrincipalOrgID Any principal in your org External access from your own org accounts S3/KMS/SQS resource policies
aws:PrincipalOrgPaths Principals under a specific OU path Over-broad in-org access Resource policies for OU-scoped sharing
aws:SourceAccount A specific calling account Confused-deputy cross-service access Service resource policies
aws:SourceArn A specific source resource Over-broad service-to-service grants SNS/SQS/KMS policies
aws:PrincipalTag/<k> Principals carrying a tag (ABAC) Tag-mismatched access Identity + resource policies

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, and you keep the management account clean of day-to-day tooling. First, register the delegated admin from the management account — once:

# 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 is where cost and signal live. Set the tracking window (unusedAccessAge, in days) and use exclusions to skip break-glass roles and sandbox accounts 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. Every knob on these two analyzers, with its default, range and the trade-off you are making:

Setting Applies to Default Valid range / values When to change Trade-off / gotcha
--type Both none (required) The six legal types Per analyzer purpose Wrong type = wrong findings or surprise billing
--analyzer-name Both none (required) 1–255 chars, unique per account Always Name is the handle for archive rules
unusedAccessAge Unused 90 1–365 days Lower for tight estates; higher to cut noise Too low floods you with on-call/quarterly roles
exclusions.accountIds Unused none Member account IDs Skip sandboxes / dev accounts Excluded accounts are not billed either
exclusions.resourceTags Unused none Tag key/value pairs Skip break-glass, service-linked roles Tags must actually be set on the roles
--tags Both none Up to 50 tags Always (ownership, IaC) Drives your own cost allocation
Region Both per-call Any enabled region Per-region IAM-resource coverage Findings are regional for non-global resources

Codify all of this in Terraform so the org analyzer is reproducible and reviewable:

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" }]
        }
      }
    }
  }
}

Account-scoped versus organization-scoped is a deliberate choice, not a default — here is how they differ on every axis that matters:

Axis ACCOUNT / ACCOUNT_UNUSED_ACCESS ORGANIZATION / ORGANIZATION_UNUSED_ACCESS
Coverage Only the account it lives in Every member account, current and future
Where you create it Any account The delegated admin (or management) account
New accounts Not covered automatically Covered automatically on join
Findings location That account Centralized in the delegated admin account
Best for A single sensitive account, or pre-org pilots The standard org-wide deployment
Quota 1 analyzer per type per account/region 1 org analyzer per type per org

2. Triage external-access findings with archive rules

List the live, unresolved external-access findings with list-findings-v2 — the current API, the one that understands all six finding types:

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

The --filter object is the same vocabulary for list-findings-v2, archive rules, and EventBridge patterns — learn the keys once and reuse them everywhere. The ones you reach for most:

Filter key Matches Operators Example use
status Finding state eq {"status":{"eq":["ACTIVE"]}} — hide archived
findingType One of the six types eq Isolate UnusedPermission only
resourceType e.g. AWS::S3::Bucket eq Triage by resource class
principal.AWS The external principal eq, contains Whitelist a partner account
resource The resource ARN eq, contains Pin one shared bucket/key
condition Conditions on the grant exists Accept org-scoped grants
error Findings the analyzer couldn’t resolve exists Surface, never auto-archive
createdAt / updatedAt Finding timestamps eq, contains Window a triage sweep

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 an archive rule 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-..."

A finding moves through three states; knowing what drives each transition keeps your triage honest:

Status Meaning How it gets there What you do
ACTIVE Live, unresolved access New finding, or access still present Triage: archive if intended, else fix
ARCHIVED Reviewed, accepted update-findings or an archive rule match Nothing — it won’t page you again
RESOLVED The underlying access is gone You removed the grant; analyzer re-scans Confirm and move on

Archive rules are filters; the fields you can match on (and the operators) decide how surgical your rule is:

Filter key Matches on Example operator Use it to…
principal.AWS The external principal ARN/account contains Whitelist a known partner account
resource The resource ARN eq / contains Accept a specific shared bucket/key
resourceType e.g. AWS::S3::Bucket eq Accept a whole resource class
condition Conditions on the grant exists Accept access gated by aws:PrincipalOrgID
error Findings the analyzer couldn’t fully evaluate exists Surface (not archive) evaluation errors

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 two delivery channels are not interchangeable — pick by what the consumer needs:

Channel Latency Best consumer What it carries Note
EventBridge Near-real-time (per event) Lambda, SNS, ticketing automation The individual finding event Filter on status=ACTIVE to cut noise
Security Hub Aggregated (control view) Central security dashboards Findings as ASFF, alongside other tools Enable the integration explicitly

The pattern that works: archive rules absorb the known-good, EventBridge escalates the genuinely new, and nobody triages the same partner-bucket finding twice. The decision table for any external-access finding that lands:

If the finding is… It’s probably… Do this
Access to a partner account you intended Expected external access Archive rule on principal.AWS
Gated by aws:PrincipalOrgID to your own org Not really external Archive rule on the condition
A public S3/SQS/KMS grant you didn’t intend A real exposure Fix the resource policy; do not archive
A new principal you don’t recognize Genuinely new, unreviewed Let EventBridge escalate to the SOC
Flagged with an error Something the analyzer couldn’t fully resolve Investigate manually; never blanket-archive

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, a different confirmation step, and a different blast radius if you get it wrong:

Finding type What it is Confirm before acting Remediation Risk if wrong
UnusedIAMRole A role nobody assumed in your window aws iam get-roleRoleLastUsed Delete the role Break a rare/seasonal consumer
UnusedIAMUserAccessKey A stale long-lived key aws iam get-access-key-last-used Deactivate, then delete Break a forgotten integration
UnusedIAMUserPassword An idle console password Last-console-login in the cred report Disable console access Lock out a rarely-used human
UnusedPermission A used role with untouched actions get-finding-v2 for the action list Trim the policy to used actions Over-trim → access-denied at runtime

UnusedPermission is the most common type at scale and your richest right-sizing signal — a role that is used, but carries actions or whole services it has never touched. 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'

The exact IAM calls that corroborate a finding before you act on it — never delete on the finding alone:

To confirm… Run Look for Safe to act when…
A role is truly dormant aws iam get-role --role-name R --query Role.RoleLastUsed Empty or a very old LastUsedDate No use within (and beyond) your window
A key is truly stale aws iam get-access-key-last-used --access-key-id AK... LastUsedDate absent or old Deactivate first; delete after a soak
A console password is idle aws iam generate-credential-report then get-credential-report Old password_last_used Disable console access
Which actions are unused aws accessanalyzer get-finding-v2 --id ... The unused services/actions list You’ve removed exactly those
Nothing still assumes a role CloudTrail LookupEvents for AssumeRole No recent events After the soak period passes

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 remediation order matters — confirm-before-delete is the rule that keeps you from turning a cleanup into an incident:

Step UnusedIAMRole UnusedPermission UnusedIAMUserAccessKey
1. Confirm RoleLastUsed is empty/old get-finding-v2 lists the unused actions get-access-key-last-used is old/never
2. Stage Detach from anything referencing it Draft the trimmed policy in a PR Deactivate the key (reversible)
3. Soak Watch for assume attempts Watch for new access-denied Watch dependent jobs for failures
4. Remove Delete the role Apply the trimmed policy Delete the key
5. Prove Finding moves to RESOLVED UnusedPermission count drops Finding moves to RESOLVED

The measurable outcome is the count of UnusedPermission findings trending toward zero release over release — that is your privilege-reduction KPI, a number you can put on a slide. Pull the aggregate without paging through individual findings:

aws accessanalyzer get-findings-statistics --analyzer-arn "$UNUSED_ARN"

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

The inputs to a generation job, what each does, and the gotcha that makes jobs fail or undershoot:

Field What it does Required Gotcha
principalArn The role/user to profile Yes Must be in the same account as the call
trails[].cloudTrailArn The trail to read history from Yes A trail must already exist and cover the window
trails[].regions Which regions’ events to include Yes Miss a region → miss that region’s actions
accessRole Role Access Analyzer assumes to read the trail Yes Needs cloudtrail/s3 read on the trail bucket
startTime / endTime The activity window Yes Window > activity period → still misses rare jobs

A generation job ends in exactly one of four states; here is what each means and your next move:

jobDetails.status Meaning Next move
IN_PROGRESS Still reading the trail Poll again; large windows take minutes
SUCCEEDED Policy is ready get-generated-policy → save the JSON
FAILED Couldn’t complete Check jobError; usually the access role or trail
CANCELED You (or the service) canceled it Re-submit with corrected inputs

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. The known blind spots, and what to do about each:

Blind spot Why it happens Mitigation
Rare / seasonal actions Outside the trail window Use a longer window; reconcile by hand
Actions CloudTrail doesn’t log Some data-plane calls aren’t recorded Add them manually from known requirements
Resource-level ARNs Generation favors broad resources Tighten Resource after generation
Conditions / aws:PrincipalOrgID Not inferred from activity Add conditions yourself
First run after a change New code paths not yet exercised Generate after a representative period

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 — generation gives you the floor, the unused analyzer proves the trim, and access-denied tells you when you cut too deep.

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; 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

The four finding types, what each means, and exactly how to treat it in a pipeline:

findingType Meaning Example CI action
ERROR The policy is malformed / won’t work Invalid action name, bad JSON, wrong key Fail the build
SECURITY_WARNING Valid but dangerous Privilege-escalation PassRole, effectively-public resource Fail the build
WARNING Likely a mistake, not dangerous Deprecated global condition key Annotate; review
SUGGESTION Style / tightening hint Empty statement, redundant action Annotate; optional

SECURITY_WARNING is the one that earns its keep: it flags things like a policy that lets a principal 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, because the rule set differs per type:

--policy-type Validates a… Typical source Rule-set focus
IDENTITY_POLICY Identity (user/role/group) policy aws_iam_role_policy, managed policies Action validity, priv-esc, wildcards
RESOURCE_POLICY Resource policy S3 bucket, KMS key, SQS policy Public access, cross-account principals
SERVICE_CONTROL_POLICY An SCP Organizations SCP SCP-specific syntax + effect rules
RESOURCE_CONTROL_POLICY An RCP (data perimeter) Organizations RCP RCP-specific syntax + effect rules

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. The three checks side by side — relative versus absolute is the distinction that decides which one you reach for:

Check Question Needs a “before” doc? Guardrail type Typical use
check-no-new-access “Does the change grant access the old one didn’t?” Yes (existing + new) Relative Trust-policy widening, no new wildcard
check-access-not-granted “Does it grant any forbidden action/resource?” No Absolute Priv-esc set, crown-jewel resources
check-no-public-access “Would this resource policy be public?” No Absolute (resource) S3/SQS/KMS/Secrets resource policies

The output schema is small but you must parse it correctly to wire a reliable gate:

Field Type Meaning How CI uses it
result PASS / FAIL The verdict FAIL → exit non-zero
reasons[] List Which statements caused a FAIL (no-new-access) Surface in the PR comment
message String Human-readable summary Log it for the reviewer

check-no-public-access evaluates a resource policy for an S3 bucket, SQS queue, KMS key, Secrets Manager secret and similar types, and fails if it would be public — drop it into the same pipeline for any resource-policy change. The resource types it understands:

Resource type --resource-type value Common public-exposure trap
S3 bucket AWS::S3::Bucket Principal:"*" without an org/account condition
S3 access point AWS::S3::AccessPoint Inherited broad bucket policy
SQS queue AWS::SQS::Queue Cross-account send left wide open
KMS key AWS::KMS::Key Key policy granting kms:* to *
Secrets Manager secret AWS::SecretsManager::Secret Resource policy without a principal condition
SNS topic AWS::SNS::Topic Public subscribe/publish

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. A starter forbidden-actions set, with why each belongs on the list:

Forbidden action(s) Why it’s dangerous Category
iam:*, iam:CreateAccessKey Mint credentials / self-escalate Privilege escalation
iam:PassRole (unscoped) Hand a service a more-powerful role Privilege escalation
iam:PutRolePolicy, iam:AttachRolePolicy Grant yourself more access Privilege escalation
iam:UpdateAssumeRolePolicy Widen who can assume a role Privilege escalation
iam:DeleteRolePermissionsBoundary Remove the inescapable ceiling Boundary bypass
organizations:LeaveOrganization, organizations:* Escape org-level SCP control Org integrity
s3:DeleteBucket on the audit bucket Destroy the evidence trail Crown-jewel protection
kms:ScheduleKeyDeletion on a CMK Make encrypted data unrecoverable Crown-jewel protection

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. Where each gate slots into each pipeline:

Pipeline stage Terraform CloudFormation Gate that runs
Author local edit local edit validate-policy on saved .json
Plan / change-set terraform plan -outshow -json create-change-setget-template check-access-not-granted, check-no-new-access
Resource-policy diff aws_s3_bucket_policy etc. in plan resource-policy resources in template check-no-public-access
Apply / execute terraform apply execute-change-set (blocked if any check FAILed)
Post-apply drift detection drift detection analyzers re-scan, findings update

Keep forbidden-actions.json in a shared repo so every pipeline enforces one definition of “never.” Choosing where to enforce is itself a decision — each control point has a different reach and failure mode:

Enforcement point Reach Catches Misses
validate-policy in CI One repo’s policies Malformed + dangerous patterns Valid-but-too-broad
check-* in CI One repo’s policies New / forbidden / public access Out-of-band console changes
SCP / RCP at the org Every principal in the OU Anything the SCP denies, always Things you didn’t think to deny
Permission boundary Every role a team creates Unbounded role creation Resource policies
Unused analyzer (detective) The whole org, continuously Drift after the fact Doesn’t block, only reports

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. 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.

The full operational surface in one place — every command you actually run, in roughly the order you’d run them, with what it’s for and where it executes:

Command What it does Where you run it Notes
organizations register-delegated-administrator Delegate Access Analyzer to the audit account Management account, once Service principal access-analyzer.amazonaws.com
accessanalyzer create-analyzer Create an external/unused/internal analyzer Delegated admin account One per --type
accessanalyzer list-analyzers List analyzers + status/ARN Delegated admin account Capture the ARN for finding calls
accessanalyzer list-findings-v2 List findings with a --filter Delegated admin account The v2 API; understands all six types
accessanalyzer get-finding-v2 Detail one finding (unused actions) Delegated admin account Drives precise UnusedPermission trims
accessanalyzer create-archive-rule Auto-archive matching findings Delegated admin account Absorbs known-good external access
accessanalyzer update-findings Archive/unarchive specific finding IDs Delegated admin account One-off triage
accessanalyzer start-policy-generation Async: CloudTrail → policy Same account as the principal Returns a jobId
accessanalyzer get-generated-policy Poll status / fetch the policy Same account Status + generatedPolicies[0].policy
accessanalyzer validate-policy Lint a policy document Any account / CI Free, stateless
accessanalyzer check-no-new-access Block access a new policy adds Any account / CI Relative guardrail
accessanalyzer check-access-not-granted Block forbidden actions/resources Any account / CI Absolute guardrail
accessanalyzer check-no-public-access Block a public resource policy Any account / CI Resource policies only
accessanalyzer get-findings-statistics Aggregate finding counts Delegated admin account Source of the KPI

Architecture at a glance

The diagram traces least privilege as a closed loop, left to right, the way the system actually runs. On the far left sits the activity source: an organization CloudTrail trail in the delegated-admin (audit) account, account 222222222222, capturing every API call across the org. That feeds two things in parallel. It feeds the stateful analyzers — the free ORGANIZATION external-access analyzer, the billed-per-entity ORGANIZATION_UNUSED_ACCESS analyzer (age 90 days), and the paid internal-access analyzer — which continuously emit findings. And it feeds the stateless generate-and-check zone, where start-policy-generation reads the trail and writes a draft policy, validate-policy lints it, and check-access-not-granted/check-no-new-access assert the guardrails. Those checks are the gate inside the CI/CD zone: a Terraform plan is rendered to JSON, every policy document is checked, and a FAIL blocks the merge before apply. On the right, the sink and score zone routes only ACTIVE findings through EventBridge to Security Hub and a SOC ticket, and get-findings-statistics charts the UnusedPermission count toward zero — the KPI that proves the whole loop is working.

Follow the two dashed feedback arrows, because they are the point. The CI/CD gate sends a trimmed role back into the analyzers (the generated, checked policy replaces a wildcard, and the unused analyzer confirms the trim). And the findings sink sends reconcile signals back to generation (a new UnusedPermission says “still too broad,” an access-denied says “too narrow”). Five numbered badges mark where a control bites or a project stalls: the unused analyzer’s billing-and-noise lever (badge 1), generation undershooting its window (badge 2), a well-formed-but-too-broad policy that only check-access-not-granted catches (badge 3), a gate that silently returns PASS (badge 4), and the statistics call that turns invisible progress into a number (badge 5). Read the legend as symptom · how to confirm · fix for each.

Architecture of IAM Access Analyzer as a closed least-privilege loop across five left-to-right zones: an activity source (an organization CloudTrail trail plus Organizations delegated-admin in audit account 222222222222) feeding both the stateful analyzers (free ORGANIZATION external-access, billed-per-entity ORGANIZATION_UNUSED_ACCESS at 90-day age, and paid internal-access) and the stateless generate-and-check zone (start-policy-generation turning CloudTrail into draft JSON, validate-policy flagging ERROR and SECURITY_WARNING, and check-access-not-granted / check-no-new-access enforcing guardrails); those checks gate a CI/CD pipeline that renders a Terraform plan to JSON and blocks the merge on FAIL before apply; and a sink-and-score zone where EventBridge routes only ACTIVE findings to Security Hub and a SOC ticket while get-findings-statistics charts the UnusedPermission count toward zero. Dashed feedback arrows return a trimmed role to the analyzers and reconcile signals to generation. Five numbered badges mark the unused-access billing-and-noise lever, generation undershooting its window, a valid-but-too-broad policy, a gate that wrongly returns PASS, and the statistics KPI

Real-world scenario

Meridian Pay, a fintech platform team, ran a 60-account AWS organization with a recurring, embarrassing audit finding: dozens of CI/CD deployment roles carried Action: "*" or broad service wildcards “to unblock pipelines,” and the count only grew quarter over quarter. Hand-trimming was hopeless — every trim risked breaking a deploy, so nobody trimmed, and the SOC 2 Type II auditor had flagged “excessive IAM permissions” two cycles running. The team was four platform engineers; the deadline was eleven weeks out.

The constraint was hard and explicit: they could not break a single production pipeline, and they had to demonstrate improvement, not assert it. They started by standing up the two org analyzers in their audit account (222222222222) via the delegated-admin pattern, with the unused analyzer set to unusedAccessAge: 90 and exclusions for their three sandbox accounts and every break-glass:true-tagged role — which immediately cut the billed entity count by about a third and silenced the on-call-role noise. Day one, the ORGANIZATION_UNUSED_ACCESS analyzer reported 312 UnusedPermission findings across the deployment roles.

The fix combined the generation and check halves. For each deployment role they ran start-policy-generation over a 90-day CloudTrail window to get a real action list, then replaced the wildcards with the generated documents in a staging account first, and let the unused analyzer confirm the trim by watching UnusedPermission findings drop on those roles. Generation undershot exactly where the docs warn it would — three roles had quarter-end reporting jobs that fell outside the window — so they reconciled those by hand from the runbooks, guided by access-denied errors in staging. 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

The one mistake that cost them a day: the first version of the gate logged the result but never checked the exit code, so a PR adding iam:PassRole passed. They caught it only because a teammate ran the negative test from the runbook — feed a known-bad policy, expect FAIL — and got PASS. After fixing the script to exit 1 on FAIL, they made that negative test a required step in the pipeline itself, so a mis-wired gate can never ship again.

Over the quarter the UnusedPermission finding count fell from 312 to 41 — roughly 87% — the audit finding closed with the get-findings-statistics trend chart as evidence, 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; and the negative test made the gate trustworthy.

The program as a timeline, because the order of moves is the lesson:

Week State Action Effect
0 312 UnusedPermission, audit open Stand up org analyzers, set exclusions Billed entities −⅓; noise gone; baseline captured
1–2 Baseline known start-policy-generation per role (90d window) Draft least-privilege policies in hand
3–5 Drafts ready Apply in staging; watch unused findings drop Trims proven safe; 3 roles need hand-reconcile
5 Gate added check-access-not-granted in every pipeline Wildcards blocked — but gate mis-wired
6 Gate “passing” everything Negative test from runbook returns PASS Bug caught; add exit 1; make test mandatory
7–10 Rollout to prod Promote trimmed policies; chart the KPI Count 312 → 41 (−87%)
11 Audit review Present get-findings-statistics trend Finding closed; zero pipelines broken

Advantages and disadvantages

Access Analyzer turns least privilege from an assertion into a measured, gated process — but it has real edges you must design around. Weigh it honestly:

Advantages Disadvantages
Generation reads actual CloudTrail activity, so trims are evidence-backed, not guesses Generation only sees the trail window — rare/seasonal actions are missed and must be reconciled by hand
The unused analyzer gives a quantitative privilege-reduction KPI you can chart and report Unused analysis is billed per analyzed role/user/month — blanket-enabling the org is needlessly expensive
Stateless check-* APIs gate a PR before anything reaches an account A check that isn’t exit-coded correctly silently passes — a guardrail that never fails isn’t one
validate-policy catches malformed and dangerous (priv-esc) policies in CI, free validate-policy alone won’t stop a valid policy that simply grants too much
Org analyzers auto-cover current and future member accounts — no enumeration Findings are a fact, not a verdict; expected external access still needs manual archiving
External-access detection is free and continuous — leave it on forever Internal-access analysis is a paid tier; scope it to crown jewels, not everything
Archive rules + EventBridge mean a finding is triaged once, then automated CloudTrail doesn’t log every action, so a generated policy can be subtly too narrow

The service is right for any org that has to retrofit least privilege onto a live estate and prove it — the generation/feedback loop converges far faster than hand-authoring, and the checks make the gains permanent. It bites hardest when teams blanket-enable unused analysis without exclusions (a cost surprise), trust a generated policy as final (subtly too narrow), or wire a check whose failure path never exits non-zero (a guardrail that never fails). Every disadvantage is manageable — with deliberate scoping, hand reconciliation, and a mandatory negative test — which is the entire point of running this as a program rather than a one-time scan.

Hands-on lab

Prove the stateless half end to end — validate-policy, check-access-not-granted, check-no-new-access — with nothing but the CLI and local JSON files. Every call here is free and needs no analyzer, so there is nothing to tear down beyond a temp directory. Run in any account where you can call accessanalyzer.

Step 1 — Set up a workspace and a deliberately dangerous policy.

mkdir -p /tmp/aa-lab && cd /tmp/aa-lab
cat > bad.json <<'JSON'
{"Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":"iam:CreateAccessKey","Resource":"*"}]}
JSON
cat > star.json <<'JSON'
{"Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":"*","Resource":"*"}]}
JSON

Step 2 — validate-policy flags the star policy. Expect at least one SECURITY_WARNING or WARNING:

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY --policy-document file://star.json \
  --query "findings[].findingType" --output text
# Expected: SECURITY_WARNING (and possibly WARNING) — never empty

Step 3 — check-access-not-granted FAILS on the forbidden action (the negative test). This is the single most important assertion in the whole build:

aws accessanalyzer check-access-not-granted \
  --policy-type IDENTITY_POLICY --policy-document file://bad.json \
  --access '[{"actions":["iam:CreateAccessKey"]}]' \
  --query 'result' --output text
# Expected: FAIL  (if this returns PASS, your gate is not wired correctly)

Step 4 — Prove the gate also PASSES a clean policy. A guardrail that fails everything is as useless as one that fails nothing:

cat > good.json <<'JSON'
{"Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":["s3:GetObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
aws accessanalyzer check-access-not-granted \
  --policy-type IDENTITY_POLICY --policy-document file://good.json \
  --access '[{"actions":["iam:CreateAccessKey"]}]' \
  --query 'result' --output text
# Expected: PASS

Step 5 — check-no-new-access catches a widening. Compare a tight existing policy against a proposed one that adds an action:

cat > existing.json <<'JSON'
{"Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":["s3:GetObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
cat > proposed.json <<'JSON'
{"Version":"2012-10-17","Statement":[
  {"Effect":"Allow","Action":["s3:GetObject","s3:DeleteObject"],"Resource":"arn:aws:s3:::reports/*"}]}
JSON
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}'
# Expected: Result FAIL — proposed grants s3:DeleteObject that existing did not

Validation checklist. You linted a dangerous policy (validate-policy), proved the absolute gate fails on a forbidden action and passes on a clean one (check-access-not-granted), and proved the relative gate catches a widening (check-no-new-access) — the exact three calls that make a CI guardrail. Each step mapped to what it proves:

Step Call Expected What it proves
2 validate-policy on star.json SECURITY_WARNING Lint catches the dangerous-but-valid
3 check-access-not-granted on bad.json FAIL The negative test: gate actually blocks
4 check-access-not-granted on good.json PASS The gate doesn’t block everything
5 check-no-new-access widening FAIL Relative guardrail catches a delta

Cleanup.

rm -rf /tmp/aa-lab

Cost note. Every call in this lab is a free, stateless API — total cost is ₹0. To exercise the stateful analyzers, create an ACCOUNT external analyzer (also free) in a sandbox; only the unused and internal analyzers carry charges, which is why they stay out of a free-tier lab.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First as a scannable table, then the entries that bite hardest expanded with the exact confirm command.

# Symptom Root cause Confirm (exact command) Fix
1 The CI gate PASSes a policy that grants iam:* Script logs result but never checks it / no exit 1 Run the negative test: bad policy → expect FAIL, got PASS [ "$res" = "FAIL" ] && exit 1; make the negative test mandatory
2 Surprise monthly Access Analyzer bill Unused analyzer enabled org-wide with no exclusions aws accessanalyzer list-analyzers shows *_UNUSED_ACCESS; check entity count Add exclusions for sandboxes + break-glass; or scope to key accounts
3 Generated policy causes AccessDenied in prod Generation window missed a rare/seasonal action App logs show AccessDenied; action not in data-pipeline-generated.json Add the action by hand; widen the window; reconcile from runbooks
4 start-policy-generation job ends FAILED accessRole can’t read the trail bucket, or wrong trail ARN get-generated-policy --job-id → read jobError Grant the access role s3/cloudtrail read; fix the trail ARN
5 Same partner-bucket finding pages the SOC weekly No archive rule; EventBridge fires on all findings list-findings-v2 shows it ACTIVE repeatedly Archive rule on principal.AWS; filter EventBridge to status=ACTIVE
6 create-analyzer returns ValidationException on --type Typo or invented type (e.g. UNUSED, EXTERNAL) Compare against the six legal --type values Use exactly ORGANIZATION_UNUSED_ACCESS etc.
7 Org analyzer created but sees no member accounts Created in a non-delegated account, or delegation missing aws organizations list-delegated-administrators register-delegated-administrator; create in that account
8 validate-policy returns nothing for an SCP Wrong --policy-type (used IDENTITY_POLICY) Re-run with --policy-type SERVICE_CONTROL_POLICY Match the policy type to the document
9 check-no-new-access always FAILs on first apply No existing policy, so everything is “new” The role/policy doesn’t exist yet in the plan Use check-access-not-granted for net-new; reserve no-new-access for edits
10 Unused analyzer flags a break-glass role you need The role isn’t tagged/excluded list-findings-v2 shows the role; tag is absent Tag it break-glass:true and add to exclusions
11 check-no-public-access errors on an identity policy It only evaluates resource policies You passed an identity policy / wrong resource type Use it only on S3/SQS/KMS/Secrets resource policies
12 UnusedPermission count won’t drop after trimming Trimmed in code but never applied, or wrong account get-findings-statistics flat; diff applied vs repo Apply the change; confirm you trimmed the live role

The expanded form for the entries that cost the most time:

1. The CI gate passes a policy that obviously grants too much. Root cause: The pipeline step calls check-access-not-granted but only logs the result — it never inspects the value or exits non-zero on FAIL, so the dangerous policy merges. Confirm: Run the negative test from your runbook — feed a policy granting iam:CreateAccessKey and assert the script exits non-zero. If it exits 0, the gate is decorative. Fix: res=$(... --query 'result' --output text); [ "$res" = "FAIL" ] && exit 1. Then make that negative test a required pipeline step so a mis-wired gate can never ship.

2. An unexpected Access Analyzer line item on the bill. Root cause: ORGANIZATION_UNUSED_ACCESS (or the account variant) is billed per analyzed IAM role and user per month; enabling it across a 60-account org with thousands of roles and no exclusions analyzes — and bills for — every one. Confirm: aws accessanalyzer list-analyzers shows a *_UNUSED_ACCESS type; the bill scales with org role/user count. Fix: Add exclusions for sandbox accountIds and break-glass/service-linked roles via resourceTags; excluded entities are not billed. If you only need a few sensitive accounts, use ACCOUNT_UNUSED_ACCESS there instead of org-wide.

3. A generated policy is too narrow and breaks the workload. Root cause: start-policy-generation only sees actions present in the trail window; a job that runs monthly or quarterly falls outside a 90-day window and is omitted, plus some actions CloudTrail never logs. Confirm: The workload logs AccessDenied for an action not present in the generated JSON. Fix: Add the missing action by hand, widen the generation window to cover the longest cadence, and reconcile known-rare actions from runbooks. Treat generation as a floor you build up from, watching access-denied as the signal you cut too deep.

4. The generation job fails outright. Root cause: The accessRole lacks read on the CloudTrail S3 bucket, or the cloudTrailArn/regions are wrong. Confirm: aws accessanalyzer get-generated-policy --job-id "$JOB" → read jobDetails.jobError. Fix: Grant the access role cloudtrail/s3:GetObject on the trail bucket and prefix; verify the trail ARN and that its regions cover the role’s activity.

5. The SOC keeps getting paged for the same accepted finding. Root cause: No archive rule absorbs the known-good, and the EventBridge rule fires on every finding rather than only new ACTIVE ones. Confirm: list-findings-v2 --filter '{"status":{"eq":["ACTIVE"]}}' shows the same finding repeatedly. Fix: Create an archive rule keyed on principal.AWS (or the condition); scope the EventBridge pattern to detail.status = ACTIVE so only genuinely new findings escalate.

Best practices

Security notes

The controls that both secure and operationalize this build — they pull in the same direction:

Control Mechanism Secures against Also enables
Tight accessRole Scoped s3/cloudtrail read Over-broad audit-history access Reliable, least-priv generation
Forbidden-actions list check-access-not-granted Priv-esc + crown-jewel destruction One org-wide definition of “never”
Org-scoped conditions aws:PrincipalOrgID in resource policies Genuine external exposure Clears the false external finding
check-no-public-access Resource-policy gate Public S3/SQS/KMS/Secrets Catches exposure pre-deploy
SCP / RCP + boundaries Org + role-creation ceilings Out-of-band console widening Defense in depth beyond CI
Archive-rule discipline Justified, named rules only Silently accepting real exposure Clean, trustworthy finding queue

Cost & sizing

The bill is dominated by exactly one thing, and the rest is free — which makes sizing mostly a scoping exercise:

A rough monthly picture for a 60-account org: external analyzer ₹0; unused analyzer billed on, say, 1,500 analyzed entities after exclusions (a small per-entity rate that you confirm against current pricing for your region); internal analyzer scoped to a couple dozen crown-jewel resources; and ₹0 for all the CI checks. The cost drivers and how to control each:

Cost driver Billed on Control lever Watch-out
Unused-access analyzer Per analyzed role/user/month exclusions (accounts + tags); scope to key accounts Org-wide with no exclusions = the surprise bill
Internal-access analyzer Paid tier Limit to crown-jewel resources Don’t enable estate-wide “just in case”
External-access analyzer Free None — leave it on
validate-policy / check-* Free None — run on every PR
start-policy-generation Free Time cost is reconciliation, not money
Engineering time Your team Automate triage; archive rules; KPI dashboard The real cost; justify with the KPI

Interview & exam questions

1. What are the three analyzer types, and which one costs money? External, unused and internal access. External answers “who outside my zone of trust can reach this?” and is free. Unused answers “what is granted but idle?” and is billed per analyzed IAM role and user per month. Internal answers “who inside my org can reach this critical resource?” and is a paid tier. There is no combined analyzer — you deploy one resource per type.

2. List the six legal analyzer --type values. ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS. Anything else (e.g. UNUSED, EXTERNAL) returns a ValidationException. An ORGANIZATION-scoped analyzer automatically covers current and future member accounts.

3. Difference between check-no-new-access and check-access-not-granted? check-no-new-access is relative: it compares a proposed policy to an existing one and fails if the change grants any access the old policy did not — ideal for “trust policies must not widen.” check-access-not-granted is absolute: independent of any before-state, it fails if the policy grants a specific forbidden action or resource — ideal for “never grant iam:CreateAccessKey.” Use both: relative for edits, absolute for a shared forbidden-actions list.

4. Why is a generated policy only a starting point? start-policy-generation can only include actions that appear in the CloudTrail window you give it, so anything running less often than the window (quarterly jobs) is missed, and some actions CloudTrail does not log at all are omitted. The result can be subtly too narrow. You reconcile rare actions by hand, widen on AccessDenied, and let the unused analyzer confirm the trim isn’t too broad.

5. An app throws AccessDenied after you replaced its wildcard with a generated policy. What happened and how do you fix it? The generation window missed an action the app needs — a rare/seasonal call outside the window, or one CloudTrail doesn’t log. Confirm the action is absent from the generated JSON, then add it by hand and widen the window for next time. This is expected: generation is a floor you build up from, with access-denied as the signal you cut too deep.

6. How do you stop the SOC being paged for the same accepted external-access finding repeatedly? Create an archive rule keyed on the known-good principal (e.g. principal.AWS containing a partner account), which auto-archives matching findings current and future; and scope the EventBridge rule to detail.status = ACTIVE so only genuinely new findings escalate. Archive rules absorb the known-good, EventBridge escalates the new, nobody triages twice.

7. What does validate-policy catch that the check-* APIs don’t, and vice versa? validate-policy lints a single document for ERROR (malformed) and SECURITY_WARNING (valid-but-dangerous, e.g. privilege escalation) — but it won’t stop a valid policy that merely grants too much. The check-* APIs reason about what the policy grants relative to a baseline or a forbidden list, catching the too-broad-but-valid case. You want both in CI.

8. Why must you run the policy checks on the rendered IaC document rather than the source? Because the source (HCL/template) may interpolate variables, modules, or generated documents — the rendered policy is what actually reaches the account. Run terraform show -json on the plan (or get-template on the CloudFormation change-set) and check each resolved policy document before apply/execute-change-set, so the gate evaluates exactly what will be deployed.

9. Your check-access-not-granted gate let a dangerous policy merge. What’s the most likely bug and how do you prevent recurrence? The pipeline step logged result but never inspected it or exited non-zero on FAIL, so the dangerous policy passed. Fix with [ "$res" = "FAIL" ] && exit 1, then add a mandatory negative test — feed a known-bad policy and assert the step fails — so a mis-wired gate can never ship. A guardrail that never fails is not a guardrail.

10. How do you control the cost of the unused-access analyzer? It is billed per analyzed IAM role and user per month, so cost scales with the entity count it watches. Use exclusions to skip sandbox accountIds and break-glass/service-linked roles by resourceTags (excluded entities are not billed), and consider ACCOUNT_UNUSED_ACCESS on a few sensitive accounts instead of org-wide if you only need targeted coverage.

11. What is the privilege-reduction KPI and where does it come from? The count of UnusedPermission (and UnusedIAMRole) findings over time, trending toward zero. Pull it with get-findings-statistics, which aggregates without paging through individual findings; snapshot it on a schedule and chart the trend. A downward line is the auditable proof that the generation/right-sizing loop is working — the metric you report up, not the raw finding list.

12. Which --policy-type values does validate-policy accept, and why does it matter? IDENTITY_POLICY, RESOURCE_POLICY, SERVICE_CONTROL_POLICY, and RESOURCE_CONTROL_POLICY. The rule set differs per type — validating an SCP as an IDENTITY_POLICY produces wrong or empty results. Always match the type to the document you’re checking.

These map most directly to AWS Certified Security – Specialty (SCS-C02)identity and access management, governance, and least privilege at scale — and the IAM/governance domains of Solutions Architect Professional (SAP-C02) and DevOps Engineer Professional (DOP-C02) for the CI/CD-gating angle. A compact cert mapping for revision:

Question theme Primary cert Domain
Analyzer types, external/unused/internal Security – Specialty IAM & detective controls
check-* / validate-policy in CI DevOps Pro / Security – Specialty Automation; policy as code
Policy generation from CloudTrail Security – Specialty Least privilege; CloudTrail
Delegated admin, org analyzers SA Pro / Security – Specialty Multi-account governance
Archive rules, EventBridge routing Security – Specialty Findings triage & response
Cost of unused analysis SA Pro Cost-aware design

Quick check

  1. You enabled ORGANIZATION_UNUSED_ACCESS across all 60 accounts and got a surprise bill. What is the charge based on, and what is the one lever that lowers it?
  2. A teammate adds iam:PassRole to a deployment role and the PR’s check-access-not-granted step still goes green. What is the most likely bug?
  3. You replaced a wildcard with a generated policy and the app now throws AccessDenied on a month-end job. Why, and what’s the fix?
  4. Which check is relative (needs a “before” document) and which is absolute (does not)?
  5. Name the six legal analyzer --type values.

Answers

  1. It is billed per analyzed IAM role and user per month, so the bill scales with the number of entities the analyzer watches. The lever is exclusions in the analysisRule — exclude sandbox accountIds and break-glass/service-linked roles by resourceTags; excluded entities are not analyzed and not billed. (Or scope to ACCOUNT_UNUSED_ACCESS on a few sensitive accounts.)
  2. The pipeline step logs the result but never checks it or exits non-zero on FAIL, so the dangerous policy merges. Fix with [ "$res" = "FAIL" ] && exit 1 and add a mandatory negative test (a known-bad policy that must produce FAIL).
  3. start-policy-generation only sees actions in the CloudTrail window you gave it; a month-end job outside that window was omitted, so the generated policy is too narrow. Add the missing action by hand, widen the window to cover the longest cadence, and use AccessDenied as the signal you cut too deep.
  4. check-no-new-access is relative — it takes an existing and a new document and fails on any added access. check-access-not-granted is absolute — it takes one document plus a forbidden --access list and fails if any is granted, with no before-state.
  5. ACCOUNT, ORGANIZATION, ACCOUNT_UNUSED_ACCESS, ORGANIZATION_UNUSED_ACCESS, ACCOUNT_INTERNAL_ACCESS, ORGANIZATION_INTERNAL_ACCESS.

Glossary

Next steps

You can now run Access Analyzer as a closed loop — detect, generate, check, enforce, and prove. Build outward:

awsiamaccess-analyzerleast-privilegesecuritycicd
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments