
Photo by Miguel Á. Padriñán on Pexels
To find IAM server certificates that are expired, call ListServerCertificates and compare each certificate’s Expiration with today. Before deleting one, check that no ALB, NLB or Classic Load Balancer listener and no CloudFront distribution still references it. AWS warns that a load balancer may keep using a deleted certificate it hasn’t detected, which can make it stop accepting traffic.
Before AWS Certificate Manager existed, the way to put HTTPS on a load balancer or CloudFront was to upload a certificate to IAM. Many accounts still have a few, and they don’t renew. You can’t manage them from the IAM console either, so they’re easy to forget until one expires on a live listener.
This example is for engineers cleaning up old TLS setups. The script lists every certificate stored in IAM, marks the IAM server certificates expired or close to expiry, and finds every listener and distribution that still uses each one. With --apply it deletes only certificates that are both expired and unreferenced.
Why do expired IAM server certificates matter?
An expired certificate on a live listener means browsers reject the connection. An expired certificate that nothing uses is a different problem: it’s clutter that someone can attach to a resource by mistake. The CIS Amazon Web Services Foundations Benchmark asks you to remove expired SSL/TLS certificates stored in IAM, and Security Hub checks it as control IAM.26, mapped to CIS v3.0.0 recommendation 1.19 and v5.0.0 recommendation 1.18.
AWS’s current advice is to use ACM wherever ACM is available and to keep IAM as the certificate store only for Regions ACM doesn’t support. ACM certificates are free and renew automatically; IAM ones don’t. Every certificate this script finds in use is also a migration candidate.
Warning: The DeleteServerCertificate reference says that if Elastic Load Balancing doesn’t detect the deletion of a bound certificate, it may keep using it and stop accepting traffic. Always remove the certificate from the load balancer first. That’s why the script refuses to delete anything still referenced.
What does the script do?
- Lists certificates
paginateListServerCertificatesreturns name, path, ID, ARN,UploadDateandExpiration. IAM is global, so one call covers the account. - Scans load balancers in every RegionFor ALBs and NLBs it reads every HTTPS and TLS listener with
DescribeListenerCertificates, which returns the default certificate and all extra SNI certificates. Classic Load Balancers carry the ARN inSSLCertificateId. - Scans CloudFront
paginateListDistributionsreturns each distribution’sViewerCertificate.IAMCertificateId, which matches the certificate’s ID, not its ARN. - ClassifiesExpired and unused, expired but still in use, expiring within
--warn-days, valid but unused, or valid. - Deletes, if askedWith
--apply,DeleteServerCertificateruns only for expired, unreferenced certificates, and not at all if any Region failed to scan.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-iam,client-ec2,client-elastic-load-balancing-v2,client-elastic-load-balancingandclient-cloudfront. The guide to AWS SDK v3 paginators explains thepaginate*helpers used throughout. - Region coverage: without
--regionsthe script scans every Region enabled in the account, because a certificate uploaded once can be used by load balancers anywhere. - Know your other consumers. The script checks load balancers and CloudFront, the usual users of IAM certificates. If anything else in your stack points at one, check it by hand.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadCertificatesAndConsumers",
"Effect": "Allow",
"Action": [
"iam:ListServerCertificates",
"ec2:DescribeRegions",
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeListenerCertificates",
"cloudfront:ListDistributions"
],
"Resource": "*"
},
{
"Sid": "DeleteOnlyWithApply",
"Effect": "Allow",
"Action": "iam:DeleteServerCertificate",
"Resource": "arn:aws:iam::111122223333:server-certificate/*"
}
]
}
Drop the second statement for a report-only run. None of the read actions support resource-level permissions, so they use "Resource": "*". To draft a policy like this from your own version of the script, use the free IAM policy generator for TypeScript, then go through the steps to review an IAM policy for least privilege.
The script to find IAM server certificates expired or unused
// find-iam-server-certificates.ts
// Lists the SSL/TLS server certificates stored in IAM, their expiry, and which load balancers (ALB, NLB,
// Classic) and CloudFront distributions still use each one. --apply deletes only certificates that are
// expired AND not referenced by any listener or distribution the script can see.
// Usage:
// npx tsx find-iam-server-certificates.ts [--regions us-east-1,eu-west-1] [--warn-days 30]
// npx tsx find-iam-server-certificates.ts --apply [--names old-www-2024,old-api-2024]
import { IAMClient, DeleteServerCertificateCommand, paginateListServerCertificates } from "@aws-sdk/client-iam";
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
ElasticLoadBalancingV2Client,
paginateDescribeListenerCertificates,
paginateDescribeListeners,
paginateDescribeLoadBalancers as paginateV2LoadBalancers,
} from "@aws-sdk/client-elastic-load-balancing-v2";
import {
ElasticLoadBalancingClient,
paginateDescribeLoadBalancers as paginateClassicLoadBalancers,
} from "@aws-sdk/client-elastic-load-balancing";
import { CloudFrontClient, paginateListDistributions } from "@aws-sdk/client-cloudfront";
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): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const apply = args.includes("--apply");
const onlyNames = new Set(list(flag("--names")));
const warnDays = Number(flag("--warn-days") ?? "30");
if (!Number.isFinite(warnDays) || warnDays < 0) {
console.error("--warn-days must be a number (0 or more)");
process.exit(1);
}
const iam = new IAMClient({ region: "us-east-1" }); // IAM and CloudFront are global
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const DAY = 86_400_000;
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// Certificate ARN (load balancers) or certificate ID (CloudFront) -> where it is used
const usedBy = new Map<string, string[]>();
const addUse = (key: string, where: string): void => {
usedBy.set(key, [...(usedBy.get(key) ?? []), where]);
};
async function regions(): Promise<string[]> {
const given = list(flag("--regions"));
if (given.length) return given;
const out = await new EC2Client({ region: "us-east-1" }).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName).filter((r): r is string => !!r);
}
async function scanRegion(region: string): Promise<void> {
const elbv2 = new ElasticLoadBalancingV2Client({ region });
for await (const page of paginateV2LoadBalancers({ client: elbv2 }, {})) {
for (const lb of page.LoadBalancers ?? []) {
if (!lb.LoadBalancerArn) continue;
for await (const lPage of paginateDescribeListeners({ client: elbv2 }, { LoadBalancerArn: lb.LoadBalancerArn })) {
for (const listener of lPage.Listeners ?? []) {
if (!listener.ListenerArn || (listener.Protocol !== "HTTPS" && listener.Protocol !== "TLS")) continue;
// Includes the default certificate and every extra SNI certificate on the listener
for await (const cPage of paginateDescribeListenerCertificates({ client: elbv2 }, { ListenerArn: listener.ListenerArn })) {
for (const cert of cPage.Certificates ?? []) {
if (cert.CertificateArn) addUse(cert.CertificateArn, `${region} ${lb.Type ?? "elbv2"} ${lb.LoadBalancerName}:${listener.Port}`);
}
}
}
}
}
}
const classic = new ElasticLoadBalancingClient({ region });
for await (const page of paginateClassicLoadBalancers({ client: classic }, {})) {
for (const lb of page.LoadBalancerDescriptions ?? []) {
for (const d of lb.ListenerDescriptions ?? []) {
const certArn = d.Listener?.SSLCertificateId;
if (certArn) addUse(certArn, `${region} classic ${lb.LoadBalancerName}:${d.Listener?.LoadBalancerPort}`);
}
}
}
}
async function main(): Promise<void> {
const certs = [];
for await (const page of paginateListServerCertificates({ client: iam }, {})) {
certs.push(...(page.ServerCertificateMetadataList ?? []));
}
if (!certs.length) {
console.log("No server certificates are stored in IAM in this account.");
return;
}
for (const region of await regions()) {
try {
await scanRegion(region);
} catch (err) {
console.error(`Could not scan ${region}: ${errText(err)} (certificates used there will look unreferenced)`);
process.exitCode = 1;
}
}
for await (const page of paginateListDistributions({ client: cloudfront }, {})) {
for (const dist of page.DistributionList?.Items ?? []) {
const certId = dist.ViewerCertificate?.IAMCertificateId;
if (certId) addUse(certId, `cloudfront ${dist.Id} (${dist.DomainName})`);
}
}
const rows = [];
const deletable: string[] = [];
for (const c of certs) {
const name = c.ServerCertificateName ?? "?";
const expires = c.Expiration;
const daysLeft = expires ? Math.floor((expires.getTime() - Date.now()) / DAY) : undefined;
const uses = [...(usedBy.get(c.Arn ?? "") ?? []), ...(usedBy.get(c.ServerCertificateId ?? "") ?? [])];
let finding = "valid";
if (daysLeft !== undefined && daysLeft < 0) finding = uses.length ? "EXPIRED and STILL IN USE" : "EXPIRED, unused: delete candidate";
else if (daysLeft !== undefined && daysLeft <= warnDays) finding = uses.length ? `expires in ${daysLeft} days, in use` : `expires in ${daysLeft} days, unused`;
else if (!uses.length) finding = "valid, unused";
if (daysLeft !== undefined && daysLeft < 0 && !uses.length) deletable.push(name);
rows.push({
Name: name,
Path: c.Path ?? "/",
Uploaded: c.UploadDate?.toISOString().slice(0, 10) ?? "-",
Expires: expires?.toISOString().slice(0, 10) ?? "-",
UsedBy: uses.length ? uses.join("; ") : "-",
Finding: finding,
});
}
console.table(rows);
console.log(`${certs.length} IAM server certificates: ${deletable.length} expired and unreferenced.`);
if (!apply) {
console.log("Report only: nothing was deleted. Add --apply to delete expired, unreferenced certificates.");
return;
}
if (process.exitCode) {
console.error("Not deleting: at least one Region could not be scanned.");
return;
}
for (const name of deletable) {
if (onlyNames.size && !onlyNames.has(name)) continue;
try {
await iam.send(new DeleteServerCertificateCommand({ ServerCertificateName: name }));
console.log(`Deleted ${name}`);
} catch (err) {
console.error(`Could not delete ${name}: ${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-iam @aws-sdk/client-ec2 @aws-sdk/client-elastic-load-balancing-v2 \
@aws-sdk/client-elastic-load-balancing @aws-sdk/client-cloudfront
npm install --save-dev tsx typescript @types/node
# Report across every enabled Region
AWS_PROFILE=readonly npx tsx find-iam-server-certificates.ts --warn-days 30
# Delete expired, unreferenced certificates (optionally only the ones you name)
AWS_PROFILE=security-admin npx tsx find-iam-server-certificates.ts --apply --names staging-2024,www-2026
Sample output
┌─────────┬────────────────────┬────────────────┬──────────────┬──────────────┬─────────────────────────────────────────────────────────────┬─────────────────────────────────────┐
│ (index) │ Name │ Path │ Uploaded │ Expires │ UsedBy │ Finding │
├─────────┼────────────────────┼────────────────┼──────────────┼──────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────┤
│ 0 │ 'staging-2024' │ '/' │ '2024-02-11' │ '2025-03-14' │ '-' │ 'EXPIRED, unused: delete candidate' │
│ 1 │ 'www-2026' │ '/' │ '2026-06-02' │ '2027-07-01' │ '-' │ 'EXPIRED, unused: delete candidate' │
│ 2 │ 'api-2026' │ '/' │ '2026-06-02' │ '2027-07-01' │ 'eu-west-1 application shop-alb:443' │ 'EXPIRED and STILL IN USE' │
│ 3 │ 'cdn-2026' │ '/cloudfront/' │ '2026-08-01' │ '2027-08-01' │ 'cloudfront E2QWRUHEXAMPLE (d111111abcdef8.cloudfront.net)' │ 'expires in 11 days, in use' │
│ 4 │ 'partner-vpn-2027' │ '/' │ '2027-01-15' │ '2028-02-15' │ 'us-east-1 classic partner-gw:8443' │ 'valid' │
└─────────┴────────────────────┴────────────────┴──────────────┴──────────────┴─────────────────────────────────────────────────────────────┴─────────────────────────────────────┘
5 IAM server certificates: 2 expired and unreferenced.
Report only: nothing was deleted. Add --apply to delete expired, unreferenced certificates.
Names and IDs are illustrative. staging-2024 and www-2026 are expired and nothing uses them, so --apply deletes both. api-2026 is the urgent one: it’s expired but still attached to shop-alb in eu-west-1 as an SNI certificate next to an ACM default certificate, so clients that ask for that hostname get an expired certificate. cdn-2026 has 11 days left on CloudFront. partner-vpn-2027 is valid on a Classic Load Balancer and is a migration candidate rather than a cleanup one.
How do you replace a certificate that’s still in use?
- Get an ACM certificateRequest a free public certificate in ACM, or import the one you have. For CloudFront, the ACM certificate must be in US East (N. Virginia).
- Swap it on the consumerOn an ALB or NLB, add it with
AddListenerCertificatesor make it the default withModifyListener, then remove the IAM one withRemoveListenerCertificates. On CloudFront, setViewerCertificate.ACMCertificateArnin the distribution config. - Re-run the scriptOnce the certificate shows no references,
--applyremoves it if it’s expired. A valid, unused certificate is left for you to delete by hand.
After the move, ACM tracks renewal. Two follow-up checks are worth adding: the example to find expiring ACM certificates before they break HTTPS covers certificates ACM can’t renew by itself, and the one to find and delete unused ACM certificates keeps the new store tidy. While you’re on the listeners, find load balancers serving plain HTTP without a redirect and check the CloudFront minimum TLS version on every distribution.
Troubleshooting
DeleteConflicton delete. IAM rejected the delete because something is still attached. Find the consumer the script didn’t see before trying again.- “Could not scan” for a Region. Usually an opt-in Region your profile can’t reach, or an SCP. The script then deletes nothing. Pass
--regionswith the Regions you use, or read the steps to troubleshoot AWS IAM access denied errors. - Throttling on large accounts. Hundreds of load balancers mean many listener calls. The SDK retries throttled calls; the guide to configure AWS SDK v3 retries and timeouts shows how to raise
maxAttempts. - A certificate shows as unused but you know it’s live. Either its consumer is in a Region you left out of
--regions, or it’s something other than a load balancer or CloudFront. Find it before using--apply.
Ask ChatWithCloud instead
For a one-off answer, ask ChatWithCloud “Which IAM server certificates are expired, and is any load balancer still using them?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile, and summarizes the JSON. It can miss consumers or be wrong, and it runs changes without a confirmation step, so leave deletions to the script and use a read-only profile, as the ChatWithCloud security page recommends. More inventory-style questions are in the guide to list AWS resources with natural language.
Frequently asked questions
How do I list IAM server certificates?
Call ListServerCertificates (or aws iam list-server-certificates). The IAM console doesn’t show them. Each entry includes the upload date and expiration.
Can I renew an IAM server certificate?
Not in IAM. Get a new certificate from your CA, upload it under a new name, move consumers to it, then delete the old one. Moving to ACM is usually the better fix.
Can I move an IAM server certificate to ACM?
You can import the same certificate into ACM if you still have its private key, since IAM never returns the key. You can’t go the other way: an ACM certificate can’t be uploaded to IAM.
Why does CloudFront need a /cloudfront/ path for IAM certificates?
AWS requires IAM certificates for CloudFront to be uploaded with a path that starts with /cloudfront, such as /cloudfront/test/. The script shows the path so you can spot them.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud