When CloudFront WAF is not enabled on a distribution, the WebACLId field in ListDistributions is empty. List your distributions from us-east-1, flag the empty ones, and for the rest look up the web ACL with WAFv2 GetWebACL in scope CLOUDFRONT, because a web ACL whose rules all run in count mode inspects traffic without blocking anything.
CloudFront is the front door of many AWS applications, and AWS WAF is the lock on it. A new distribution doesn’t get a web ACL unless someone chooses one, so it’s common to find a few that were created in a hurry and never protected. This example is for engineers who want to find every distribution where CloudFront WAF is not enabled, and also the quieter cases where a web ACL is attached but doesn’t block anything.
You’ll get a report-only TypeScript script for the AWS SDK for JavaScript v3. It reads every distribution, every CLOUDFRONT-scoped web ACL, and the tenants of multi-tenant distributions, and changes nothing. It complements the script to check the minimum TLS version of your CloudFront distributions, which covers the other half of an edge security review.
Why do CloudFront distributions end up without AWS WAF?
- It’s optional. On the pay-as-you-go pricing, attaching a web ACL is a choice at creation time or later. Infrastructure code copied from an older example often has no
WebACLIdat all. - It was AWS WAF Classic. Older distributions may point at a WAF Classic web ACL, which uses an ID instead of an ARN. AWS WAF Classic reached end of support on 30 September 2025, so these need migrating to the current AWS WAF.
- It was set to count. Teams often add managed rule groups in count mode to watch for false positives, then never switch them to block.
- A tenant opted out. In a multi-tenant distribution, each distribution tenant can override the web ACL or disable it.
Distributions on a CloudFront flat-rate pricing plan are the exception: the plan includes AWS WAF, and a web ACL must stay associated for as long as the distribution uses the plan.
What does AWS WAF cost on a CloudFront distribution?
On pay-as-you-go pricing, AWS WAF is billed on top of CloudFront. These are the standard charges as of September 2026 from the official AWS WAF pricing page:
| Charge | Price |
|---|---|
| Web ACL | $5.00 per month, prorated hourly |
| Rule or rule group in the web ACL | $1.00 per month each; a managed rule group counts as one |
| Requests inspected | $0.60 per million |
| Web ACL capacity above 1,500 WCUs | $0.20 per million requests for each extra 500 WCUs |
| Bot Control, Fraud Control | Extra subscription and request fees |
Worked example. One web ACL with three AWS managed rule groups and one rate-based rule, serving 10 million requests a month: $5.00 + (4 × $1.00) + (10 × $0.60) = $5.00 + $4.00 + $6.00 = $15.00 a month. Sharing one web ACL across several distributions with the same needs avoids paying the $5.00 and the rule fees again for each. To see what WAF already costs you, run the script to break down last month’s AWS bill by service.
What does the script check?
- Lists distributions
ListDistributions(paginated) from us-east-1, withWebACLId,Enabled, aliases andConnectionMode. - Lists web ACLsWAFv2
ListWebACLswithScope: "CLOUDFRONT". CloudFront web ACLs only exist in us-east-1, whatever Region you normally use. - Reads each attached web ACL once
GetWebACLreturns the rules. The script lists managed rule groups, counts rate-based rules, notes Firewall Manager rule groups and the default action, and flags a web ACL where every rule is in count mode. - Checks tenantsFor distributions with
ConnectionModetenant-only,ListDistributionTenantsshows tenants whoseCustomizations.WebAclaction isoverrideordisable. - GradesNO WAF, WAF CLASSIC, COUNT ONLY or PROTECTED; exit code 2 when an enabled distribution isn’t PROTECTED.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-cloudfrontand@aws-sdk/client-wafv2. - A profile for the account that owns the distributions; see how AWS SDK v3 credential providers load profiles and SSO sessions.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListDistributionsAndTenants",
"Effect": "Allow",
"Action": [
"cloudfront:ListDistributions",
"cloudfront:ListDistributionTenants",
"wafv2:ListWebACLs"
],
"Resource": "*"
},
{
"Sid": "ReadCloudFrontWebAcls",
"Effect": "Allow",
"Action": "wafv2:GetWebACL",
"Resource": "arn:aws:wafv2:us-east-1:*:global/webacl/*/*"
}
]
}
Every action is read-only. The IAM policy generator that reads TypeScript SDK code produces a similar draft from the script itself.
The script to find CloudFront distributions where WAF is not enabled
// find-cloudfront-distributions-without-waf.ts
// Lists every CloudFront distribution and reports whether an AWS WAF web ACL protects it, what the web ACL
// contains (managed rule groups, rate-based rules, count-only rules) and, for multi-tenant distributions,
// which tenants override or disable the web ACL. Report only: it changes nothing.
// Usage: npx tsx find-cloudfront-distributions-without-waf.ts
import {
CloudFrontClient,
paginateListDistributions,
paginateListDistributionTenants,
} from "@aws-sdk/client-cloudfront";
import { WAFV2Client, GetWebACLCommand, ListWebACLsCommand } from "@aws-sdk/client-wafv2";
import type { DistributionSummary } from "@aws-sdk/client-cloudfront";
import type { WebACL, WebACLSummary } from "@aws-sdk/client-wafv2";
// CloudFront is global; its API and CLOUDFRONT-scoped WAF resources live in us-east-1.
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const waf = new WAFV2Client({ region: "us-east-1" });
interface Row {
Id: string;
Domain: string;
Enabled: boolean;
Verdict: string;
WebACL: string;
Protection: string;
}
async function listDistributions(): Promise<DistributionSummary[]> {
const all: DistributionSummary[] = [];
for await (const page of paginateListDistributions({ client: cloudfront }, {})) {
all.push(...(page.DistributionList?.Items ?? []));
}
return all;
}
async function listWebAcls(): Promise<Map<string, WebACLSummary>> {
const byArn = new Map<string, WebACLSummary>();
let NextMarker: string | undefined;
do {
const out = await waf.send(new ListWebACLsCommand({ Scope: "CLOUDFRONT", Limit: 100, NextMarker }));
for (const acl of out.WebACLs ?? []) if (acl.ARN) byArn.set(acl.ARN, acl);
NextMarker = out.WebACLs?.length ? out.NextMarker : undefined;
} while (NextMarker);
return byArn;
}
// Summarizes what a web ACL actually does: managed groups, rate limits, and rules that only count.
function describeAcl(acl: WebACL): { text: string; countOnly: boolean } {
const rules = acl.Rules ?? [];
const managed: string[] = [];
let rateBased = 0;
let counting = 0;
for (const rule of rules) {
const m = rule.Statement?.ManagedRuleGroupStatement;
if (m) managed.push(`${m.Name}${rule.OverrideAction?.Count ? " (count)" : ""}`);
if (rule.Statement?.RateBasedStatement) rateBased++;
if (rule.OverrideAction?.Count || rule.Action?.Count) counting++;
}
const fms = (acl.PreProcessFirewallManagerRuleGroups?.length ?? 0) + (acl.PostProcessFirewallManagerRuleGroups?.length ?? 0);
const parts = [
`${rules.length} rules`,
managed.length ? `managed: ${managed.join(", ")}` : "no managed groups",
`${rateBased} rate-based`,
`default ${acl.DefaultAction?.Block ? "BLOCK" : "ALLOW"}`,
];
if (fms) parts.push(`${fms} Firewall Manager groups`);
return { text: parts.join("; "), countOnly: rules.length > 0 && counting === rules.length && fms === 0 };
}
async function tenantGaps(distributionId: string): Promise<string[]> {
const gaps: string[] = [];
for await (const page of paginateListDistributionTenants({ client: cloudfront }, { AssociationFilter: { DistributionId: distributionId } })) {
for (const t of page.DistributionTenantList ?? []) {
const custom = t.Customizations?.WebAcl;
if (custom?.Action === "disable") gaps.push(`${t.Name}: WAF disabled`);
else if (custom?.Action === "override") gaps.push(`${t.Name}: own web ACL`);
}
}
return gaps;
}
async function main(): Promise<void> {
const [distributions, acls] = await Promise.all([listDistributions(), listWebAcls()]);
const details = new Map<string, { name: string; text: string; countOnly: boolean }>();
const rows: Row[] = [];
for (const d of distributions) {
const webAclId = d.WebACLId ?? "";
let verdict = "NO WAF";
let aclName = "-";
let protection = "-";
if (webAclId && !webAclId.startsWith("arn:")) {
verdict = "WAF CLASSIC";
aclName = webAclId;
protection = "AWS WAF Classic web ACL; migrate it to AWS WAF";
} else if (webAclId) {
if (!details.has(webAclId)) {
const summary = acls.get(webAclId);
const out = summary?.Id && summary.Name
? await waf.send(new GetWebACLCommand({ Scope: "CLOUDFRONT", Id: summary.Id, Name: summary.Name })).catch(() => undefined)
: undefined;
const described = out?.WebACL ? describeAcl(out.WebACL) : { text: "web ACL not readable", countOnly: false };
details.set(webAclId, { name: summary?.Name ?? webAclId, ...described });
}
const info = details.get(webAclId)!;
verdict = info.countOnly ? "COUNT ONLY" : "PROTECTED";
aclName = info.name;
protection = info.text;
}
if (d.ConnectionMode === "tenant-only") {
const gaps = await tenantGaps(d.Id ?? "");
if (gaps.length) protection += ` | tenants: ${gaps.join(", ")}`;
}
rows.push({ Id: d.Id ?? "?", Domain: d.Aliases?.Items?.[0] ?? d.DomainName ?? "?", Enabled: d.Enabled ?? false, Verdict: verdict, WebACL: aclName, Protection: protection });
}
const order = ["NO WAF", "WAF CLASSIC", "COUNT ONLY", "PROTECTED"];
rows.sort((a, b) => order.indexOf(a.Verdict) - order.indexOf(b.Verdict));
console.table(rows);
const unprotected = rows.filter((r) => r.Enabled && r.Verdict !== "PROTECTED");
const unused = [...acls.values()].filter((a) => a.ARN && !distributions.some((d) => d.WebACLId === a.ARN));
console.log(`${unprotected.length} of ${rows.length} enabled distributions have no blocking AWS WAF web ACL.`);
if (unused.length) console.log(`CLOUDFRONT web ACLs not attached to any distribution: ${unused.map((a) => a.Name).join(", ")}`);
if (unprotected.length) 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-cloudfront @aws-sdk/client-wafv2
npm install --save-dev tsx typescript
AWS_PROFILE=security-audit npx tsx find-cloudfront-distributions-without-waf.ts
The Region in your profile doesn’t matter; the script pins both clients to us-east-1.
Sample output
┌─────────┬──────────────────┬──────────────────────┬─────────┬───────────────┬────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Id │ Domain │ Enabled │ Verdict │ WebACL │ Protection │
├─────────┼──────────────────┼──────────────────────┼─────────┼───────────────┼────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'E2QWRUHAPOMQZL' │ 'docs.example.com' │ true │ 'NO WAF' │ '-' │ '-' │
│ 1 │ 'E1KSDF8ZL3MNOP' │ 'legacy.example.com' │ true │ 'WAF CLASSIC' │ '473e64fd-f30b-4765-81a0-62ad96dd167a' │ 'AWS WAF Classic web ACL; migrate it to AWS WAF' │
│ 2 │ 'E3ABCD1234EFGH' │ 'shop.example.com' │ true │ 'COUNT ONLY' │ 'shop-edge-acl' │ '2 rules; managed: AWSManagedRulesCommonRuleSet (count), AWSManagedRulesKnownBadInputsRuleSet (count); 0 rate-based; default ALLOW' │
│ 3 │ 'E9ZYXW7654VUTS' │ 'www.example.com' │ true │ 'PROTECTED' │ 'web-edge-acl' │ '4 rules; managed: AWSManagedRulesCommonRuleSet, AWSManagedRulesKnownBadInputsRuleSet, AWSManagedRulesAmazonIpReputationList; 1 rate-based; default ALLOW' │
└─────────┴──────────────────┴──────────────────────┴─────────┴───────────────┴────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
3 of 4 enabled distributions have no blocking AWS WAF web ACL.
CLOUDFRONT web ACLs not attached to any distribution: old-marketing-acl
The IDs and names are illustrative. docs.example.com has no web ACL at all. legacy.example.com still points at AWS WAF Classic. shop.example.com has a web ACL, but both rule groups are in count mode, so it logs attacks and lets them through. The unattached old-marketing-acl still costs $5.00 a month plus its rules.
Which rules should a CloudFront web ACL start with?
For a distribution with no web ACL, a reasonable first version uses AWS managed rule groups plus one rate limit:
AWSManagedRulesCommonRuleSet, the core rule set, for common web exploits. AWS describes it as covering high-risk vulnerabilities such as those in the OWASP Top 10.AWSManagedRulesKnownBadInputsRuleSetfor request patterns known to be malicious, such as exploit payloads for widely published vulnerabilities.AWSManagedRulesAmazonIpReputationListto block IP addresses Amazon threat intelligence has marked as bad.- A rate-based rule that limits requests per IP address, set well above your real peak.
Start new rule groups in count mode for a few days, review the matches in the WAF logs or sampled requests, then switch them to block. The COUNT ONLY verdict exists to catch the last step being forgotten. The CloudFront console’s one-click protection creates a web ACL with a preconfigured set of protections if you prefer to start there.
WAF protects what CloudFront serves, not other ways into your origin. The scripts to find load balancers without HTTPS listeners, find API Gateway methods without authorization and find public Lambda function URLs check origins that can be reached directly.
Troubleshooting
- “web ACL not readable” in the Protection column. The ARN on the distribution wasn’t in the
ListWebACLsresult orGetWebACLfailed, usually because the profile’s WAFv2 permissions are narrower than the policy above. Runaws wafv2 get-web-acl --scope CLOUDFRONT --region us-east-1with the name and ID to see the error. - Distributions you expected are missing.
ListDistributionsonly returns distributions in the account of your profile. Run the script once per account. AccessDeniedonListDistributionTenants. Only matters if you use multi-tenant distributions; add the permission above.- The site stops loading after you switch to block. A managed rule is matching real traffic. Set that single rule to count with a rule action override and investigate; the guide to troubleshoot a Route 53 domain and CloudFront distribution that isn’t working helps rule out DNS and certificate causes first.
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 of my CloudFront distributions have no web ACL?” and it can call ListDistributions and read WebACLId. Set the session Region to us-east-1 for WAF questions, since CloudFront web ACLs live there. SDK v2 reached end of support on 8 September 2025, so newer features such as distribution tenants may not be available to it. Generated code runs without a confirmation step, so use ChatWithCloud with a read-only AWS profile and check the ChatWithCloud security model before you start.
Frequently asked questions
How do I check if AWS WAF is enabled on a CloudFront distribution?
Run aws cloudfront get-distribution-config --id <id> and read WebACLId. Empty means no web ACL; an ARN means AWS WAF; a plain ID means AWS WAF Classic.
Why can’t I see my CloudFront web ACL in the WAF console?
CloudFront web ACLs have the global CLOUDFRONT scope and are managed in us-east-1. In the API, call WAFv2 with Scope: "CLOUDFRONT" and the us-east-1 Region.
Can one web ACL protect several CloudFront distributions?
Yes. A standard distribution has one web ACL, but the same web ACL can be associated with many distributions, which keeps the monthly web ACL and rule fees down.
Does a CloudFront flat-rate plan include AWS WAF?
Yes. Flat-rate plans include AWS WAF and DDoS protection, and a web ACL must remain associated with the distribution while it’s on a plan.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud