Photo by Sander Weeteling on Unsplash
Route 53 Resolver query logging is on for a VPC when a query logging configuration in the same Region has an ACTIVE association with that VPC. To check every VPC, compare DescribeVpcs with ListResolverQueryLogConfigAssociations per Region, then read each configuration’s destination and status. A VPC with no association sends DNS queries that nobody records.
DNS queries tell you what a workload tried to reach, including domains it should never contact. Route 53 Resolver answers those queries for every VPC, but it only records them when you set up query logging, one Region at a time.
This example is for engineers who own network visibility across accounts and Regions. The script reports, for every VPC, whether Route 53 Resolver query logging is associated, where the logs go, whether delivery has failed, and whether a DNS Firewall rule group is attached. With --apply it associates an existing configuration with the VPCs you name. It complements the audit that finds VPCs without flow logs: flow logs show connections, query logs show the names behind them.
What does Resolver query logging record?
A query logging configuration can log four kinds of DNS traffic:
- Queries that originate in the VPCs you associate, and the responses.
- Queries from on-premises resources that use an inbound Resolver endpoint.
- Queries that use an outbound Resolver endpoint for recursive resolution.
- Queries that hit Resolver DNS Firewall rules that block, allow or monitor domain lists.
Each entry includes the Region, VPC ID, source IP and instance ID, the query name and type, the response code and the answer data. Resolver logs only unique queries: a repeat that Resolver answers from its cache within the TTL isn’t logged. Keep that in mind when you count queries per host.
Why bother? NIST SP 800-81r3, the Secure DNS Deployment Guide (March 2026), says DNS logging should capture current and historical traffic to support forensics and incident response, and that queries for domains flagged as malicious by protective DNS should always be logged. On AWS, that protective DNS layer is DNS Firewall, which is why the script checks it alongside logging.
Where can the logs go, and what does that cost?
You choose one destination per configuration: a CloudWatch Logs log group, an S3 bucket or a Firehose stream. A VPC can deliver to only one destination of each type, so the most any VPC can have is three. For high query volumes AWS suggests S3, to avoid throttling on CloudWatch Logs writes.
Route 53 doesn’t charge for query logs, but the destination does, and AWS notes that CloudWatch vended logs charges apply even when logs go straight to S3. Rates from the AWS Price List for us-east-1, dated 22 September 2026, for the first 10 TB a month (see the Amazon CloudWatch pricing page for other Regions and tiers):
| Destination | Delivery or ingestion | Storage |
|---|---|---|
| CloudWatch Logs (Standard class) | $0.50 per GB | $0.03 per GB-month |
| S3 | $0.25 per GB | S3 storage rates |
| Firehose | $0.25 per GB | Firehose and target charges |
Worked example. VPCs that produce 40 GB of query logs a month cost 40 × $0.50 = $20.00 to ingest into CloudWatch Logs, plus storage, or 40 × $0.25 = $10.00 to deliver to S3, plus S3 storage. Measure a week of real volume on one busy VPC before you extrapolate. A retention period caps the storage side; the script to set CloudWatch Logs retention for every log group handles the log group, and the one to find S3 buckets without lifecycle rules handles the bucket.
What does the script do?
- Picks Regions
--regions, or--all-regionsto useDescribeRegions. Configurations and VPCs must be in the same Region, so each Region is checked on its own. - Reads configurations
ListResolverQueryLogConfigsgives each configuration’s name,DestinationArn,StatusandShareStatus. - Reads associations
ListResolverQueryLogConfigAssociationsmaps eachResourceId(a VPC ID) to its configurations, withStatusandError. - Checks DNS Firewall
ListFirewallRuleGroupAssociationscounts rule groups per VPC. - Joins with VPCs
DescribeVpcslists every VPC, so ones with no association show up asNO QUERY LOGGING. - Associates, if askedWith
--apply,AssociateResolverQueryLogConfigruns once per VPC in--vpcs.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-ec2and@aws-sdk/client-route53resolver. - For
--apply, a query logging configuration that already exists in the target Region, with a working destination. - Resolver delivers logs through the
AWSServiceRoleForRoute53Resolverservice-linked role, which it creates the first time you associate a VPC. The identity running--applymay need permission to create that role if it doesn’t exist yet.
Which IAM permissions does it need?
The Service Authorization Reference lists ec2:DescribeVpcs as a dependent action for the query logging list and associate calls, so it’s needed even though the script also calls it directly.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadVpcsAndResolverLogging",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"ec2:DescribeVpcs",
"route53resolver:ListResolverQueryLogConfigs",
"route53resolver:ListResolverQueryLogConfigAssociations",
"route53resolver:ListFirewallRuleGroupAssociations"
],
"Resource": "*"
},
{
"Sid": "AssociateOnlyWithApply",
"Effect": "Allow",
"Action": "route53resolver:AssociateResolverQueryLogConfig",
"Resource": "arn:aws:route53resolver:*:111122223333:resolver-query-log-config/*"
}
]
}
Drop the second statement for a report-only role. The guide to reviewing an IAM policy for least privilege explains how to narrow the configuration ARN once you know which one you’ll use.
The script to check Route 53 Resolver query logging
// check-route53-resolver-query-logging.ts
// For every VPC in the Regions you choose, shows whether a Route 53 Resolver query logging configuration is
// associated, where the logs go (CloudWatch Logs, S3 or Firehose), whether delivery has failed, and whether a
// DNS Firewall rule group is associated. --apply associates one existing configuration with the VPCs you name.
// Usage:
// npx tsx check-route53-resolver-query-logging.ts [--regions us-east-1,eu-west-1 | --all-regions]
// npx tsx check-route53-resolver-query-logging.ts --regions us-east-1 --apply \
// --config-id rqlc-0123456789abcdef --vpcs vpc-0a1b2c3d4e5f67890,vpc-0f9e8d7c6b5a43210
import { EC2Client, DescribeRegionsCommand, paginateDescribeVpcs } from "@aws-sdk/client-ec2";
import {
Route53ResolverClient,
AssociateResolverQueryLogConfigCommand,
paginateListResolverQueryLogConfigs,
paginateListResolverQueryLogConfigAssociations,
paginateListFirewallRuleGroupAssociations,
} from "@aws-sdk/client-route53resolver";
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): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const apply = args.includes("--apply");
const configId = flag("--config-id");
const applyVpcs = list(flag("--vpcs"));
interface Row {
Region: string;
Vpc: string;
Name: string;
QueryLogging: string;
Destinations: string;
DnsFirewall: number;
Finding: string;
}
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// The destination type is in the ARN: arn:aws:logs:..., arn:aws:s3:::..., arn:aws:firehose:...
function destinationType(arn: string | undefined): string {
const service = arn?.split(":")[2];
if (service === "logs") return "CloudWatch Logs";
if (service === "s3") return "S3";
if (service === "firehose") return "Firehose";
return service ?? "?";
}
async function regionsToScan(): Promise<string[]> {
if (args.includes("--all-regions")) {
const ec2 = new EC2Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const out = await ec2.send(new DescribeRegionsCommand({})); // only Regions enabled for the account
return (out.Regions ?? []).map((r) => r.RegionName).filter((r): r is string => !!r).sort();
}
const chosen = list(flag("--regions"));
return chosen.length ? chosen : [process.env.AWS_REGION ?? "us-east-1"];
}
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
const resolver = new Route53ResolverClient({ region });
const configs = new Map<string, { name: string; dest: string; status: string; share: string }>();
for await (const page of paginateListResolverQueryLogConfigs({ client: resolver }, {})) {
for (const c of page.ResolverQueryLogConfigs ?? []) {
if (!c.Id) continue;
configs.set(c.Id, {
name: c.Name ?? c.Id,
dest: destinationType(c.DestinationArn),
status: c.Status ?? "?",
share: c.ShareStatus ?? "NOT_SHARED",
});
}
}
const byVpc = new Map<string, { configId: string; status: string; error?: string }[]>();
for await (const page of paginateListResolverQueryLogConfigAssociations({ client: resolver }, {})) {
for (const a of page.ResolverQueryLogConfigAssociations ?? []) {
if (!a.ResourceId || !a.ResolverQueryLogConfigId) continue;
const entry = { configId: a.ResolverQueryLogConfigId, status: a.Status ?? "?", error: a.Error ?? undefined };
byVpc.set(a.ResourceId, [...(byVpc.get(a.ResourceId) ?? []), entry]);
}
}
const firewallCount = new Map<string, number>();
for await (const page of paginateListFirewallRuleGroupAssociations({ client: resolver }, {})) {
for (const f of page.FirewallRuleGroupAssociations ?? []) {
if (f.VpcId) firewallCount.set(f.VpcId, (firewallCount.get(f.VpcId) ?? 0) + 1);
}
}
const rows: Row[] = [];
for await (const page of paginateDescribeVpcs({ client: ec2 }, {})) {
for (const vpc of page.Vpcs ?? []) {
if (!vpc.VpcId) continue;
const assocs = byVpc.get(vpc.VpcId) ?? [];
const findings: string[] = [];
const working = assocs.filter((a) => a.status === "ACTIVE" || a.status === "CREATING");
if (!assocs.length) findings.push("NO QUERY LOGGING");
for (const a of assocs) {
const cfg = configs.get(a.configId);
if (a.status === "FAILED" || a.status === "ACTION_NEEDED") findings.push(`association ${a.status}${a.error && a.error !== "NONE" ? `: ${a.error}` : ""}`);
if (cfg?.status === "FAILED") findings.push(`config ${cfg.name} FAILED to deliver`);
}
if (assocs.length && !working.length) findings.push("not logging");
rows.push({
Region: region,
Vpc: vpc.VpcId,
Name: vpc.Tags?.find((t) => t.Key === "Name")?.Value ?? (vpc.IsDefault ? "(default)" : "-"),
QueryLogging: assocs.map((a) => `${configs.get(a.configId)?.name ?? a.configId} (${a.status})`).join(", ") || "-",
Destinations: [...new Set(assocs.map((a) => configs.get(a.configId)?.dest ?? "shared/unknown"))].join(", ") || "-",
DnsFirewall: firewallCount.get(vpc.VpcId) ?? 0,
Finding: findings.join("; ") || "ok",
});
}
}
return rows;
}
async function main(): Promise<void> {
const regions = await regionsToScan();
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${errText(err)}`);
process.exitCode = 1;
}
}
console.table(rows);
const missing = rows.filter((r) => r.Finding.includes("NO QUERY LOGGING"));
const broken = rows.filter((r) => /FAILED|ACTION_NEEDED|not logging/.test(r.Finding));
console.log(
`${rows.length} VPCs in ${regions.length} Region(s): ${missing.length} without Resolver query logging, ` +
`${broken.length} with a failed or inactive association, ${rows.filter((r) => r.DnsFirewall === 0).length} without DNS Firewall.`,
);
if (!apply) {
console.log("Report only: nothing was changed. Use --apply --config-id <rqlc-...> --vpcs <a,b> with one --regions value.");
return;
}
if (!configId || !applyVpcs.length || regions.length !== 1) {
console.error("--apply needs --config-id, --vpcs and exactly one Region in --regions");
process.exit(1);
}
const resolver = new Route53ResolverClient({ region: regions[0] });
for (const vpcId of applyVpcs) {
if (!rows.some((r) => r.Vpc === vpcId)) {
console.error(`Skipping ${vpcId}: not found in ${regions[0]}`);
continue;
}
try {
const out = await resolver.send(
new AssociateResolverQueryLogConfigCommand({ ResolverQueryLogConfigId: configId, ResourceId: vpcId }),
);
console.log(`Associated ${vpcId} with ${configId}: ${out.ResolverQueryLogConfigAssociation?.Status ?? "?"}`);
} catch (err) {
console.error(`Could not associate ${vpcId}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-route53resolver
npm install --save-dev tsx typescript @types/node
# Report on every enabled Region
AWS_PROFILE=readonly npx tsx check-route53-resolver-query-logging.ts --all-regions
# Associate an existing configuration with two VPCs in us-east-1
AWS_PROFILE=network-admin npx tsx check-route53-resolver-query-logging.ts --regions us-east-1 --apply \
--config-id rqlc-0123456789abcdef --vpcs vpc-0a1b2c3d4e5f67890,vpc-0f9e8d7c6b5a43210
Sample output
┌─────────┬─────────────┬─────────────────────────┬─────────────┬───────────────────────┬───────────────────┬─────────────┬──────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Vpc │ Name │ QueryLogging │ Destinations │ DnsFirewall │ Finding │
├─────────┼─────────────┼─────────────────────────┼─────────────┼───────────────────────┼───────────────────┼─────────────┼──────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'vpc-0a1b2c3d4e5f67890' │ 'prod' │ 'dns-to-cwl (ACTIVE)' │ 'CloudWatch Logs' │ 1 │ 'ok' │
│ 1 │ 'us-east-1' │ 'vpc-0f9e8d7c6b5a43210' │ 'staging' │ 'dns-to-s3 (FAILED)' │ 'S3' │ 0 │ 'association FAILED: ACCESS_DENIED; config dns-to-s3 FAILED to deliver; not logging' │
│ 2 │ 'us-east-1' │ 'vpc-0123456789abcdef0' │ '(default)' │ '-' │ '-' │ 0 │ 'NO QUERY LOGGING' │
└─────────┴─────────────┴─────────────────────────┴─────────────┴───────────────────────┴───────────────────┴─────────────┴──────────────────────────────────────────────────────────────────────────────────────┘
3 VPCs in 1 Region(s): 1 without Resolver query logging, 1 with a failed or inactive association, 2 without DNS Firewall.
Report only: nothing was changed. Use --apply --config-id <rqlc-...> --vpcs <a,b> with one --regions value.
IDs and names are illustrative. prod is fine: an active association to CloudWatch Logs and one DNS Firewall rule group. staging looks covered in the console but isn’t logging: its association failed with ACCESS_DENIED, which the API reference describes as permissions that don’t allow sending logs to the destination, for example a bucket policy that no longer allows log delivery. The default VPC has nothing at all. If nothing runs there, the example to find and delete default VPCs removes it instead of paying to log it.
Once logs arrive in CloudWatch Logs, the guide to running CloudWatch Logs Insights queries with SDK v3 helps you search them for unexpected domains. Query logs name every host your workloads look up, so treat that log group as sensitive; the script to find CloudWatch log groups without KMS encryption checks whether it’s protected by a key you control.
Troubleshooting
- A VPC shows
NO QUERY LOGGINGbut you know it’s logged. Query logging can also be applied through a Route 53 Profile, which covers every VPC associated with the Profile. Those VPCs have no direct association, so this script can’t see them; check your Profiles before you add a second configuration. FAILEDwithDESTINATION_NOT_FOUND. The bucket, log group or stream was deleted. Create a new configuration with a live destination and move the VPCs to it.AccessDeniedExceptionon associate. Besides missing IAM permissions, the API reference says this error is also returned when the CloudWatch Logs resource policy has hit its 5,120-character limit. The guide to troubleshooting AWS IAM access denied errors covers the IAM side.LimitExceededException. The defaults are 20 configurations per Region and 100 VPC associations per Region across all configurations. Adding configurations doesn’t add association capacity; request a quota increase.- Configuration shows
SHARED_WITH_ME. It was shared through AWS RAM from another account, often a central logging account. That’s the pattern AWS recommends for a central bucket, so don’t replace it.
Ask ChatWithCloud instead
To check without a script, ask ChatWithCloud “Which VPCs in us-east-1 have no Route 53 Resolver query logging configuration?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and answers from the result. It uses one profile and Region per session, so the multi-Region sweep above is easier as a script. The guide to analyzing your AWS security posture with an AI CLI has more questions like this, and the ChatWithCloud security page explains why a read-only profile is the right choice.
Frequently asked questions
Is Route 53 Resolver query logging enabled by default?
No. You create a query logging configuration in each Region and associate it with each VPC, directly or through a Route 53 Profile.
Does Route 53 charge for Resolver query logs?
No. You pay for the destination: CloudWatch vended logs charges apply, including when logs go directly to S3, plus storage at the destination.
Why are some DNS queries missing from the logs?
Resolver logs only unique queries. Repeats answered from its cache within the record’s TTL aren’t logged.
Is Resolver query logging the same as public hosted zone query logging?
No. Resolver query logging covers queries made from your VPCs. Query logging for a public hosted zone records queries that resolvers on the internet send to your authoritative zone.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud