To delete unused ACM certificates safely, list certificates of every key type and status in each Region, call DescribeCertificate to confirm InUseBy is empty, then delete the expired, failed and timed-out ones with DeleteCertificate. Review unused issued, imported and exported certificates by hand first, because they may be installed outside AWS.
Certificate Manager lists fill up quietly: a certificate requested for a demo, a validation that never finished, the wildcard that was replaced last year and left behind. Unused ACM certificates rarely cost money, but they count toward quotas, hide the certificates that matter, and trigger expiry alerts for domains nobody serves anymore.
This example is for engineers cleaning up ACM across Regions. It finds every certificate nothing uses, including the ones ListCertificates hides by default, explains why each one is unused, and deletes only the safe group when you pass --apply. For certificates that are in use and about to expire, the script to find expiring ACM certificates before renewal fails is the right tool.
When is it safe to delete unused ACM certificates?
ACM won’t let you delete a certificate that another AWS service is using: DeleteCertificate fails with ResourceInUseException, “The certificate is in use by another AWS service in the caller’s account. Remove the association and try again.” That protects load balancers, CloudFront distributions and API Gateway domains. It doesn’t protect copies of a certificate running outside AWS, which is why the script splits its findings in two.
| Certificate state | Script action | Why |
|---|---|---|
| Expired, revoked, failed, inactive, validation timed out | delete | Unusable as-is. A timed-out request has to be deleted and requested again anyway. |
| Pending validation | review | ACM keeps trying for 72 hours, then marks it timed out. Finish the DNS record or let it time out. |
| Issued by ACM (public or private), not exported, not attached | review (--include-issued deletes) |
Not in use, and not eligible for managed renewal, so it will simply expire. |
| Issued and exported | review | The certificate and key may be installed on servers outside AWS. |
| Imported | review | You brought it in, so the same certificate may be in use elsewhere; ACM never renews imported certificates. |
| In use, managed by another service, or issued through ACME | skipped | Attached to a resource, or deletion belongs to another service. ACM deletes ACME certificates itself 1 year after they expire. |
Does deleting unused certificates save money?
Mostly no. Public certificates for services integrated with ACM cost nothing. Exportable public certificates are charged when they’re issued and again when they renew, and private certificates from AWS Private CA are charged at issuance, so deleting them afterward doesn’t return anything. Deleting a private certificate also has no effect on the CA: you keep paying for the private CA until you delete it.
The real reasons are hygiene and quotas. By default an account can hold 2,500 ACM certificates per Region, and expired and revoked certificates still count toward that total. You can request up to twice the quota per year, so an account that keeps requesting without deleting eventually hits the limit. Keeping a complete certificate inventory is central to the NIST SP 1800-16 guide to TLS server certificate management: you can’t manage expiry or key changes for certificates you can’t account for. Certificates uploaded to IAM instead of ACM belong in that inventory too; the script to find expired and unused IAM server certificates lists them with the load balancers and distributions that still use them.
What does the script do?
- Picks RegionsThe Regions you pass in
--regions, plusus-east-1always, because CloudFront only uses certificates from US East (N. Virginia). - Lists everything
paginateListCertificateswith everyCertificateStatusand every key algorithm inIncludes.keyTypes. Without that filter,ListCertificatesreturns onlyRSA_2048certificates, so ECDSA and RSA 4096 certificates would be missed. - Confirms they’re unusedSkips summaries with
InUse, then callsDescribeCertificateand skips anything with a non-emptyInUseByor aManagedByvalue. - ClassifiesSorts each certificate into delete or review, with the reason, following the table above.
- Deletes, if asked
--applycallsDeleteCertificatefor the delete group;--include-issuedadds unused, non-exported certificates that ACM issued.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-acm. - A read-only profile for the report and a separate one for
--apply; the guide to AWS SDK v3 credential providers shows both. - For imported and exported certificates: a list of servers, appliances or CDNs outside AWS that might hold them.
Which IAM permissions does it need?
ListCertificates doesn’t support resource-level permissions. The delete statement is only needed with --apply.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListCertificates",
"Effect": "Allow",
"Action": "acm:ListCertificates",
"Resource": "*"
},
{
"Sid": "DescribeCertificates",
"Effect": "Allow",
"Action": "acm:DescribeCertificate",
"Resource": "arn:aws:acm:*:111122223333:certificate/*"
},
{
"Sid": "DeleteOnlyWithApply",
"Effect": "Allow",
"Action": "acm:DeleteCertificate",
"Resource": "arn:aws:acm:*:111122223333:certificate/*"
}
]
}
The free IAM policy generator for TypeScript produces the same list from the script if you change which calls it makes.
The script to find unused ACM certificates
// find-unused-acm-certificates.ts
// Lists ACM certificates of every key type and status in the given Regions (us-east-1 is always included,
// because CloudFront uses certificates from there), confirms with DescribeCertificate that nothing uses them,
// and sorts them into "delete" (expired, failed, timed out, revoked, inactive) and "review" (issued but unused).
// --apply deletes the "delete" group; add --include-issued to also delete unused issued certificates.
// Usage:
// npx tsx find-unused-acm-certificates.ts [--regions eu-west-1,us-east-1]
// npx tsx find-unused-acm-certificates.ts --regions eu-west-1 --apply [--include-issued]
import {
ACMClient,
CertificateStatus,
DeleteCertificateCommand,
DescribeCertificateCommand,
KeyAlgorithm,
paginateListCertificates,
type CertificateSummary,
} from "@aws-sdk/client-acm";
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 regions = [...new Set([
...(flag("--regions") ?? process.env.AWS_REGION ?? "").split(",").map((r) => r.trim()).filter(Boolean),
"us-east-1",
])];
const apply = args.includes("--apply");
const includeIssued = args.includes("--include-issued");
const DEAD = new Set<string>(["EXPIRED", "FAILED", "VALIDATION_TIMED_OUT", "REVOKED", "INACTIVE"]);
const DAY = 86_400_000;
interface Finding {
region: string;
arn: string;
Region: string;
Domain: string;
Type: string;
Status: string;
Expires: string;
Action: "delete" | "review";
Why: string;
}
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const day = (d?: Date): string => (d ? d.toISOString().slice(0, 10) : "-");
async function scanRegion(region: string): Promise<Finding[]> {
const acm = new ACMClient({ region });
const summaries: CertificateSummary[] = [];
const input = {
CertificateStatuses: Object.values(CertificateStatus),
Includes: { keyTypes: Object.values(KeyAlgorithm) }, // the default returns RSA_2048 only
};
for await (const page of paginateListCertificates({ client: acm }, input)) {
summaries.push(...(page.CertificateSummaryList ?? []));
}
const findings: Finding[] = [];
for (const s of summaries) {
if (!s.CertificateArn || s.InUse) continue;
const cert = (await acm.send(new DescribeCertificateCommand({ CertificateArn: s.CertificateArn }))).Certificate;
if (!cert || (cert.InUseBy ?? []).length > 0) continue; // attached to a load balancer, distribution, API...
if (s.ManagedBy) continue; // managed by another service (for example CloudFront); leave it alone
const status = cert.Status ?? "?";
const base = {
region, arn: s.CertificateArn, Region: region, Domain: cert.DomainName ?? "?",
Type: cert.Type ?? "?", Status: status, Expires: day(cert.NotAfter),
};
if (DEAD.has(status)) {
findings.push({ ...base, Action: "delete", Why: `not in use, ${status.toLowerCase().replace(/_/g, " ")}` });
} else if (status === "PENDING_VALIDATION") {
const age = cert.CreatedAt ? Math.floor((Date.now() - cert.CreatedAt.getTime()) / DAY) : 0;
findings.push({ ...base, Action: "review", Why: `pending validation for ${age} days` });
} else if (status === "ISSUED") {
const why = cert.Type === "IMPORTED"
? "imported, not attached to an AWS resource (may be used elsewhere)"
: s.Exported
? "exported: may be installed outside AWS"
: "issued, not attached: ACM won't renew it";
findings.push({ ...base, Action: "review", Why: why });
}
}
return findings;
}
async function main(): Promise<void> {
const all: Finding[] = [];
for (const region of regions) all.push(...(await scanRegion(region)));
console.table(all.map(({ region: _r, arn: _a, ...row }) => row));
const toDelete = all.filter((f) => f.Action === "delete" || (includeIssued && f.Why.startsWith("issued, not attached")));
console.log(`${all.length} unused certificates in ${regions.join(", ")}; ${toDelete.length} selected for deletion.`);
if (!apply) {
console.log("Report only: nothing was deleted. Add --apply to delete the 'delete' rows.");
return;
}
for (const f of toDelete) {
try {
await new ACMClient({ region: f.region }).send(new DeleteCertificateCommand({ CertificateArn: f.arn }));
console.log(`Deleted ${f.Domain} (${f.Status}) in ${f.region}`);
} catch (err) {
console.error(`Could not delete ${f.arn}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
ACM throttles DescribeCertificate at 10 requests per second and ListCertificates at 8. The script calls them one at a time, and the SDK’s default retry handles the occasional ThrottlingException; the guide to AWS SDK v3 retries and timeouts shows how to raise the attempt count for very large accounts.
How do you run it?
npm install @aws-sdk/client-acm
npm install --save-dev tsx typescript @types/node
# Report for eu-west-1 and us-east-1
AWS_PROFILE=readonly npx tsx find-unused-acm-certificates.ts --regions eu-west-1
# Delete expired, failed and timed-out certificates that nothing uses
AWS_PROFILE=certs-admin npx tsx find-unused-acm-certificates.ts --regions eu-west-1 --apply
Sample output
┌─────────┬─────────────┬─────────────────────────┬─────────────────┬────────────────────────┬──────────────┬──────────┬─────────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Domain │ Type │ Status │ Expires │ Action │ Why │
├─────────┼─────────────┼─────────────────────────┼─────────────────┼────────────────────────┼──────────────┼──────────┼─────────────────────────────────────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'old.example.com' │ 'AMAZON_ISSUED' │ 'EXPIRED' │ '2026-03-02' │ 'delete' │ 'not in use, expired' │
│ 1 │ 'eu-west-1' │ 'beta.example.com' │ 'AMAZON_ISSUED' │ 'ISSUED' │ '2027-02-11' │ 'review' │ "issued, not attached: ACM won't renew it" │
│ 2 │ 'eu-west-1' │ 'vpn.example.com' │ 'AMAZON_ISSUED' │ 'ISSUED' │ '2027-01-20' │ 'review' │ 'exported: may be installed outside AWS' │
│ 3 │ 'us-east-1' │ '*.staging.example.com' │ 'AMAZON_ISSUED' │ 'VALIDATION_TIMED_OUT' │ '-' │ 'delete' │ 'not in use, validation timed out' │
│ 4 │ 'us-east-1' │ 'legacy.example.com' │ 'IMPORTED' │ 'ISSUED' │ '2026-12-01' │ 'review' │ 'imported, not attached to an AWS resource (may be used elsewhere)' │
└─────────┴─────────────┴─────────────────────────┴─────────────────┴────────────────────────┴──────────────┴──────────┴─────────────────────────────────────────────────────────────────────┘
5 unused certificates in eu-west-1, us-east-1; 2 selected for deletion.
Deleted old.example.com (EXPIRED) in eu-west-1
Deleted *.staging.example.com (VALIDATION_TIMED_OUT) in us-east-1
Domains are illustrative. beta.example.com isn’t attached to anything and will expire on its own; delete it with --include-issued once you’re sure no deployment is about to use it. vpn.example.com was exported, so check the VPN appliance first. legacy.example.com is imported: find where the same certificate is installed before removing it from ACM.
What should you check before deleting the review group?
- Infrastructure as code. A stack may reference the ARN and fail on its next deploy. Search templates and state for the certificate ARN.
- Pending deployments. A certificate requested for a load balancer that isn’t built yet looks exactly like an abandoned one. The audit for load balancers without HTTPS listeners shows where a certificate might be wanted.
- DNS records. DNS validation leaves a CNAME in your zone. Remove it after deleting the certificate, and check the zone itself with the script to find unused Route 53 hosted zones.
- CloudFront. Distributions use certificates from
us-east-1, whatever Region you work in, and their TLS settings are worth a look at the same time: check the CloudFront minimum TLS version. - Ownership tags. If nobody knows who requested a certificate, tag the next ones; the script to find untagged AWS resources keeps that honest.
Troubleshooting
ResourceInUseExceptionon delete. Something attached the certificate between the scan and the delete, or the association is in another service’s console. Remove the association and run again.- A certificate you expected isn’t listed. Certificates issued through ACME are excluded by default and can’t be deleted anyway. Otherwise, check the Region: ACM certificates are Regional.
- Private CA charges continue. Deleting certificates doesn’t delete the CA. Delete the private CA itself when nothing depends on it.
AccessDeniedException. The profile lacks the statement for the call named in the message; see how to fix AWS IAM AccessDenied errors.
Ask ChatWithCloud instead
For a quick look, ask ChatWithCloud “List ACM certificates in us-east-1 and eu-west-1 that aren’t in use, with their status and expiry date.” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the results. Mention every key type in the question, since the API defaults to RSA 2048 only. Because ChatWithCloud runs the code it writes without asking first, use a read-only profile and delete with the script.
Frequently asked questions
How do I delete unused ACM certificates?
Confirm InUseBy is empty with DescribeCertificate, then call DeleteCertificate with the ARN. ACM refuses to delete certificates still associated with another AWS service.
Why doesn’t ListCertificates show all my certificates?
By default it returns only RSA_2048 certificates and excludes ACME-issued ones. Pass every key algorithm in Includes.keyTypes to see the rest.
Do expired ACM certificates count toward the quota?
Yes. Expired and revoked certificates still count toward the default 2,500 certificates per Region until you delete them.
Will ACM renew a certificate that isn’t in use?
Not an Amazon-issued one that is neither associated with a service nor exported. It isn’t eligible for managed renewal and will expire.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud