Photo by Dimitri Karastelev on Unsplash
CloudFront access logs are not enabled when a distribution has neither standard logging (legacy), set in DistributionConfig.Logging, nor a standard logging (v2) delivery, set up through the CloudWatch Logs API in us-east-1. Checking only one of them gives false alarms. Call GetDistributionConfig per distribution, then match DescribeDeliverySources and DescribeDeliveries by distribution ARN.
CloudFront access logs record every viewer request: when it arrived, the processing time, the request path and the response. Without them, a traffic spike, a scraping run or a leaked signed URL leaves no trail you can query afterwards. CloudFront doesn’t turn them on for you.
This example is for engineers auditing an account with more than a handful of distributions. The script reports each distribution’s legacy S3 logging, its v2 log destinations and real-time logs, then flags the ones where CloudFront access logs are not enabled. It also catches two quieter failures: a v2 delivery source with nowhere to send logs, and a legacy log bucket that can no longer receive them. It’s report only.
How do the two CloudFront access log versions differ?
CloudFront supports two versions of standard logging, and a distribution can use either or both. The CloudFront guide to configuring standard logging (v2) covers the newer one in detail.
| Standard logging (legacy) | Standard logging (v2) | |
|---|---|---|
| Destinations | S3 only | CloudWatch Logs, Firehose, S3 |
| Where it’s configured | Logging in the distribution config (CloudFront API) |
Delivery source, destination and delivery (CloudWatch Logs API, always in us-east-1) |
| Bucket requirements | ACLs enabled; not in an opt-in Region | Bucket policy for log delivery; opt-in Regions supported |
| Field choice and formats | Fixed fields | Choose fields, output format, partitioning |
For v2, a distribution has at most one delivery source, created with PutDeliverySource using the distribution ARN and the log type ACCESS_LOGS. The source does nothing on its own: logs flow only once CreateDelivery links it to a destination. That’s why the script reports a source with no delivery separately.
Both versions are best effort. AWS says to use them to understand the nature of your traffic, not as a complete accounting of every request, because an entry can arrive late or, rarely, not at all.
Why does the legacy log bucket need ACLs?
Legacy logging writes through the awslogsdelivery account, and CloudFront grants that account FULL_CONTROL in the bucket ACL when you enable logging. A bucket with S3 Object Ownership set to bucket owner enforced has ACLs disabled, which prevents delivery. Since April 2023, new buckets have ACLs disabled by default, so a log bucket someone recreated or “hardened” can quietly stop receiving files. The example to find S3 buckets with ACLs enabled is the usual push to disable ACLs; exclude legacy CloudFront log buckets from it, or move those distributions to v2 first.
What do CloudFront access logs cost?
CloudFront doesn’t charge for enabling standard logs. You pay for delivery and storage at the destination. Rates from the AWS Price List for us-east-1, dated 22 September 2026, first 10 TB a month (the Amazon CloudWatch pricing page lists the other tiers under vended logs):
| Destination | Charge per GB of logs |
|---|---|
| Legacy logging to S3 | No delivery charge; S3 storage and requests only |
| v2 to S3 | No delivery charge; $0.03 to convert to Parquet if you choose it; S3 storage |
| v2 to CloudWatch Logs (Standard class) | $0.50 ingestion ($0.25 in the Infrequent Access class), plus storage |
| v2 to Firehose | $0.25 delivery, plus Firehose and target charges |
Worked example. A distribution that generates 60 GB of access logs a month costs nothing to deliver to S3 as plain text, 60 × $0.03 = $1.80 to deliver as Parquet, or 60 × $0.50 = $30.00 to ingest into CloudWatch Logs Standard. For most audit needs, S3 plus a lifecycle rule is the cheap default; the example to find S3 buckets without lifecycle rules keeps the log bucket from growing forever.
What does the script check?
- Reads v2 sources
paginateDescribeDeliverySourcesin us-east-1, keeping sources whereserviceiscloudfrontandlogTypeisACCESS_LOGS. - Reads v2 deliveries
paginateDescribeDeliveriesmaps each source to its destination types (CWL,S3,FH). - Lists distributions
paginateListDistributions, with the real-time log config ARN on the default cache behavior. - Reads legacy logging
GetDistributionConfigper distribution forLogging.Enabled,BucketandPrefix. - Checks the legacy bucket
GetBucketOwnershipControlsflags buckets set toBucketOwnerEnforced.
Real-time logs go to Kinesis Data Streams and are billed separately. The script shows them but doesn’t count them as access logs, because you pick a sampling rate for them and they’re built for live monitoring rather than audit.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-cloudfront,@aws-sdk/client-cloudwatch-logsand@aws-sdk/client-s3. - Credentials for the account that owns the distributions. The v2 check reads delivery sources in that account only, so a delivery set up from another account won’t appear.
Which IAM permissions does it need?
All read-only. GetBucketOwnershipControls needs access to the log buckets, which may belong to a central logging account.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListDistributionsAndDeliveries",
"Effect": "Allow",
"Action": ["cloudfront:ListDistributions", "logs:DescribeDeliverySources", "logs:DescribeDeliveries"],
"Resource": "*"
},
{
"Sid": "ReadDistributionConfig",
"Effect": "Allow",
"Action": "cloudfront:GetDistributionConfig",
"Resource": "arn:aws:cloudfront::111122223333:distribution/*"
},
{
"Sid": "ReadLogBucketOwnership",
"Effect": "Allow",
"Action": "s3:GetBucketOwnershipControls",
"Resource": "arn:aws:s3:::*"
}
]
}
Replace arn:aws:s3:::* with your log bucket ARNs once you know them. The free IAM policy generator for TypeScript code can draft this from the script itself.
The script to find CloudFront access logs not enabled
// find-cloudfront-distributions-without-logging.ts
// Reports every CloudFront distribution with its access logging: standard logging (legacy) to S3 from the
// distribution config, standard logging (v2) from CloudWatch Logs delivery sources and deliveries in us-east-1,
// and real-time logs on the default cache behavior. Flags distributions with no access logs at all, v2 sources
// with no delivery, and legacy log buckets that have ACLs disabled. Report only: nothing is changed.
// Usage:
// npx tsx find-cloudfront-distributions-without-logging.ts
import { CloudFrontClient, GetDistributionConfigCommand, paginateListDistributions } from "@aws-sdk/client-cloudfront";
import {
CloudWatchLogsClient,
paginateDescribeDeliveries,
paginateDescribeDeliverySources,
} from "@aws-sdk/client-cloudwatch-logs";
import { S3Client, GetBucketOwnershipControlsCommand } from "@aws-sdk/client-s3";
// CloudFront is global, and standard logging (v2) is configured through the CloudWatch Logs API in us-east-1.
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const logs = new CloudWatchLogsClient({ region: "us-east-1" });
const s3 = new S3Client({ region: "us-east-1", followRegionRedirects: true });
interface Row {
Distribution: string;
Domain: string;
Enabled: string;
LegacyS3: string;
V2Destinations: string;
RealTime: string;
Finding: string;
}
const DEST_LABEL: Record<string, string> = { CWL: "CloudWatch Logs", S3: "S3", FH: "Firehose" };
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// Legacy logging stores the bucket as "amzn-s3-demo-bucket.s3.amazonaws.com".
const bucketName = (domain: string): string => domain.replace(/\.s3[.-][a-z0-9.-]*amazonaws\.com$/, "");
const ownershipCache = new Map<string, string>();
async function objectOwnership(bucket: string): Promise<string> {
const cached = ownershipCache.get(bucket);
if (cached) return cached;
let result: string;
try {
const out = await s3.send(new GetBucketOwnershipControlsCommand({ Bucket: bucket }));
result = out.OwnershipControls?.Rules?.[0]?.ObjectOwnership ?? "?";
} catch (err) {
const status = (err as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
const name = err instanceof Error ? err.name : "";
// No ownership controls on an older bucket means ACLs are still enabled (ObjectWriter behaviour).
if (status === 404 && name !== "NoSuchBucket") result = "none set";
else result = `unknown (${name || status})`;
}
ownershipCache.set(bucket, result);
return result;
}
async function main(): Promise<void> {
// Standard logging (v2): one delivery source per distribution, with zero or more deliveries.
const sourceByArn = new Map<string, string>();
for await (const page of paginateDescribeDeliverySources({ client: logs }, {})) {
for (const src of page.deliverySources ?? []) {
if (src.service !== "cloudfront" || src.logType !== "ACCESS_LOGS" || !src.name) continue;
for (const arn of src.resourceArns ?? []) sourceByArn.set(arn, src.name);
}
}
const destinationsBySource = new Map<string, string[]>();
for await (const page of paginateDescribeDeliveries({ client: logs }, {})) {
for (const d of page.deliveries ?? []) {
if (!d.deliverySourceName) continue;
const list = destinationsBySource.get(d.deliverySourceName) ?? [];
list.push(DEST_LABEL[d.deliveryDestinationType ?? ""] ?? d.deliveryDestinationType ?? "?");
destinationsBySource.set(d.deliverySourceName, list);
}
}
const rows: Row[] = [];
for await (const page of paginateListDistributions({ client: cloudfront }, {})) {
for (const dist of page.DistributionList?.Items ?? []) {
if (!dist.Id || !dist.ARN) continue;
const findings: string[] = [];
const cfg = await cloudfront.send(new GetDistributionConfigCommand({ Id: dist.Id }));
const legacy = cfg.DistributionConfig?.Logging;
let legacyText = "off";
if (legacy?.Enabled && legacy.Bucket) {
const bucket = bucketName(legacy.Bucket);
const ownership = await objectOwnership(bucket);
legacyText = `${bucket}/${legacy.Prefix ?? ""}`;
if (ownership === "BucketOwnerEnforced") findings.push("legacy bucket has ACLs disabled: no log delivery");
else if (ownership.startsWith("unknown")) findings.push(`legacy bucket ownership ${ownership}`);
}
const source = sourceByArn.get(dist.ARN);
const destinations = source ? destinationsBySource.get(source) ?? [] : [];
if (source && !destinations.length) findings.push("v2 source has no delivery");
const realTime = dist.DefaultCacheBehavior?.RealtimeLogConfigArn ? "default behavior" : "-";
if (legacyText === "off" && !destinations.length) {
findings.unshift(dist.Enabled ? "NO ACCESS LOGS" : "no access logs (distribution disabled)");
}
rows.push({
Distribution: dist.Id,
Domain: dist.Aliases?.Items?.[0] ?? dist.DomainName ?? "?",
Enabled: dist.Enabled ? "yes" : "no",
LegacyS3: legacyText,
V2Destinations: destinations.join(", ") || (source ? "source only" : "-"),
RealTime: realTime,
Finding: findings.join("; ") || "ok",
});
}
}
console.table(rows);
const none = rows.filter((r) => r.Finding.startsWith("NO ACCESS LOGS"));
console.log(
`${rows.length} distributions: ${none.length} enabled without access logs, ` +
`${rows.filter((r) => r.LegacyS3 !== "off").length} using standard logging (legacy), ` +
`${rows.filter((r) => r.V2Destinations !== "-" && r.V2Destinations !== "source only").length} using standard logging (v2).`,
);
console.log("Report only: nothing was changed.");
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-cloudfront @aws-sdk/client-cloudwatch-logs @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node
AWS_PROFILE=readonly npx tsx find-cloudfront-distributions-without-logging.ts
Sample output
┌─────────┬──────────────────┬─────────────────────────────────┬─────────┬──────────────────────────────┬───────────────────────┬────────────────────┬────────────────────────────────────────────────────┐
│ (index) │ Distribution │ Domain │ Enabled │ LegacyS3 │ V2Destinations │ RealTime │ Finding │
├─────────┼──────────────────┼─────────────────────────────────┼─────────┼──────────────────────────────┼───────────────────────┼────────────────────┼────────────────────────────────────────────────────┤
│ 0 │ 'E1A2B3C4D5E6F7' │ 'www.example.com' │ 'yes' │ 'amzn-s3-demo-logs/www/' │ '-' │ '-' │ 'ok' │
│ 1 │ 'E2B3C4D5E6F7G8' │ 'api.example.com' │ 'yes' │ 'amzn-s3-demo-new-logs/api/' │ '-' │ '-' │ 'legacy bucket has ACLs disabled: no log delivery' │
│ 2 │ 'E3C4D5E6F7G8H9' │ 'static.example.com' │ 'yes' │ 'off' │ 'CloudWatch Logs, S3' │ '-' │ 'ok' │
│ 3 │ 'E4D5E6F7G8H9I0' │ 'media.example.com' │ 'yes' │ 'off' │ 'source only' │ 'default behavior' │ 'NO ACCESS LOGS; v2 source has no delivery' │
│ 4 │ 'E5E6F7G8H9I0J1' │ 'e5e6f7g8h9i0j1.cloudfront.net' │ 'no' │ 'off' │ '-' │ '-' │ 'no access logs (distribution disabled)' │
└─────────┴──────────────────┴─────────────────────────────────┴─────────┴──────────────────────────────┴───────────────────────┴────────────────────┴────────────────────────────────────────────────────┘
5 distributions: 1 enabled without access logs, 2 using standard logging (legacy), 1 using standard logging (v2).
Report only: nothing was changed.
IDs and names are illustrative. api.example.com has legacy logging switched on, yet no logs arrive, because its bucket enforces bucket-owner ownership. media.example.com has a v2 source and real-time logs but no delivery, so it has no access logs. static.example.com sends v2 logs to both CloudWatch Logs and S3. The disabled distribution is listed but not counted.
To fix a flagged distribution, add a v2 delivery in the console’s Logging tab or through PutDeliveryDestination and CreateDelivery. Logs in S3 are easiest to query with Athena; the guide to running an Athena query with AWS SDK v3 shows the calls. While you’re in the distribution settings, the checks for the CloudFront minimum TLS version and for CloudFront distributions without AWS WAF cover the rest of the edge.
Troubleshooting
- “This ResourceId has already been used in another Delivery Source in this account”. CloudFront documents this error when you create a second delivery source for the same distribution. Add a delivery to the existing source instead, or delete it first.
- v2 logs to S3 stopped after you changed the suffix path. The bucket policy must allow the new path. The default is
AWSLogs/<account-id>/CloudFront. - Updating a distribution with legacy logging fails. CloudFront needs
s3:GetBucketAclands3:PutBucketAclon the log bucket, andFULL_CONTROLin its ACL, to grantawslogsdeliveryaccess. - The legacy bucket uses SSE-KMS. The customer managed key policy must let
delivery.logs.amazonaws.comcallkms:GenerateDataKey*, pluskms:Decryptwith an S3 Bucket Key. The AWS managed key won’t work. - Ownership shows
unknown. The bucket is in another account or your role can’t read it; check it from the bucket owner’s account.
Ask ChatWithCloud instead
For a quick check, ask ChatWithCloud “Which CloudFront distributions have standard logging turned off?” It writes AWS SDK for JavaScript v2 code and runs it on your machine with your profile. If the answer only covers the legacy Logging setting, ask a follow-up about CloudWatch Logs delivery sources in us-east-1 to cover v2. It can be wrong, so compare its answer with this script once. The overview of how ChatWithCloud runs AWS calls explains the loop, and the AWS practical examples hub has the other logging audits.
Frequently asked questions
Are CloudFront access logs enabled by default?
No. You turn on standard logging (legacy) in the distribution settings, or standard logging (v2) by adding a log delivery, for each distribution.
Can I use legacy and v2 CloudFront logging together?
Yes. Enabling v2 doesn’t change legacy logging. If both write to S3, use a different bucket or prefix so the files don’t overwrite each other.
Why is my CloudFront log bucket empty?
For legacy logging, check that the bucket still has ACLs enabled and grants awslogsdelivery access. Also note that CloudFront writes no log file for an hour with no requests.
Which Region do I use to configure CloudFront v2 logging?
US East (N. Virginia), us-east-1, for the CloudWatch Logs API calls, even when the destination is in another Region.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud