Photo by Luke Caunt on Unsplash
To find S3 buckets without versioning, list your buckets with ListBuckets and call GetBucketVersioning on each one. A response with no Status means versioning was never enabled; Suspended means it was turned off after being on. The TypeScript script below does both across every region and only reads bucket settings.
Versioning is what lets you recover an object after an accidental overwrite or delete. Without it, a bad deploy script or a mistyped aws s3 rm --recursive is permanent. This example is for engineers auditing an AWS account who want to find S3 buckets without versioning quickly, see which ones were deliberately suspended, and decide which need it switched on.
It’s one of our AWS practical examples with full TypeScript scripts. Run it alongside the script to find public and private S3 buckets for a fuller picture of S3 risk. Versioning lets you recover from a bad delete; the script to find S3 buckets without server access logging helps you find out who made it.
What do the three versioning states mean?
An S3 bucket is always in one of three states, and GetBucketVersioning reports them slightly differently from how the console labels them:
| State | Status in the API response |
What happens on overwrite or delete |
|---|---|---|
| Unversioned (never enabled) | absent | The old data is gone for good. |
| Enabled | Enabled |
Every write creates a new version; a delete adds a delete marker you can remove. |
| Suspended | Suspended |
Existing versions are kept, but new writes use the null version ID and replace each other. |
Once a bucket has been versioned it can never go back to unversioned, only to suspended. So a Suspended bucket usually means someone made a decision, often to control cost, while an absent status usually means nobody ever looked. The script sorts never-enabled buckets to the top for that reason. The Amazon S3 user guide on versioning covers the details of each state.
What does the script do?
- Lists every bucket
paginateListBucketsreturns all general purpose buckets the account owns, with each bucket’sBucketRegion. - Queries each bucket in its own regionOne
S3Clientper region, withfollowRegionRedirectsas a safety net. Ten buckets are checked at a time. - Classifies the resultNever enabled, Suspended, Enabled, or Error with the error name, plus the MFA Delete setting.
- Prints a table or JSONPass
--jsonto feed the result into another tool or a ticket.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-s3package. - A profile in the account that owns the buckets. Only the bucket owner can read a bucket’s versioning state.
Which IAM permissions does it need?
Two read-only actions: s3:ListAllMyBuckets, which only supports "Resource": "*", and s3:GetBucketVersioning on bucket ARNs. The script never calls PutBucketVersioning.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListBuckets",
"Effect": "Allow",
"Action": "s3:ListAllMyBuckets",
"Resource": "*"
},
{
"Sid": "ReadVersioningState",
"Effect": "Allow",
"Action": "s3:GetBucketVersioning",
"Resource": "arn:aws:s3:::*"
}
]
}
AWS’s ReadOnlyAccess managed policy also covers both actions. To tighten a policy like this further, use the checklist to review an IAM policy for least privilege before you attach it.
The script to find S3 buckets without versioning
// s3-versioning-report.ts
// Lists every S3 bucket in the account and reports its versioning status:
// "Never enabled", "Suspended" or "Enabled" (plus MFA Delete).
// Read-only. Usage: npx tsx s3-versioning-report.ts [--json]
import {
S3Client,
paginateListBuckets,
GetBucketVersioningCommand,
type Bucket,
} from "@aws-sdk/client-s3";
type Row = {
bucket: string;
region: string;
versioning: "Never enabled" | "Suspended" | "Enabled" | "Error";
mfaDelete: string;
created: string;
note: string;
};
const clients = new Map<string, S3Client>();
function s3For(region: string): S3Client {
let client = clients.get(region);
if (!client) {
client = new S3Client({ region, followRegionRedirects: true });
clients.set(region, client);
}
return client;
}
async function checkBucket(b: Bucket): Promise<Row> {
const bucket = b.Name ?? "";
const region = b.BucketRegion ?? "us-east-1";
const base = { bucket, region, created: b.CreationDate?.toISOString().slice(0, 10) ?? "" };
try {
const res = await s3For(region).send(new GetBucketVersioningCommand({ Bucket: bucket }));
// Status is absent for buckets that have never had versioning turned on.
const versioning = res.Status === "Enabled" ? "Enabled" : res.Status === "Suspended" ? "Suspended" : "Never enabled";
return { ...base, versioning, mfaDelete: res.MFADelete ?? "Disabled", note: "" };
} catch (err) {
const name = err instanceof Error ? err.name : String(err);
return { ...base, versioning: "Error", mfaDelete: "", note: name };
}
}
async function main(): Promise<void> {
const asJson = process.argv.includes("--json");
const listClient = s3For(process.env.AWS_REGION ?? "us-east-1");
const buckets: Bucket[] = [];
for await (const page of paginateListBuckets({ client: listClient }, {})) {
buckets.push(...(page.Buckets ?? []));
}
// Check a few buckets at a time to stay well under S3 request rates.
const rows: Row[] = [];
for (let i = 0; i < buckets.length; i += 10) {
rows.push(...(await Promise.all(buckets.slice(i, i + 10).map(checkBucket))));
}
const order = { "Never enabled": 0, Suspended: 1, Error: 2, Enabled: 3 } as const;
rows.sort((a, b) => order[a.versioning] - order[b.versioning] || a.bucket.localeCompare(b.bucket));
if (asJson) {
console.log(JSON.stringify(rows, null, 2));
return;
}
console.table(
rows.map((r) => ({
Bucket: r.bucket,
Region: r.region,
Versioning: r.versioning,
"MFA Delete": r.mfaDelete,
Created: r.created,
Note: r.note,
})),
);
const count = (v: Row["versioning"]) => rows.filter((r) => r.versioning === v).length;
console.log(
`${rows.length} buckets: ${count("Never enabled")} never versioned, ${count("Suspended")} suspended, ` +
`${count("Enabled")} enabled, ${count("Error")} could not be read`,
);
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
If you later add a remediation step, the AI IAM policy generator for TypeScript SDK code will pick up the extra s3:PutBucketVersioning action from your code.
How do you run it?
npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript
# Table view
AWS_PROFILE=readonly npx tsx s3-versioning-report.ts
# JSON, filtered to buckets that were never versioned
AWS_PROFILE=readonly npx tsx s3-versioning-report.ts --json \
| jq -r '.[] | select(.versioning == "Never enabled") | .bucket'
Sample output
┌─────────┬─────────────────────────┬─────────────┬─────────────────┬────────────┬──────────────┬────────────────┐
│ (index) │ Bucket │ Region │ Versioning │ MFA Delete │ Created │ Note │
├─────────┼─────────────────────────┼─────────────┼─────────────────┼────────────┼──────────────┼────────────────┤
│ 0 │ 'acme-build-artifacts' │ 'us-east-1' │ 'Never enabled' │ 'Disabled' │ '2023-04-11' │ '' │
│ 1 │ 'acme-customer-uploads' │ 'eu-west-1' │ 'Never enabled' │ 'Disabled' │ '2024-01-29' │ '' │
│ 2 │ 'acme-web-logs' │ 'us-east-1' │ 'Suspended' │ 'Disabled' │ '2022-08-03' │ '' │
│ 3 │ 'acme-payroll-exports' │ 'us-east-1' │ 'Error' │ '' │ '2025-06-17' │ 'AccessDenied' │
│ 4 │ 'acme-terraform-state' │ 'us-east-1' │ 'Enabled' │ 'Disabled' │ '2022-02-14' │ '' │
│ 5 │ 'acme-backups-eu' │ 'eu-west-1' │ 'Enabled' │ 'Enabled' │ '2023-11-02' │ '' │
└─────────┴─────────────────────────┴─────────────┴─────────────────┴────────────┴──────────────┴────────────────┘
6 buckets: 2 never versioned, 1 suspended, 2 enabled, 1 could not be read
Bucket names and results are illustrative. The AccessDenied row is typical of a bucket whose bucket policy denies everyone except one role, even inside the owning account.
Should every bucket have versioning enabled?
Not automatically. Versioning keeps every overwritten and deleted object as a noncurrent version, and you pay storage for each one. A bucket that holds build artifacts rewritten on every commit can grow many times larger with versioning on. Before you enable it, check how much data the bucket holds with the script to find the size of each S3 bucket, then add a lifecycle rule with NoncurrentVersionExpiration so old versions are deleted after, say, 30 or 90 days. For buckets that are already versioned, the script to measure S3 storage used by noncurrent object versions shows how much old data they hold today.
Buckets that almost always need versioning: anything holding the only copy of customer data, Terraform state, backups and audit exports. S3 Replication and S3 Object Lock also require it. Buckets where it’s often left off on purpose: short-lived scratch space and logs that are already copied elsewhere. Noncurrent versions are billed at the storage class of the version, so a lifecycle transition to a cheaper class is another way to cap the cost.
Tip: to turn versioning on after review, run aws s3api put-bucket-versioning --bucket NAME --versioning-configuration Status=Enabled with a profile that has s3:PutBucketVersioning. Keep that out of the read-only audit profile.
Troubleshooting
AccessDeniedon some buckets only. A bucket policy is denying the audit role, or an SCP applies. The guide to troubleshoot AWS IAM access denied errors step by step shows how to find which policy said no.- A bucket you expected is missing.
ListBucketsreturns only buckets owned by the calling account. Directory buckets (S3 Express One Zone) aren’t listed and don’t support versioning. PermanentRedirectorAuthorizationHeaderMalformed. The client region didn’t match the bucket. The script usesBucketRegionand region redirects; if you copy onlycheckBucket, keep both.- Delete markers after enabling. Deleting an object in a versioned bucket hides it behind a delete marker, so a HEAD check returns 404. The script to check if an S3 object exists in TypeScript explains how to check a specific version.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which of my S3 buckets don’t have versioning enabled?” and follow up with “How big are they?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile, and explains the result, the same flow described in the guide to analyze your AWS security posture with an AI CLI. It runs changes without a confirmation step, so ask it to report, not to enable versioning, and use a read-only profile. The ChatWithCloud security model explains what data leaves your machine.
Frequently asked questions
How do I check if S3 versioning is enabled with the AWS CLI?
Run aws s3api get-bucket-versioning --bucket NAME. An empty response means versioning was never enabled; otherwise you’ll see "Status": "Enabled" or "Suspended".
Can I disable versioning on an S3 bucket?
You can suspend it, not remove it. Existing versions stay until you delete them or a lifecycle rule expires them.
Does S3 versioning cost extra?
There’s no fee for the feature itself, but every noncurrent version is billed as stored data at its storage class rate.
Does this script cover every region?
Yes. ListBuckets is global, and the script sends each GetBucketVersioning call to the bucket’s own region.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud