Photo by Jose Fontano on Unsplash
To check IAM Access Analyzer in all Regions, call ListAnalyzers in each Region from DescribeRegions and look for an ACTIVE analyzer of type ACCOUNT or ORGANIZATION. Those external access analyzers are regional and free. Then call ListFindingsV2 with a status = ACTIVE filter to count the resources shared publicly or with other accounts.
IAM Access Analyzer answers a question that policy reviews rarely do: which of your resources can someone outside your account or organization reach right now? It only answers it for Regions where an analyzer exists, though. This example is for engineers who want to check IAM Access Analyzer in all Regions of an account and get a one-screen summary of what each analyzer has found.
You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that lists external, internal and unused access analyzers per Region, counts active findings by resource type, and creates the missing external access analyzers only when you pass --apply. The guide to review an IAM policy for least privilege uses Access Analyzer’s ValidatePolicy to lint a single policy; this script covers the other half of the service, the analyzers that watch live resources.
Which analyzer types does Access Analyzer have?
ListAnalyzers returns six types. The difference that matters for a multi-Region check is scope: some are per Region, one covers every Region at once.
| Type | Finds | Scope | Price (September 2026) |
|---|---|---|---|
ACCOUNT / ORGANIZATION |
External access: resources shared publicly or outside the zone of trust | One Region per analyzer | No additional charge |
ACCOUNT_UNUSED_ACCESS / ORGANIZATION_UNUSED_ACCESS |
Unused roles, access keys, passwords and permissions | All Regions (IAM is global) | $0.20 per IAM role or user analyzed per month |
ACCOUNT_INTERNAL_ACCESS / ORGANIZATION_INTERNAL_ACCESS |
Which principals inside the account or organization can reach selected S3 buckets and DynamoDB tables | One Region per analyzer | $9.00 per monitored resource per month |
Prices are the ones on the IAM Access Analyzer pricing page when this was checked; verify them before you turn on a paid analyzer. The practical rule: every Region you use needs its own free external access analyzer, while one unused access analyzer is enough for the whole account.
External access findings cover resource types such as S3 buckets and directory buckets, IAM role trust policies, KMS keys, Lambda functions and layers, SQS queues, SNS topics, Secrets Manager secrets, EFS file systems, ECR repositories, EBS and RDS snapshots, and DynamoDB tables and streams. That makes one analyzer a broad replacement for several single-service checks.
What does the script do?
- Lists Regions
DescribeRegionsreturns the Regions enabled for your account, or you pass--regions=. - Lists analyzers
ListAnalyzers(paginated, no type filter) in each Region, with name, type and status:ACTIVE,CREATING,DISABLEDorFAILED. - Counts active findings
ListFindingsV2withstatus: { eq: ["ACTIVE"] }, grouped by resource type for external and internal analyzers and by finding type (UnusedIAMRole,UnusedPermissionand so on) for unused access analyzers. - Counts public findingsA second query adds
isPublic: { eq: ["true"] }for external analyzers, because public access is the finding to handle first. - Creates, if askedWith
--apply,CreateAnalyzerwith typeACCOUNTin each Region that has no active external access analyzer.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-accessanalyzerand@aws-sdk/client-ec2. - A profile for the account, set up as described in AWS SDK v3 credential providers such as fromIni and fromSSO.
- For an
ORGANIZATIONanalyzer, the management account or the delegated administrator for Access Analyzer. Member accounts can’t see organization analyzers, so run the script there too.
Which IAM permissions does it need?
ListFindingsV2 is authorized by the access-analyzer:ListFindings action; there is no separate V2 action. The last two statements are only for --apply, which creates the AWSServiceRoleForAccessAnalyzer service-linked role the first time.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReportAnalyzersAndFindings",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"access-analyzer:ListAnalyzers",
"access-analyzer:ListFindings"
],
"Resource": "*"
},
{
"Sid": "CreateExternalAnalyzerOnlyWithApply",
"Effect": "Allow",
"Action": "access-analyzer:CreateAnalyzer",
"Resource": "arn:aws:access-analyzer:*:*:analyzer/account-external-access"
},
{
"Sid": "AccessAnalyzerServiceLinkedRole",
"Effect": "Allow",
"Action": "iam:CreateServiceLinkedRole",
"Resource": "arn:aws:iam::*:role/aws-service-role/access-analyzer.amazonaws.com/AWSServiceRoleForAccessAnalyzer",
"Condition": { "StringEquals": { "iam:AWSServiceName": "access-analyzer.amazonaws.com" } }
}
]
}
An audit role only needs the first statement. The free IAM policy generator for TypeScript AWS code can draft this kind of policy from your own SDK v3 scripts, and the guide to find the IAM actions used in AWS SDK JavaScript code explains how commands map to actions.
The script to check IAM Access Analyzer in all Regions
// check-iam-access-analyzer-all-regions.ts
// Lists IAM Access Analyzer analyzers in every enabled Region (external, internal and unused access),
// summarizes ACTIVE findings per analyzer, and flags Regions with no external access analyzer.
// Changes nothing unless you pass --apply, which creates a free account-level external access analyzer
// in each Region that has none.
// Usage: npx tsx check-iam-access-analyzer-all-regions.ts [--regions=us-east-1,eu-west-1] [--apply]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
AccessAnalyzerClient,
CreateAnalyzerCommand,
paginateListAnalyzers,
paginateListFindingsV2,
} from "@aws-sdk/client-accessanalyzer";
import type { AnalyzerSummary, Criterion } from "@aws-sdk/client-accessanalyzer";
const args = process.argv.slice(2);
const apply = args.includes("--apply");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1];
const EXTERNAL = new Set(["ACCOUNT", "ORGANIZATION"]);
interface AnalyzerRow {
Region: string;
Analyzer: string;
Type: string;
Status: string;
ActiveFindings: number;
Public: number | string;
TopTypes: string;
}
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
const out = await new EC2Client({}).send(new DescribeRegionsCommand({})); // enabled Regions only
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
async function analyzersIn(client: AccessAnalyzerClient): Promise<AnalyzerSummary[]> {
const all: AnalyzerSummary[] = [];
for await (const page of paginateListAnalyzers({ client }, {})) all.push(...(page.analyzers ?? []));
return all;
}
// Counts ACTIVE findings, grouped by resource type (external/internal) or finding type (unused access).
async function summarize(client: AccessAnalyzerClient, analyzer: AnalyzerSummary) {
const filter: Record<string, Criterion> = { status: { eq: ["ACTIVE"] } };
const byKey = new Map<string, number>();
let total = 0;
for await (const page of paginateListFindingsV2({ client }, { analyzerArn: analyzer.arn, filter })) {
for (const f of page.findings ?? []) {
const key = (analyzer.type ?? "").includes("UNUSED") ? f.findingType ?? "?" : f.resourceType ?? "?";
byKey.set(key, (byKey.get(key) ?? 0) + 1);
total++;
}
}
let publicCount: number | string = "-";
if (EXTERNAL.has(analyzer.type ?? "") && total > 0) {
publicCount = 0;
const publicFilter: Record<string, Criterion> = { ...filter, isPublic: { eq: ["true"] } };
for await (const page of paginateListFindingsV2({ client }, { analyzerArn: analyzer.arn, filter: publicFilter })) {
publicCount += page.findings?.length ?? 0;
}
}
const top = [...byKey.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, n]) => `${k} ${n}`).join(", ");
return { total, publicCount, top: top || "-" };
}
async function main(): Promise<void> {
const regions = await listRegions();
const rows: AnalyzerRow[] = [];
const noExternal: string[] = [];
let unusedSeen = false;
for (const region of regions) {
const client = new AccessAnalyzerClient({ region });
let analyzers: AnalyzerSummary[];
try {
analyzers = await analyzersIn(client);
} catch (err) {
rows.push({ Region: region, Analyzer: `ERROR ${err instanceof Error ? err.name : err}`, Type: "", Status: "", ActiveFindings: 0, Public: "", TopTypes: "" });
continue;
}
if (!analyzers.some((a) => EXTERNAL.has(a.type ?? "") && a.status === "ACTIVE")) noExternal.push(region);
if (analyzers.some((a) => (a.type ?? "").includes("UNUSED"))) unusedSeen = true;
for (const a of analyzers) {
const s = a.status === "ACTIVE" ? await summarize(client, a) : { total: 0, publicCount: "-", top: a.statusReason?.code ?? "-" };
rows.push({ Region: region, Analyzer: a.name ?? "?", Type: a.type ?? "?", Status: a.status ?? "?", ActiveFindings: s.total, Public: s.publicCount, TopTypes: s.top });
}
}
console.table(rows);
console.log(`External access analyzer missing in ${noExternal.length} of ${regions.length} Regions: ${noExternal.join(", ") || "none"}`);
if (!unusedSeen) console.log("No unused access analyzer found (one per account or organization covers all Regions; it is a paid feature).");
if (apply && noExternal.length) {
for (const region of noExternal) {
try {
const out = await new AccessAnalyzerClient({ region }).send(new CreateAnalyzerCommand({
analyzerName: "account-external-access",
type: "ACCOUNT",
}));
console.log(`Created ${out.arn}`);
} catch (err) {
console.error(`Could not create an analyzer in ${region}: ${err instanceof Error ? `${err.name} ${err.message}` : err}`);
process.exitCode = 1;
}
}
} else if (noExternal.length) {
console.log("Run again with --apply to create an account-level external access analyzer in those Regions.");
process.exitCode = 2;
}
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
The script never creates a paid analyzer. It reports whether an unused access analyzer exists anywhere, because one of those covers every Region, and leaves that decision to you.
How do you run it?
npm install @aws-sdk/client-accessanalyzer @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Report every enabled Region
AWS_PROFILE=security-audit npx tsx check-iam-access-analyzer-all-regions.ts
# Create the free external access analyzer where it's missing
AWS_PROFILE=security-admin npx tsx check-iam-access-analyzer-all-regions.ts --apply
A new analyzer scans the Region’s supported resources right after it’s created. Run the report again after a few minutes to see its first findings.
Sample output
┌─────────┬─────────────┬─────────────────────────────────────┬─────────────────────────┬──────────┬────────────────┬────────┬──────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Analyzer │ Type │ Status │ ActiveFindings │ Public │ TopTypes │
├─────────┼─────────────┼─────────────────────────────────────┼─────────────────────────┼──────────┼────────────────┼────────┼──────────────────────────────────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'ExternalAccess-ConsoleAnalyzer-eu' │ 'ACCOUNT' │ 'ACTIVE' │ 3 │ 0 │ 'AWS::IAM::Role 2, AWS::KMS::Key 1' │
│ 1 │ 'us-east-1' │ 'ExternalAccess-ConsoleAnalyzer' │ 'ACCOUNT' │ 'ACTIVE' │ 9 │ 2 │ 'AWS::S3::Bucket 4, AWS::IAM::Role 3, AWS::SQS::Queue 2' │
│ 2 │ 'us-east-1' │ 'unused-access-90d' │ 'ACCOUNT_UNUSED_ACCESS' │ 'ACTIVE' │ 27 │ '-' │ 'UnusedPermission 14, UnusedIAMRole 9, UnusedIAMUserAccessKey 4' │
└─────────┴─────────────┴─────────────────────────────────────┴─────────────────────────┴──────────┴────────────────┴────────┴──────────────────────────────────────────────────────────────────┘
External access analyzer missing in 2 of 4 Regions: eu-central-1, us-west-2
Run again with --apply to create an account-level external access analyzer in those Regions.
The names and counts are illustrative. eu-central-1 and us-west-2 have no external analyzer, so nothing watches resources there. In us-east-1, 2 of the 9 active findings are public: those are resources anyone on the internet can reach. The unused access analyzer is a single row, yet it covers IAM for the whole account.
How should you act on the findings?
An active finding means a policy grants access to a principal outside your zone of trust. It doesn’t mean the access was used. Work through them in this order:
- Public findings first. Check whether the access is intended, such as a static website bucket behind CloudFront. If not, tighten the resource policy; the finding moves to
RESOLVEDonce Access Analyzer re-analyzes the resource. The script to find public SNS topics and SQS queues shows the policy patterns to look for. - Cross-account access to roles. A trust policy that lets another account assume a role shows up as an
AWS::IAM::Rolefinding. Compare it with the output of the script to find IAM roles trusted by external accounts. - Intended sharing. Archive it, or better, write an archive rule with the
principal.AWSorresourceOwnerAccountfilter keys so the same partner account doesn’t produce new findings every time.
Unused access findings are the least privilege work list. The NIST definition of least privilege asks for only the access a user or process needs for its function; UnusedPermission findings show where a role has more than that. The scripts to find unused IAM roles and to find old and unused IAM access keys do a free, rougher version of the same analysis from last-used dates.
Troubleshooting
ValidationExceptiononListFindingsV2. A filter key isn’t valid for that analyzer type.isPubliconly applies to external access analyzers, which is why the script checks the type first.ConflictExceptionon--apply. An analyzer calledaccount-external-accessalready exists in that Region, possibly inCREATINGorFAILEDstate. Delete the failed one or pick another name.- Analyzer status
FAILED. The status reason is shown inTopTypes.AWS_SERVICE_ACCESS_DISABLEDmeans trusted access for Access Analyzer is off in AWS Organizations. AccessDeniedException. The profile lacks the first statement above; the guide to diagnose AWS IAM access denied errors walks through SCPs and permission boundaries too.
Ask ChatWithCloud instead
ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile, and sends the JSON result to the AI model to write the answer. Ask “Which Access Analyzer findings are public in this Region?” and it can call ListAnalyzers and ListFindings. It works with one profile and Region per session, so this script remains the better tool for a sweep across Regions, and newer operations such as ListFindingsV2 may be missing from SDK v2. Generated code runs without a confirmation step, so connect ChatWithCloud to your AWS account with a read-only profile and read how ChatWithCloud handles credentials and data.
Frequently asked questions
Is IAM Access Analyzer global or regional?
External and internal access analyzers are regional: each one analyzes supported resources in its own Region. An unused access analyzer covers IAM users and roles, which are global, so one per account or organization is enough.
Is IAM Access Analyzer free?
External access analysis and policy validation have no additional charge. Unused access analysis, internal access analysis and custom policy checks are paid, per the IAM Access Analyzer pricing page.
How do I enable IAM Access Analyzer in all Regions at once?
There is no single switch. Create an analyzer in each Region with CreateAnalyzer, as the script’s --apply does, or deploy an AWS::AccessAnalyzer::Analyzer resource with CloudFormation StackSets across Regions.
What is the difference between ListFindings and ListFindingsV2?
ListFindingsV2 also returns unused and internal access findings with their finding type. Both use the access-analyzer:ListFindings permission.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud