
Photo by Pavel Danilyuk on Pexels
When Route 53 and CloudFront are not working together, check the chain in order: the registrar delegates to your hosted zone’s name servers, the record is an alias to the right *.cloudfront.net domain, the distribution lists your domain as an alternate domain name and is deployed, its ACM certificate is in us-east-1 and covers the name, and the origin isn’t returning 403.
A custom domain in front of CloudFront has five links that each fail in their own way: NXDOMAIN or an old IP means DNS, a certificate warning means the ACM certificate or the alternate domain names, and a 403 page means CloudFront or the origin refused the request. Guessing which link broke wastes an afternoon.
This example gives you a read-only TypeScript script for the AWS SDK for JavaScript v3 that checks every link from registrar to origin and prints PASS or FAIL for each, followed by the fix for every failure. If you haven’t created the records yet, start with the sibling example to point www to CloudFront with a Route 53 alias record. Both belong to the collection of AWS SDK v3 examples for operations work.
What does the script check?
- NS delegationIt finds the most specific public hosted zone for the domain, reads its four name servers with
GetHostedZone, and compares them with the NS records public DNS returns. A mismatch means the registrar points somewhere else, and nothing in the hosted zone is visible to the internet. - The record
ListResourceRecordSetsfinds the A, AAAA or CNAME record for the name. An alias passes when its target zone isZ2FDTNDATAQYW2and its DNS name ends in.cloudfront.net. It also prints what public resolvers return for the name. - The distribution
paginateListDistributionsfinds the distribution with that domain name and checks that it’s enabled,Deployedrather thanInProgress, and lists your domain among its alternate domain names. - The certificateFor the distribution’s ACM certificate it checks the region (us-east-1), the status (
ISSUED), the expiry date, and that one of its names covers yours, including one-level wildcards. - A real requestFinally it fetches
https://your-domain/without following redirects and prints the status code,x-cacheandserverheaders, or the TLS error if the handshake fails.
Prerequisites
- Node.js 18 or later (for the built-in
fetchandnode:dns/promises), npm andtsx. - The
@aws-sdk/client-route-53,@aws-sdk/client-cloudfrontand@aws-sdk/client-acmpackages. - An AWS profile in the account that owns the hosted zone and the distribution. If they live in different accounts, run the relevant steps with each profile.
Which IAM permissions does it need?
Read-only actions only. route53:ListHostedZonesByName and cloudfront:ListDistributions don’t support resource-level permissions; the others can be scoped.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListZonesAndDistributions",
"Effect": "Allow",
"Action": [
"route53:ListHostedZonesByName",
"cloudfront:ListDistributions"
],
"Resource": "*"
},
{
"Sid": "ReadZones",
"Effect": "Allow",
"Action": [
"route53:GetHostedZone",
"route53:ListResourceRecordSets"
],
"Resource": "arn:aws:route53:::hostedzone/*"
},
{
"Sid": "ReadCloudFrontCertificates",
"Effect": "Allow",
"Action": "acm:DescribeCertificate",
"Resource": "arn:aws:acm:us-east-1:123456789012:certificate/*"
}
]
}
The IAM policy generator for TypeScript code drafts a matching policy if you add checks of your own.
The script: why is Route 53 with CloudFront not working?
// diagnose-route53-cloudfront.ts
// Walks the chain from registrar to origin for a domain served by CloudFront and prints
// PASS/FAIL for each link: NS delegation, the Route 53 record, the distribution's alternate
// domain names and status, the ACM certificate, and a live HTTPS request. Read-only.
// Usage: npx tsx diagnose-route53-cloudfront.ts www.example.com
import { Resolver } from "node:dns/promises";
import {
Route53Client,
ListHostedZonesByNameCommand,
GetHostedZoneCommand,
ListResourceRecordSetsCommand,
type HostedZone,
} from "@aws-sdk/client-route-53";
import { CloudFrontClient, paginateListDistributions, type DistributionSummary } from "@aws-sdk/client-cloudfront";
import { ACMClient, DescribeCertificateCommand } from "@aws-sdk/client-acm";
const domain = process.argv[2]?.toLowerCase().replace(/\.$/, "");
if (!domain) throw new Error("Usage: diagnose-route53-cloudfront.ts <domain>");
const CLOUDFRONT_ZONE_ID = "Z2FDTNDATAQYW2";
const route53 = new Route53Client({ region: "us-east-1" });
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const acm = new ACMClient({ region: "us-east-1" });
const resolver = new Resolver();
resolver.setServers(["1.1.1.1", "8.8.8.8"]); // public resolvers, not your office or VPN DNS
const log = (ok: boolean, step: string, detail: string) => console.log(`${ok ? "PASS" : "FAIL"} ${step.padEnd(26)} ${detail}`);
const strip = (name: string) => name.toLowerCase().replace(/\.$/, "");
const covers = (pattern: string, name: string) =>
pattern === name || (pattern.startsWith("*.") && name.split(".").slice(1).join(".") === pattern.slice(2));
// The most specific public hosted zone that contains the domain.
async function findZone(): Promise<HostedZone | undefined> {
const labels = domain.split(".");
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join(".");
const res = await route53.send(new ListHostedZonesByNameCommand({ DNSName: candidate, MaxItems: 5 }));
const zone = res.HostedZones?.find((z) => strip(z.Name ?? "") === candidate && !z.Config?.PrivateZone);
if (zone) return zone;
}
return undefined;
}
async function main(): Promise<void> {
console.log(`Diagnosing ${domain}\n`);
// 1. Delegation: the registrar's NS records must match the hosted zone's name servers.
const zone = await findZone();
if (!zone?.Id) return log(false, "Hosted zone", `no public hosted zone for ${domain} in this account`);
const zoneName = strip(zone.Name!);
const zoneId = zone.Id.replace("/hostedzone/", "");
const { DelegationSet } = await route53.send(new GetHostedZoneCommand({ Id: zoneId }));
const expected = (DelegationSet?.NameServers ?? []).map(strip).sort();
const actual = (await resolver.resolveNs(zoneName).catch(() => [] as string[])).map(strip).sort();
log(expected.join() === actual.join(), "NS delegation", `zone ${zoneId} expects [${expected.join(", ")}], public DNS has [${actual.join(", ") || "nothing"}]`);
// 2. The record: an alias A/AAAA to CloudFront (or a CNAME, which can't be used at the apex).
const rr = await route53.send(new ListResourceRecordSetsCommand({ HostedZoneId: zoneId, StartRecordName: domain, MaxItems: 10 }));
const records = (rr.ResourceRecordSets ?? []).filter((r) => strip(r.Name ?? "") === domain && ["A", "AAAA", "CNAME"].includes(r.Type ?? ""));
if (records.length === 0) return log(false, "Route 53 record", `no A, AAAA or CNAME record named ${domain}`);
let target = "";
for (const r of records) {
if (r.AliasTarget) {
const ok = r.AliasTarget.HostedZoneId === CLOUDFRONT_ZONE_ID && strip(r.AliasTarget.DNSName ?? "").endsWith(".cloudfront.net");
log(ok, `Record ${r.Type}`, `alias -> ${r.AliasTarget.DNSName} (zone ${r.AliasTarget.HostedZoneId})`);
if (ok) target = strip(r.AliasTarget.DNSName!);
} else {
const values = (r.ResourceRecords ?? []).map((v) => strip(v.Value ?? ""));
const ok = r.Type === "CNAME" && values.some((v) => v.endsWith(".cloudfront.net"));
log(ok, `Record ${r.Type}`, `${values.join(", ")} (TTL ${r.TTL}s)`);
if (ok) target = values[0];
}
}
const resolved = await resolver.resolve4(domain).catch((e: NodeJS.ErrnoException) => [`error ${e.code}`]);
console.log(` public DNS answer for ${domain}: ${resolved.join(", ")}`);
if (!target) return log(false, "CloudFront target", "no record points at a *.cloudfront.net domain");
// 3. The distribution the record points to.
let dist: DistributionSummary | undefined;
for await (const page of paginateListDistributions({ client: cloudfront }, {})) {
dist = page.DistributionList?.Items?.find((d) => d.DomainName?.toLowerCase() === target) ?? dist;
}
if (!dist) return log(false, "Distribution", `${target} isn't a distribution in this account`);
log(dist.Enabled === true, "Distribution enabled", `${dist.Id} enabled=${dist.Enabled}`);
log(dist.Status === "Deployed", "Distribution status", `${dist.Status}`);
const aliases = (dist.Aliases?.Items ?? []).map((a) => a.toLowerCase());
log(aliases.includes(domain), "Alternate domain names", `[${aliases.join(", ")}]`);
// 4. The certificate: ACM, us-east-1, issued, covering this exact name.
const certArn = dist.ViewerCertificate?.ACMCertificateArn;
if (!certArn) {
log(false, "Certificate", "no ACM certificate; the default *.cloudfront.net cert can't serve a custom domain");
} else if (!certArn.startsWith("arn:aws:acm:us-east-1:")) {
log(false, "Certificate region", `${certArn} is not in us-east-1`);
} else {
const { Certificate: cert } = await acm.send(new DescribeCertificateCommand({ CertificateArn: certArn }));
const names = (cert?.SubjectAlternativeNames ?? []).map((n) => n.toLowerCase());
const valid = cert?.Status === "ISSUED" && (cert.NotAfter ?? new Date(0)) > new Date();
log(valid, "Certificate status", `${cert?.Status}, expires ${cert?.NotAfter?.toISOString()}`);
log(names.some((n) => covers(n, domain)), "Certificate covers name", `[${names.join(", ")}]`);
}
// 5. A real request through CloudFront.
try {
const res = await fetch(`https://${domain}/`, { redirect: "manual" });
const detail = `${res.status} ${res.statusText}, x-cache: ${res.headers.get("x-cache") ?? "-"}, server: ${res.headers.get("server") ?? "-"}`;
log(res.status < 400, "HTTPS request", detail + (res.status >= 300 && res.status < 400 ? ` -> ${res.headers.get("location")}` : ""));
} catch (err) {
const cause = (err as { cause?: { code?: string; message?: string } }).cause;
log(false, "HTTPS request", `${cause?.code ?? ""} ${cause?.message ?? (err as Error).message}`.trim());
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The script queries 1.1.1.1 and 8.8.8.8 directly, so a stale answer cached by your office network or VPN resolver doesn’t hide a fixed problem, or a real one.
How do you run it?
npm install @aws-sdk/client-route-53 @aws-sdk/client-cloudfront @aws-sdk/client-acm
npm install --save-dev tsx typescript
AWS_PROFILE=readonly npx tsx diagnose-route53-cloudfront.ts www.example.com
AWS_PROFILE=readonly npx tsx diagnose-route53-cloudfront.ts example.com
Sample output
Diagnosing www.example.com
PASS NS delegation zone Z0123456789EXAMPLE expects [ns-1234.awsdns-26.org, ns-2012.awsdns-59.co.uk, ns-345.awsdns-43.com, ns-678.awsdns-20.net], public DNS has [ns-1234.awsdns-26.org, ns-2012.awsdns-59.co.uk, ns-345.awsdns-43.com, ns-678.awsdns-20.net]
PASS Record A alias -> d111111abcdef8.cloudfront.net. (zone Z2FDTNDATAQYW2)
public DNS answer for www.example.com: 18.160.10.21, 18.160.10.87, 18.160.10.112, 18.160.10.45
PASS Distribution enabled E1ABCDEF2GHIJK enabled=true
PASS Distribution status Deployed
FAIL Alternate domain names [example.com]
PASS Certificate status ISSUED, expires 2027-08-14T23:59:59.000Z
FAIL Certificate covers name [example.com]
FAIL HTTPS request ERR_TLS_CERT_ALTNAME_INVALID Hostname/IP does not match certificate's altnames: Host: www.example.com. is not in the cert's altnames: DNS:*.cloudfront.net
IDs, addresses and dates are illustrative. DNS is fine here; the distribution was set up for the apex only. The fix is a certificate that covers www.example.com (or *.example.com), then adding www.example.com to the alternate domain names.
How do you fix each failing check?
NS delegation
Copy the four name servers from the hosted zone into the domain’s name server settings at the registrar. This mismatch is common after deleting and recreating a hosted zone, because the new zone gets a different set of name servers; for domains registered through Route 53, update them under Registered domains. The TLD’s NS records have their own TTL, often a day or two, so changes can take that long to reach every resolver. A replaced zone keeps billing until it’s deleted; the script to find unused Route 53 hosted zones shows which zone the domain really delegates to.
Alias target
The alias must use hosted zone ID Z2FDTNDATAQYW2 and the exact *.cloudfront.net name of the distribution you expect. After recreating a distribution the domain name changes, and an old alias quietly points at nothing you own. Also check for a private hosted zone with the same name if the failure only happens inside a VPC.
Alternate domain names and distribution status
Every name visitors use must be listed on the distribution, or CloudFront answers with a 403 “The request could not be satisfied” page. CloudFront won’t let you add a name the certificate doesn’t cover, and a CNAMEAlreadyExists error means another distribution, possibly in another account, already claims it. After any change the distribution shows InProgress for a few minutes before Deployed, and a disabled distribution doesn’t serve requests at all.
ACM certificate region and coverage
CloudFront reads certificates only from us-east-1. The certificate must be ISSUED, which needs its DNS validation CNAME to stay in the hosted zone so renewals keep working, and must cover every alternate domain name. A wildcard *.example.com covers www.example.com but not example.com or a.b.example.com. When the name the browser sends in the TLS handshake (SNI) isn’t on any certificate for the distribution, the browser gets CloudFront’s default *.cloudfront.net certificate and shows a name-mismatch warning. The script to find expiring ACM certificates and missing validation CNAMEs checks every certificate in the account for that renewal risk. Once the certificate is right, check the CloudFront minimum TLS version so the distribution doesn’t still accept TLS 1.0 or 1.1.
TTLs and caching
Alias records don’t have a TTL you set; Route 53 uses the target’s. Negative answers are cached too: if a resolver asked for the name before the record existed, it caches the NXDOMAIN for the negative-caching TTL in the zone’s SOA record, which is 86,400 seconds (one day) in Route 53’s default SOA. Test with the public resolvers the script uses, and remember CloudFront also caches your content, so an old page after a fix may need an invalidation, not a DNS change.
A 403 from CloudFront or the origin
A 403 Forbidden response, as MDN describes it, means the server understood the request and refused it. Look at the body and x-cache header to see who refused. CloudFront’s own page means a missing alternate domain name, a geographic restriction or an AWS WAF rule. An S3 AccessDenied XML body means the origin refused: the bucket policy doesn’t allow the distribution’s origin access control, no default root object is set for /, or the object doesn’t exist, which S3 reports as 403 when the caller lacks s3:ListBucket. Check the key exists; the example to upload a file to S3 with S3Client in TypeScript shows how deploys should write objects. S3 website endpoints don’t support origin access control, so use the bucket’s REST endpoint for a private bucket. For permission problems on your own side, the guide to troubleshoot AWS IAM access denied errors step by step applies to bucket policies as well.
Ask ChatWithCloud instead
Ask the same questions in plain English: “Does the Route 53 record for www.example.com point at a CloudFront distribution that lists www.example.com and has a valid us-east-1 certificate for it?” ChatWithCloud writes AWS SDK for JavaScript v2 code, runs it with your profile on your machine, and explains what it found; the guide to troubleshoot AWS infrastructure with an AI CLI shows how follow-up questions narrow things down. It uses one profile and region per session and can’t query public DNS or your registrar, so the NS and TLS checks above still need the script. Because generated code runs without a confirmation step, connect ChatWithCloud to a read-only AWS profile for this kind of work; ChatWithCloud’s security and data handling covers what’s sent for processing, and the guide to list AWS resources with natural language helps you inventory every distribution and zone first.
Frequently asked questions
Why does my CloudFront custom domain return 403?
Either CloudFront refused it (the domain isn’t an alternate domain name, a geo restriction, or AWS WAF) or the origin did (an S3 bucket policy without origin access control, no default root object, or a missing object). The response body and x-cache header tell you which.
Why is my Route 53 domain not resolving at all?
Usually the registrar still points at old name servers, or the hosted zone was recreated and got new ones. Compare the zone’s NS record with what public DNS returns for the domain.
How long do Route 53 changes take with CloudFront?
Record changes reach Route 53’s name servers within about a minute (INSYNC). Name server changes at the registrar, cached negative answers and distribution deployments each add their own delay.
Can I use an ACM certificate from eu-west-1 with CloudFront?
No. CloudFront only uses ACM certificates from us-east-1. Request or import a certificate there, even if your origin runs 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
