Photo by Denny Müller on Unsplash
To find public AMIs you own, call DescribeImages with Owners: ["self"] in every Region and check each image’s Public flag, then read DescribeImageAttribute with launchPermission to see accounts and organizations it’s shared with. The group all means public. Remove it with ModifyImageAttribute and turn on block public access for AMIs.
An AMI is a full disk image: operating system, application code, configuration and anything that was on the instance when someone created it. Make one public and every AWS account can launch it, look through its file system and copy what they find. It usually happens by accident, through a console toggle during a test or a script that shared with all instead of an account ID. This example is for engineers who need to find public AMIs across every Region and close the exposure without breaking legitimate shares.
You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that reports by default and changes nothing unless you pass --apply. It’s the AMI companion to the script to find public EBS and RDS snapshots, which covers disks and databases shared the same way. Other accounts can also reach you through resource policies and role trust, which the scripts to find public SNS topics and SQS queues and to find IAM roles trusted by external accounts cover.
Why are public AMIs risky?
- They carry whatever was on the disk. SSH keys,
.envfiles, shell history, application credentials and access keys left in~/.awsall travel with the image. - They’re unencrypted by definition. AWS doesn’t allow an AMI with encrypted volumes or snapshots of encrypted volumes to be made public, so every public AMI you find is readable.
- Copies outlive the fix. Anyone can launch an instance from a public AMI and create their own AMI from that instance. Making yours private later doesn’t touch their copy.
AWS doesn’t bill you when other accounts launch your AMI, so nothing on your invoice hints that it’s being used. Treat any credential found in a public image as exposed. The script to find IAM access keys older than 90 days or never used helps track down keys that may have been baked into an image.
What is block public access for AMIs?
Block public access for AMIs is an account-level setting, configured separately in each Region, that rejects any attempt to make an AMI public. Two details matter for an audit:
- It doesn’t touch existing public AMIs. They stay public until you remove the
allgroup yourself. - The default depends on history. It’s on for new accounts and for existing accounts with no public AMIs. It’s off for accounts that had one or more public AMIs on or after July 15, 2023, even if they’re all private now.
GetImageBlockPublicAccessState returns block-new-sharing or unblocked, plus ManagedBy: account, or declarative-policy when an AWS Organizations declarative policy sets it and the account can’t change it. Enabling it can take up to 10 minutes, during which the state still reads unblocked.
How does the script find public AMIs?
- Lists Regions
DescribeRegions, or the Regions you pass with--regions=. - Reads block public access
GetImageBlockPublicAccessStateonce per Region. - Lists your AMIs
paginateDescribeImageswithOwners: ["self"], including deprecated and disabled images. - Reads launch permissions
DescribeImageAttributewithAttribute: "launchPermission"returns entries withGroup: "all",UserId,OrganizationArnorOrganizationalUnitArn. - Fixes on requestWith
--apply,ModifyImageAttributeremoves theallgroup; account and organization shares are reported, never removed. Adding--enable-blockalso callsEnableImageBlockPublicAccesswhere the state isunblockedand the account manages it.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-ec2. - An AWS profile configured as in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.
Which IAM permissions does it need?
Image ARNs have no account ID (arn:aws:ec2:region::image/ami-id). The last two statements are for --apply only.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListImagesAndSettings",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"ec2:DescribeImages",
"ec2:GetImageBlockPublicAccessState"
],
"Resource": "*"
},
{
"Sid": "ReadLaunchPermissions",
"Effect": "Allow",
"Action": "ec2:DescribeImageAttribute",
"Resource": "arn:aws:ec2:*::image/*"
},
{
"Sid": "MakePrivateApplyOnly",
"Effect": "Allow",
"Action": "ec2:ModifyImageAttribute",
"Resource": "arn:aws:ec2:*::image/*"
},
{
"Sid": "BlockPublicSharingApplyOnly",
"Effect": "Allow",
"Action": "ec2:EnableImageBlockPublicAccess",
"Resource": "*"
}
]
}
EnableImageBlockPublicAccess and the three list calls don’t support resource-level permissions, so they use "*". Leave both apply statements off the profile you use for reports.
The script to find public AMIs
// find-public-amis.ts
// Finds AMIs you own that are public or shared with other accounts or organizations, in each Region,
// and shows whether block public access for AMIs is on. Dry run by default.
// --apply removes the "all" group from public AMIs (makes them private)
// --apply --enable-block also turns on block public access (block-new-sharing) where it's off
// Account and organization shares are reported, never removed.
// Usage: npx tsx find-public-amis.ts [--regions=us-east-1,eu-west-1] [--apply] [--enable-block]
import {
EC2Client,
DescribeRegionsCommand,
paginateDescribeImages,
DescribeImageAttributeCommand,
ModifyImageAttributeCommand,
GetImageBlockPublicAccessStateCommand,
EnableImageBlockPublicAccessCommand,
} from "@aws-sdk/client-ec2";
const args = process.argv.slice(2);
const apply = args.includes("--apply");
const enableBlock = apply && args.includes("--enable-block");
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);
interface Row {
Region: string;
ImageId: string;
Name: string;
Created: string;
Public: string;
Accounts: number;
Orgs: number;
Action: string;
}
interface RegionState {
Region: string;
BlockPublicAccess: string;
ManagedBy: string;
}
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg;
const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
async function scanRegion(region: string, rows: Row[], states: RegionState[]): Promise<void> {
const ec2 = new EC2Client({ region });
const bpa = await ec2.send(new GetImageBlockPublicAccessStateCommand({}));
const state: RegionState = {
Region: region,
BlockPublicAccess: bpa.ImageBlockPublicAccessState ?? "unknown",
ManagedBy: bpa.ManagedBy ?? "-",
};
states.push(state);
const input = { Owners: ["self"], IncludeDeprecated: true, IncludeDisabled: true };
for await (const page of paginateDescribeImages({ client: ec2 }, input)) {
for (const img of page.Images ?? []) {
if (!img.ImageId) continue;
const perms = (await ec2.send(new DescribeImageAttributeCommand({ ImageId: img.ImageId, Attribute: "launchPermission" }))).LaunchPermissions ?? [];
const isPublic = img.Public === true || perms.some((p) => p.Group === "all");
const accounts = perms.filter((p) => p.UserId).length;
const orgs = perms.filter((p) => p.OrganizationArn || p.OrganizationalUnitArn).length;
if (!isPublic && accounts === 0 && orgs === 0) continue; // private and unshared
const row: Row = {
Region: region,
ImageId: img.ImageId,
Name: img.Name ?? "-",
Created: (img.CreationDate ?? "").slice(0, 10),
Public: isPublic ? "PUBLIC" : "no",
Accounts: accounts,
Orgs: orgs,
Action: isPublic ? "would make private" : "review shares",
};
if (isPublic && apply) {
try {
await ec2.send(new ModifyImageAttributeCommand({ ImageId: img.ImageId, LaunchPermission: { Remove: [{ Group: "all" }] } }));
row.Public = "no";
row.Action = "made private";
} catch (err) {
row.Action = `failed: ${err instanceof Error ? err.name : String(err)}`;
}
}
rows.push(row);
}
}
if (enableBlock && state.BlockPublicAccess === "unblocked" && state.ManagedBy !== "declarative-policy") {
const out = await ec2.send(new EnableImageBlockPublicAccessCommand({ ImageBlockPublicAccessState: "block-new-sharing" }));
state.BlockPublicAccess = `${out.ImageBlockPublicAccessState ?? "requested"} (can take up to 10 min)`;
}
}
async function main(): Promise<void> {
const rows: Row[] = [];
const states: RegionState[] = [];
for (const region of await listRegions()) {
try {
await scanRegion(region, rows, states);
} catch (err) {
console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
}
}
rows.sort((a, b) => b.Public.localeCompare(a.Public) || a.Region.localeCompare(b.Region));
console.table(rows);
console.table(states.filter((s) => s.BlockPublicAccess !== "block-new-sharing"));
const pub = rows.filter((r) => r.Public === "PUBLIC").length;
const open = states.filter((s) => s.BlockPublicAccess === "unblocked").length;
console.log(`${rows.length} shared AMI(s), ${pub} still public; block public access off in ${open} of ${states.length} Region(s).`);
if (!apply && pub) console.log("Dry run. Re-run with --apply to make public AMIs private (add --enable-block to block new public sharing).");
if (pub) process.exitCode = 2;
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Report every enabled Region (dry run)
AWS_PROFILE=security-audit npx tsx find-public-amis.ts
# Make public AMIs private and block new public sharing in two Regions
AWS_PROFILE=ec2-admin npx tsx find-public-amis.ts --regions=us-east-1,eu-west-1 --apply --enable-block
The script makes one DescribeImageAttribute call per AMI you own, so accounts with thousands of images take a while; the SDK retries throttled calls on its own.
Sample output
┌─────────┬─────────────┬─────────────────────────┬─────────────────────────────┬──────────────┬──────────┬──────────┬──────┬──────────────────────┐
│ (index) │ Region │ ImageId │ Name │ Created │ Public │ Accounts │ Orgs │ Action │
├─────────┼─────────────┼─────────────────────────┼─────────────────────────────┼──────────────┼──────────┼──────────┼──────┼──────────────────────┤
│ 0 │ 'eu-west-1' │ 'ami-0c4f2a9d81e6b3f57' │ 'debug-snapshot-orders-api' │ '2025-11-04' │ 'PUBLIC' │ 0 │ 0 │ 'would make private' │
│ 1 │ 'us-east-1' │ 'ami-07d3e9b2a5c18f604' │ 'app-golden-2024-03' │ '2024-03-18' │ 'PUBLIC' │ 2 │ 0 │ 'would make private' │
│ 2 │ 'us-east-1' │ 'ami-0a91f6c3d27e4b858' │ 'base-hardened-al2023' │ '2026-08-30' │ 'no' │ 1 │ 1 │ 'review shares' │
└─────────┴─────────────┴─────────────────────────┴─────────────────────────────┴──────────────┴──────────┴──────────┴──────┴──────────────────────┘
┌─────────┬──────────────────┬───────────────────┬───────────┐
│ (index) │ Region │ BlockPublicAccess │ ManagedBy │
├─────────┼──────────────────┼───────────────────┼───────────┤
│ 0 │ 'ap-southeast-2' │ 'unblocked' │ 'account' │
│ 1 │ 'eu-west-1' │ 'unblocked' │ 'account' │
└─────────┴──────────────────┴───────────────────┴───────────┘
3 shared AMI(s), 2 still public; block public access off in 2 of 17 Region(s).
Dry run. Re-run with --apply to make public AMIs private (add --enable-block to block new public sharing).
The IDs are illustrative. app-golden-2024-03 and the debug-snapshot image are public; the second name suggests it was built from a live instance to troubleshoot something, which is exactly the kind of image that holds credentials. The base-hardened AMI is shared with one account and an organization, which is probably deliberate. Two Regions still allow new public sharing.
What should you do after finding a public AMI?
- Make it privateRun with
--apply, or remove theallgroup in the console under Edit AMI permissions. You keep launch access as the owner. - Block new public sharingAdd
--enable-block, or use a declarative policy to set it for every account and Region at once. - Assume it was copiedRotate any credential that was on the image: database passwords, API tokens, SSH keys, and IAM access keys.
- Find who shared itA
ModifyImageAttributeevent in CloudTrail shows who made the change and when; the script to check CloudTrail is enabled and logging in every AWS Region confirms those events are being recorded. - Delete what you don’t needOld test images are the ones most often left public. The script to clean up AMIs and snapshots older than 30 days deregisters them and deletes the snapshots behind them.
Encrypting the volumes behind your images closes the door for good, because encrypted AMIs can’t be made public at all. The script to find unencrypted EBS volumes and turn on default encryption makes new volumes, and so new images, encrypted by default.
Troubleshooting
UnauthorizedOperationonEnableImageBlockPublicAccess. The profile lacks the permission, or an SCP denies it. WhenManagedByisdeclarative-policy, the script skips the call because the account can’t change it.- A public AMI isn’t listed. It may belong to another account;
Owners: ["self"]returns only images your account owns. - Block public access still says
unblocked. The change can take up to 10 minutes. Re-run the report later. - A public AMI disappeared from the list on its own. Public AMIs get a deprecation date two years after creation by default, and AWS eventually removes public sharing from deprecated AMIs nobody has launched for six months or more. Don’t rely on that as your control.
Ask ChatWithCloud instead
ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your profile and sends the JSON result to the AI model to write the answer. Ask “Do I own any public AMIs in eu-west-1?” or “Is block public access for AMIs enabled in every Region?” Changes run without a confirmation step, so explore with a read-only AWS profile connected to ChatWithCloud, and read the ChatWithCloud security model to see what leaves your machine. More scripts like this one are collected in the AWS practical examples with SDK v3 code.
Frequently asked questions
How do I check if my AMI is public?
Run DescribeImages with your AMI ID and look at Public, or read DescribeImageAttribute with launchPermission. A Group of all means every AWS account can launch it.
Does block public access make existing public AMIs private?
No. It only blocks new public sharing. Remove the all launch permission from existing public AMIs yourself.
Can an encrypted AMI be public?
No. AMIs with encrypted volumes, snapshots of encrypted volumes or product codes can’t be made public. They can still be shared with specific accounts.
Does making an AMI private stop instances already launched from it?
No. It stops new launches by other accounts. Instances and AMIs they already created from your image stay in their accounts.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud