Photo by Daniel Forsman on Unsplash
S3 server access logging is not enabled on a bucket when GetBucketLogging returns no LoggingEnabled element and no CloudWatch Logs delivery source exists for it. List buckets with ListBuckets, check both per bucket, and turn logging on with PutBucketLogging to a log bucket in the same Region and account that grants logging.s3.amazonaws.com write access.
Server access logs answer questions CloudTrail often can’t: who tried to read an object and got denied because their credentials were invalid, which lifecycle rule expired a file, or which client is hammering a bucket with requests. They’re off by default, and nobody notices until the day they’re needed. CloudFront distributions in front of a bucket keep their own request logs, also off by default; the script to find CloudFront distributions without access logging reports which ones record nothing.
This example is for engineers who want a complete list of buckets with S3 server access logging not enabled, and a safe way to fix it. The script checks both delivery paths S3 now supports, reports the gaps and, with --apply --target, turns on delivery to a central log bucket. It sits alongside the checks to find S3 buckets whose policy doesn’t require HTTPS and find S3 buckets that still use ACLs.
Server access logs or CloudTrail data events?
Both record object-level requests, and AWS recommends CloudTrail for bucket- and object-level auditing. They still differ in ways that decide which one you need. From the AWS page comparing logging options for Amazon S3:
| Property | Server access logs | CloudTrail data events |
|---|---|---|
| Price | No charge for delivery to an S3 bucket; you pay for log storage | Data events are charged per event, plus storage |
| Delivery speed | Within a few hours, best effort | Data events about every 5 minutes |
| Requests that fail authentication | Logged | Not logged |
| Lifecycle transitions and expirations | Logged | Not logged |
| Log file integrity validation | No | Yes |
| Logging a subset of objects by prefix | No | Yes |
For most buckets, server access logs are the cheap default and CloudTrail data events are for the few buckets where you need near-real-time, tamper-evident audit records. Server access logs are best effort: a record can arrive late, be duplicated or occasionally not arrive at all, so don’t use them as a billing ledger. For the CloudTrail side, the check to verify CloudTrail is enabled and logging in every AWS Region covers management events. Load balancers keep their own request logs in S3, and those are off by default too; the script to find ALB, NLB and Classic load balancers without access logs reports them and turns logging on.
Two delivery paths, and why a simple check misses one
- To an S3 bucket. Configured with
PutBucketLoggingand visible inGetBucketLogging. Space-delimited text, SSE-S3 encryption only, and the destination must be in the same Region and account. - To CloudWatch Logs. Configured with CloudWatch Logs vended-log APIs: a delivery source of type
S3_SERVER_ACCESS_LOGS, a destination and a delivery. It doesn’t appear inGetBucketLogging, supports KMS encryption and cross-account aggregation, and is billed at CloudWatch vended logs rates.
A check that only reads GetBucketLogging reports CloudWatch-delivered buckets as unlogged. The script reads the delivery sources and deliveries in each Region first, so those buckets count as covered.
What does the script do?
- Lists buckets
paginateListBuckets, which returns each bucket’sBucketRegion.GetBucketLocationis the fallback. - Reads CloudWatch Logs deliveries
DescribeDeliverySourcesandDescribeDeliveriesper Region, keeping sources of typeS3_SERVER_ACCESS_LOGSthat have a delivery. - Reads each bucket’s logging
GetBucketLoggingwith a client in the bucket’s Region. Buckets that log into themselves are flagged; buckets that are already log destinations are skipped. - Enables logging on requestWith
--apply --target,PutBucketLoggingsends logs totarget/<bucket>/with date-based partitioning (PartitionedPrefix,EventTime). Buckets in other Regions are skipped.
Prerequisites
- Node.js 18 or later, npm,
tsx,@aws-sdk/client-s3and@aws-sdk/client-cloudwatch-logs. - For
--apply: one log bucket per Region, with Object Lock off, Requester Pays off, SSE-S3 default encryption and its own logging off. - A lifecycle rule on the log bucket so logs don’t grow forever; the script to find S3 buckets without lifecycle rules will catch it if you forget.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadLoggingConfig",
"Effect": "Allow",
"Action": [
"s3:ListAllMyBuckets",
"s3:GetBucketLocation",
"s3:GetBucketLogging",
"logs:DescribeDeliverySources",
"logs:DescribeDeliveries"
],
"Resource": "*"
},
{
"Sid": "EnableLoggingWithApply",
"Effect": "Allow",
"Action": "s3:PutBucketLogging",
"Resource": "arn:aws:s3:::*"
}
]
}
Drop the second statement for a report-only role. The log bucket also needs a bucket policy that lets the S3 logging service write to it. Without it, PutBucketLogging succeeds but no logs arrive:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3ServerAccessLogsPolicy",
"Effect": "Allow",
"Principal": { "Service": "logging.s3.amazonaws.com" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-access-logs-us-east-1/*",
"Condition": {
"ArnLike": { "aws:SourceArn": ["arn:aws:s3:::orders-exports", "arn:aws:s3:::static-site-assets"] },
"StringEquals": { "aws:SourceAccount": "123456789012" }
}
}
]
}
List every source bucket in aws:SourceArn, or use arn:aws:s3:::* together with the aws:SourceAccount condition. If the log bucket has Deny statements, such as the HTTPS-only rule, make sure they don’t block this principal.
The script to fix S3 server access logging not enabled
// find-s3-buckets-without-access-logging.ts
// Lists every general purpose S3 bucket and how its server access logs are delivered: to an S3 bucket
// (GetBucketLogging) or to CloudWatch Logs (a vended-log delivery source). Report only unless you pass
// --apply --target <log-bucket>, which turns on S3 delivery to that bucket for flagged buckets in its Region.
// Usage:
// npx tsx find-s3-buckets-without-access-logging.ts [--regions us-east-1,eu-west-1]
// npx tsx find-s3-buckets-without-access-logging.ts --apply --target my-access-logs-us-east-1 [--names a,b]
import { CloudWatchLogsClient, paginateDescribeDeliveries, paginateDescribeDeliverySources } from "@aws-sdk/client-cloudwatch-logs";
import { GetBucketLocationCommand, GetBucketLoggingCommand, PutBucketLoggingCommand, S3Client, paginateListBuckets } from "@aws-sdk/client-s3";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const list = (v: string | undefined) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const onlyRegions = new Set(list(flag("--regions")));
const onlyNames = new Set(list(flag("--names")));
const apply = args.includes("--apply");
const target = flag("--target");
if (apply && !target) {
console.error("--apply needs --target <log-bucket> (same Region and account as the source buckets)");
process.exit(1);
}
interface Row {
Region: string;
Bucket: string;
Delivery: string;
Destination: string;
Status: string;
}
const clients = new Map<string, S3Client>();
const s3 = (region: string): S3Client => {
let c = clients.get(region);
if (!c) {
c = new S3Client({ region });
clients.set(region, c);
}
return c;
};
// Buckets whose server access logs go to CloudWatch Logs: a delivery source of type
// S3_SERVER_ACCESS_LOGS that has at least one delivery attached.
async function cloudWatchLogged(region: string): Promise<Set<string>> {
const logs = new CloudWatchLogsClient({ region });
const sources = new Map<string, string[]>(); // source name -> bucket ARNs
for await (const page of paginateDescribeDeliverySources({ client: logs }, {})) {
for (const s of page.deliverySources ?? []) {
if (s.logType === "S3_SERVER_ACCESS_LOGS" && s.name) sources.set(s.name, s.resourceArns ?? []);
}
}
const logged = new Set<string>();
if (!sources.size) return logged;
for await (const page of paginateDescribeDeliveries({ client: logs }, {})) {
for (const d of page.deliveries ?? []) {
for (const arn of sources.get(d.deliverySourceName ?? "") ?? []) logged.add(arn.replace("arn:aws:s3:::", ""));
}
}
return logged;
}
async function main(): Promise<void> {
// ListBuckets returns BucketRegion; GetBucketLocation is the fallback for older responses.
const buckets: { name: string; region: string }[] = [];
for await (const page of paginateListBuckets({ client: s3("us-east-1") }, {})) {
for (const b of page.Buckets ?? []) {
if (!b.Name) continue;
let region = b.BucketRegion;
if (!region) {
const loc = await s3("us-east-1").send(new GetBucketLocationCommand({ Bucket: b.Name }));
region = loc.LocationConstraint ?? "us-east-1"; // null means us-east-1
}
if (!onlyRegions.size || onlyRegions.has(region)) buckets.push({ name: b.Name, region });
}
}
const cwByRegion = new Map<string, Set<string>>();
for (const region of new Set(buckets.map((b) => b.region))) {
try {
cwByRegion.set(region, await cloudWatchLogged(region));
} catch (err) {
console.error(`${region}: can't read CloudWatch Logs deliveries (${err instanceof Error ? err.name : String(err)})`);
cwByRegion.set(region, new Set());
}
}
const rows: Row[] = [];
const targets = new Set<string>();
for (const b of buckets) {
const row: Row = { Region: b.region, Bucket: b.name, Delivery: "", Destination: "", Status: "" };
try {
const { LoggingEnabled } = await s3(b.region).send(new GetBucketLoggingCommand({ Bucket: b.name }));
const viaCw = cwByRegion.get(b.region)?.has(b.name) ?? false;
if (LoggingEnabled?.TargetBucket) {
targets.add(LoggingEnabled.TargetBucket);
row.Delivery = viaCw ? "S3 + CloudWatch Logs" : "S3";
row.Destination = `${LoggingEnabled.TargetBucket}/${LoggingEnabled.TargetPrefix ?? ""}`;
row.Status = LoggingEnabled.TargetBucket === b.name ? "LOGS INTO ITSELF" : "ok";
} else if (viaCw) {
row.Delivery = "CloudWatch Logs";
row.Status = "ok";
} else {
row.Delivery = "none";
row.Status = "NOT ENABLED";
}
} catch (err) {
row.Status = `error: ${err instanceof Error ? err.name : String(err)}`;
}
rows.push(row);
}
// A log bucket shouldn't log itself; mark destinations so they aren't counted as gaps.
for (const r of rows) if (targets.has(r.Bucket) && r.Status === "NOT ENABLED") r.Status = "log destination (skip)";
if (apply && target) {
const targetRegion = buckets.find((b) => b.name === target)?.region;
for (const r of rows) {
if (r.Status !== "NOT ENABLED" && r.Status !== "LOGS INTO ITSELF") continue;
if (r.Bucket === target || (onlyNames.size && !onlyNames.has(r.Bucket))) continue;
if (r.Region !== targetRegion) {
r.Status += " (target is in another Region)";
continue;
}
try {
await s3(r.Region).send(
new PutBucketLoggingCommand({
Bucket: r.Bucket,
BucketLoggingStatus: {
LoggingEnabled: {
TargetBucket: target,
TargetPrefix: `${r.Bucket}/`,
TargetObjectKeyFormat: { PartitionedPrefix: { PartitionDateSource: "EventTime" } },
},
},
}),
);
r.Delivery = "S3";
r.Destination = `${target}/${r.Bucket}/`;
r.Status = "ENABLED";
} catch (err) {
r.Status = `error: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
const gaps = rows.filter((r) => r.Status !== "ok" && r.Status !== "log destination (skip)");
console.table(gaps.length ? gaps : rows);
console.log(`${rows.length} buckets checked, ${rows.filter((r) => r.Status.startsWith("NOT ENABLED")).length} without server access logging`);
if (!apply) console.log("Report only. Re-run with --apply --target <log-bucket> to turn logging on.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-s3 @aws-sdk/client-cloudwatch-logs
npm install --save-dev tsx typescript @types/node
# Report on every bucket in the account
AWS_PROFILE=readonly npx tsx find-s3-buckets-without-access-logging.ts
# Send logs for two us-east-1 buckets to the central log bucket
AWS_PROFILE=s3-admin npx tsx find-s3-buckets-without-access-logging.ts --apply --target my-access-logs-us-east-1 --names orders-exports,static-site-assets
Sample output
┌─────────┬─────────────┬──────────────────────┬──────────┬────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ (index) │ Region │ Bucket │ Delivery │ Destination │ Status │
├─────────┼─────────────┼──────────────────────┼──────────┼────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'orders-exports' │ 'S3' │ 'my-access-logs-us-east-1/orders-exports/' │ 'ENABLED' │
│ 1 │ 'us-east-1' │ 'static-site-assets' │ 'S3' │ 'my-access-logs-us-east-1/static-site-assets/' │ 'ENABLED' │
│ 2 │ 'us-east-1' │ 'tmp-uploads' │ 'S3' │ 'tmp-uploads/logs/' │ 'LOGS INTO ITSELF' │
│ 3 │ 'eu-west-1' │ 'eu-reports' │ 'none' │ '' │ 'NOT ENABLED (target is in another Region)' │
└─────────┴─────────────┴──────────────────────┴──────────┴────────────────────────────────────────────────┴─────────────────────────────────────────────┘
23 buckets checked, 1 without server access logging
Names are illustrative. tmp-uploads logs into itself, which creates a loop: every log write is itself a request that gets logged. Point it at the log bucket instead. eu-reports needs a log bucket in eu-west-1, because S3 delivery can’t cross Regions.
What should you check after turning logging on?
- Logs actually arrive. The first files can take a few hours. If nothing shows up after a day, the log bucket policy is the usual cause.
- The log bucket is private. Access logs contain requester IPs, object keys and user agents. Run the check to find public and private S3 buckets with the AWS SDK on it.
- Storage stays bounded. Busy buckets produce many small log objects. Expire them with a lifecycle rule; the guide to calculate S3 GET and PUT request costs explains why small objects add request charges when you process them later.
- You can query them. The date-based prefix the script sets lets Athena prune by day; the guide to run an Athena query with AWS SDK v3 shows the query side.
Troubleshooting
InvalidTargetBucketForLogging. The log bucket doesn’t exist, belongs to another account or sits in another Region. A bucket with Object Lock or Requester Pays enabled can’t be a destination either.- Logging is on but the log bucket is empty. The bucket policy doesn’t allow
logging.s3.amazonaws.com, aDenyblocks it, or the bucket uses SSE-KMS default encryption. Use SSE-S3 on the log bucket. - CloudWatch Logs delivery shows as missing. The script needs the two
logs:Describe*actions in each Region; without them it prints an error and treats the Region as having no deliveries. AccessDeniedon one bucket. A bucket policy can deny even the account’s own admins. The guide to troubleshoot AWS IAM access denied errors step by step covers bucket policy denials.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which S3 buckets don’t have server access logging enabled?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and explains the answer, one profile and Region per session. Features added after SDK v2’s end of support in September 2025 may be missing from the code it writes, and it runs generated code without a confirmation step, so connect ChatWithCloud to your AWS account with a read-only profile. The guide to ask AI which S3 buckets are largest and which are public has more bucket questions.
Frequently asked questions
How do I check if S3 server access logging is enabled?
Run aws s3api get-bucket-logging --bucket my-bucket. An empty response means no S3 delivery; then check aws logs describe-delivery-sources for CloudWatch Logs delivery.
Does S3 server access logging cost anything?
Delivery to an S3 bucket is free; you pay for storing the log files and normal charges for reading them. Delivery to CloudWatch Logs is billed at vended logs ingestion rates.
Can the log bucket be in another Region or account?
Not with S3 bucket delivery: the destination must be in the same Region and account. CloudWatch Logs delivery supports cross-account and cross-Region aggregation.
Should the log bucket have access logging enabled?
No. AWS recommends against enabling server access logging on the destination bucket, and logging a bucket into itself creates a loop.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud