Photo by Filip Szalbot on Unsplash
To find unused KMS keys, list customer managed keys with ListKeys and DescribeKey, then call GetKeyLastUsage for each one to see its last successful cryptographic operation. For keys older than KMS’s usage tracking, check CloudTrail event history for the last 90 days. The script below does both, prices each key and only reports: deleting a KMS key can make data unrecoverable.
Customer managed keys cost money every month and widen your attack surface, and it’s hard to tell which ones still protect something. Keys created for a migration, a proof of concept or a departed team’s pipeline sit next to the ones encrypting production databases, often with no alias to tell them apart. This example is for engineers who need to find unused KMS keys in a region and back the list with evidence before anyone disables one. You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3.
It’s part of our AWS security audit examples with runnable scripts, and it applies the same last-used logic as the script to find IAM access keys older than 90 days or never used and the one to find unused IAM roles with RoleLastUsed.
What does an unused KMS key cost?
From the AWS KMS price list for US East (N. Virginia), as of September 2026:
| Item | Price |
|---|---|
| Customer managed key | $1 per month, prorated hourly |
| First and second key rotation | +$1 per month each; later rotations aren’t billed |
| Key scheduled for deletion | No charge |
| AWS managed and AWS owned keys | No charge for creation and storage |
| Symmetric requests | $0.03 per 10,000, after 20,000 free requests a month across all regions |
So an unused key with automatic rotation that has rotated at least twice costs $3 a month; 40 of them cost $120 a month, or $1,440 a year. Every enabled key is also one more key policy to review.
How do you know whether a KMS key is still used?
AWS KMS now records the last successful cryptographic operation on each key. GetKeyLastUsage returns the operation, its timestamp and the CloudTrail event ID, plus a TrackingStartDate: the date KMS began recording for that key, or the key’s creation date if later. Read the result like this:
- Usage present: the key was used since tracking began. The script compares the date with your
--dayswindow. - Empty, key created on or after tracking began: the key has never been used for a cryptographic operation.
- Empty, key created before tracking began: no use since tracking began, but it might have been used before. The script then searches CloudTrail event history, which keeps management events, including KMS calls, for 90 days. Older evidence needs a trail delivering to S3; the script to check CloudTrail is logging in every AWS Region confirms you have one.
Only cryptographic operations count: Encrypt, Decrypt, GenerateDataKey and its variants, ReEncrypt, Sign, Verify, GenerateMac, VerifyMac and DeriveSharedSecret. DescribeKey calls, including the ones this script makes, don’t make a key look used. Usage can take up to an hour to be recorded.
What can the last-used date miss?
AWS is explicit that last usage shouldn’t be the only signal before deletion, and three cases show why:
- Encrypted EBS volumes. EC2 calls KMS to decrypt a volume’s data key only when the volume is attached to an instance. A key protecting a volume that has been attached for a year can look unused, and deleting it makes the next attach fail. The script to find unencrypted EBS volumes and check default encryption shows which key each Region uses for new volumes.
- Data keys used locally. After
GenerateDataKey, encryption and decryption with the plaintext data key happen outside KMS and aren’t recorded. - Asymmetric public keys. A downloaded public key can encrypt data or verify signatures outside KMS with no record at all.
That’s why the script never acts on its findings. NIST’s Recommendation for Key Management, SP 800-57 Part 1 treats every key as having a lifecycle that ends in destruction; the safe way to move a KMS key along it is to disable it first and watch for failures.
What does the script do?
- Maps aliases
paginateListAliases, so each key shows its human-readable names. - Lists customer managed keys
paginateListKeysandDescribeKey, skipping keys withKeyManager: "AWS"and keys already pending deletion. - Checks last usage
GetKeyLastUsagefor every key. - Falls back to CloudTrailFor keys older than their tracking start,
LookupEventswithResourceNameset to the key ARN, filtered to cryptographic event names and paced to stay under the limit of 2 requests per second. - Reports
UNUSED,in useorCHECK(usage couldn’t be read) for every key, and the monthly cost of the enabled unused ones.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-kmsand@aws-sdk/client-cloudtrailpackages. - A profile with a default region, or
AWS_REGIONset. KMS keys are regional; run once per region.
Which IAM permissions does it need?
All read-only. Replace 123456789012 and us-east-1 in the key ARN.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListKeysAndEvents",
"Effect": "Allow",
"Action": [
"kms:ListKeys",
"kms:ListAliases",
"cloudtrail:LookupEvents"
],
"Resource": "*"
},
{
"Sid": "ReadKeyDetailsAndUsage",
"Effect": "Allow",
"Action": [
"kms:DescribeKey",
"kms:GetKeyLastUsage"
],
"Resource": "arn:aws:kms:us-east-1:123456789012:key/*"
}
]
}
KMS checks the key policy too. The IAM statement works for keys whose key policy lets IAM policies grant access, which the default key policy does; keys with a custom policy that doesn’t will show CHECK. kms:GetKeyLastUsage is a newer action, so older read-only policies may lack it. The guide to review an IAM policy for least privilege and the IAM policy generator for TypeScript SDK code help if you adapt the script.
The full script to find unused KMS keys
// find-unused-kms-keys.ts
// Reports customer managed KMS keys in one region with no cryptographic use in the last --days days.
// Uses KMS GetKeyLastUsage first, and CloudTrail event history (last 90 days) for keys whose usage
// tracking started after they were created. Read-only: it never disables or deletes a key.
// Usage: npx tsx find-unused-kms-keys.ts [--days 90]
import {
KMSClient,
paginateListKeys,
paginateListAliases,
DescribeKeyCommand,
GetKeyLastUsageCommand,
} from "@aws-sdk/client-kms";
import { CloudTrailClient, LookupEventsCommand } from "@aws-sdk/client-cloudtrail";
const KEY_PER_MONTH = 1; // USD per customer managed key, us-east-1, as of September 2026 (up to $3 with rotations)
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
const kms = new KMSClient({}); // region from AWS_REGION or your profile
const cloudtrail = new CloudTrailClient({});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// Operations that count as using a key (the same list KMS tracks for GetKeyLastUsage).
const CRYPTO_OPS = new Set([
"Decrypt", "DeriveSharedSecret", "Encrypt", "GenerateDataKey", "GenerateDataKeyPair",
"GenerateDataKeyPairWithoutPlaintext", "GenerateDataKeyWithoutPlaintext", "GenerateMac",
"ReEncrypt", "Sign", "Verify", "VerifyMac",
]);
// Most recent cryptographic CloudTrail event for this key in the last 90 days.
// Skips DescribeKey and other management calls (including this script's own). LookupEvents allows 2 calls/second.
async function lastTrailEvent(keyArn: string, maxPages = 10): Promise<{ name: string; time: Date } | undefined> {
let NextToken: string | undefined;
for (let page = 0; page < maxPages; page++) {
await sleep(600);
const res = await cloudtrail.send(
new LookupEventsCommand({
LookupAttributes: [{ AttributeKey: "ResourceName", AttributeValue: keyArn }],
StartTime: new Date(Date.now() - 90 * 86_400_000),
MaxResults: 50,
NextToken,
}),
);
const e = (res.Events ?? []).find((ev) => CRYPTO_OPS.has(ev.EventName ?? ""));
if (e?.EventTime) return { name: e.EventName ?? "", time: e.EventTime };
NextToken = res.NextToken;
if (!NextToken) break;
}
return undefined;
}
async function main(): Promise<void> {
const days = Number(arg("--days") ?? 90);
const cutoff = Date.now() - days * 86_400_000;
const aliases = new Map<string, string[]>();
for await (const page of paginateListAliases({ client: kms }, {})) {
for (const a of page.Aliases ?? []) {
if (a.TargetKeyId && a.AliasName) aliases.set(a.TargetKeyId, [...(aliases.get(a.TargetKeyId) ?? []), a.AliasName]);
}
}
const rows = [];
let unusedCost = 0;
for await (const page of paginateListKeys({ client: kms }, {})) {
for (const k of page.Keys ?? []) {
const keyId = k.KeyId ?? "";
const { KeyMetadata: md } = await kms.send(new DescribeKeyCommand({ KeyId: keyId }));
if (!md || md.KeyManager !== "CUSTOMER" || md.KeyState === "PendingDeletion") continue; // AWS managed keys are free
let lastUsed: Date | undefined;
let evidence = "";
try {
const u = await kms.send(new GetKeyLastUsageCommand({ KeyId: keyId }));
if (u.KeyLastUsage?.Timestamp) {
lastUsed = u.KeyLastUsage.Timestamp;
evidence = `${u.KeyLastUsage.Operation ?? ""} (KMS)`;
} else if (u.KeyCreationDate && u.TrackingStartDate && u.KeyCreationDate < u.TrackingStartDate) {
// No use since tracking began, but the key is older than tracking: check CloudTrail.
const ev = await lastTrailEvent(md.Arn ?? keyId);
if (ev) [lastUsed, evidence] = [ev.time, `${ev.name} (CloudTrail)`];
else evidence = `no use since ${u.TrackingStartDate.toISOString().slice(0, 10)}`;
} else {
evidence = "never used since creation";
}
} catch (err) {
evidence = `can't read usage: ${err instanceof Error ? err.name : String(err)}`;
}
const unused = !evidence.startsWith("can't") && (!lastUsed || lastUsed.getTime() < cutoff);
if (unused && md.KeyState === "Enabled") unusedCost += KEY_PER_MONTH;
rows.push({
Key: keyId,
Aliases: (aliases.get(keyId) ?? []).join(", "),
State: md.KeyState ?? "",
Spec: md.KeySpec ?? "",
Created: md.CreationDate?.toISOString().slice(0, 10) ?? "",
"Last used": lastUsed ? lastUsed.toISOString().slice(0, 10) : "-",
Evidence: evidence,
Verdict: evidence.startsWith("can't") ? "CHECK" : unused ? "UNUSED" : "in use",
});
}
}
console.table(rows);
console.log(`Enabled keys with no use in ${days} days: at least $${unusedCost.toFixed(2)}/month.`);
console.log("Report only. Disable a key and watch CloudTrail for DisabledException before scheduling deletion.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Event history covers 90 days, so for keys that need the CloudTrail fallback, a --days value above 90 isn’t backed by evidence.
How do you run it?
npm install @aws-sdk/client-kms @aws-sdk/client-cloudtrail
npm install --save-dev tsx typescript
# Keys with no cryptographic use in the last 90 days
AWS_PROFILE=security-audit AWS_REGION=us-east-1 npx tsx find-unused-kms-keys.ts
# A stricter 30-day window
AWS_PROFILE=security-audit AWS_REGION=eu-central-1 npx tsx find-unused-kms-keys.ts --days 30
Sample output
┌─────────┬────────────────────────────────────────┬───────────────────────┬────────────┬─────────────────────┬──────────────┬──────────────┬───────────────────────────────────────────┬──────────┐
│ (index) │ Key │ Aliases │ State │ Spec │ Created │ Last used │ Evidence │ Verdict │
├─────────┼────────────────────────────────────────┼───────────────────────┼────────────┼─────────────────────┼──────────────┼──────────────┼───────────────────────────────────────────┼──────────┤
│ 0 │ '1234abcd-12ab-34cd-56ef-1234567890ab' │ 'alias/app-data' │ 'Enabled' │ 'SYMMETRIC_DEFAULT' │ '2023-03-14' │ '2026-09-26' │ 'Decrypt (KMS)' │ 'in use' │
│ 1 │ '2345bcde-23bc-45de-67fa-2345678901bc' │ 'alias/old-etl' │ 'Enabled' │ 'SYMMETRIC_DEFAULT' │ '2022-11-02' │ '-' │ 'no use since 2026-03-11' │ 'UNUSED' │
│ 2 │ '3456cdef-34cd-56ef-78ab-3456789012cd' │ '' │ 'Disabled' │ 'RSA_2048' │ '2025-06-20' │ '-' │ 'never used since creation' │ 'UNUSED' │
│ 3 │ '4567defa-45de-67fa-89bc-4567890123de' │ 'alias/partner-share' │ 'Enabled' │ 'SYMMETRIC_DEFAULT' │ '2024-01-09' │ '-' │ "can't read usage: AccessDeniedException" │ 'CHECK' │
└─────────┴────────────────────────────────────────┴───────────────────────┴────────────┴─────────────────────┴──────────────┴──────────────┴───────────────────────────────────────────┴──────────┘
Enabled keys with no use in 90 days: at least $1.00/month.
Report only. Disable a key and watch CloudTrail for DisabledException before scheduling deletion.
IDs are illustrative. alias/old-etl predates usage tracking and has no cryptographic events in CloudTrail either. The disabled RSA key costs $1 a month but isn’t counted in the total, which only covers enabled keys. alias/partner-share has a key policy that doesn’t grant this profile access.
What should you do with an unused KMS key?
- Find the ownerAliases, tags and the CloudTrail event that created the key usually point to a team or a stack.
- Disable it
DisableKeyis reversible. Leave it disabled for at least a full business cycle, including month-end jobs. - Watch for failuresAttempts to use a disabled key fail with
DisabledException, which CloudTrail records. Any hit means something still depends on the key. - Schedule deletion
ScheduleKeyDeletionrequires a waiting period of 7 to 30 days (default 30). The key can’t be used while pending deletion, you can cancel at any time before the period ends, and you aren’t charged for it in the meantime.
After the waiting period, anything encrypted under the key can’t be decrypted. Multi-Region keys have their own order: replicas must be deleted before the primary. To stop a recently used key from being disabled by mistake, key policies can use the kms:TrailingDaysWithoutKeyUsage condition key. For the keys you keep, the script to enable KMS key rotation on customer managed keys turns on automatic rotation where it’s missing. For the broader review, see the guide to analyze your AWS security posture with an AI CLI.
Troubleshooting
AccessDeniedExceptiononGetKeyLastUsagefor some keys. The key policy doesn’t allow your principal. The steps to troubleshoot AWS IAM access denied errors cover key policies as well as IAM policies.ThrottlingExceptionfrom CloudTrail.LookupEventsallows 2 requests per second per account and region. The script sleeps 600 ms between calls; raise it if other tools use the API at the same time.- A key you know is used shows
UNUSED. Check the region, then the three blind spots above. Any tool that tries to find unused KMS keys has the same limits. - Not sure which role the script ran as. The example to check the permissions of your currently assumed IAM role shows it.
Ask ChatWithCloud instead
ChatWithCloud can answer “Which customer managed KMS keys in this region have no aliases?” or “When was this key last used?” by writing AWS SDK for JavaScript v2 code, running it on your machine with your AWS profile and explaining the result. It generates SDK v2 code, and v2 reached end of support on 8 September 2025, so AWS features added since then may not be available to it; for a key last-used check, the script above is the more reliable route. ChatWithCloud also runs generated code without a confirmation step, so never ask it to disable or delete keys on a profile that allows it. Connect ChatWithCloud with a read-only AWS profile and read the ChatWithCloud security model first.
Frequently asked questions
How do I check when a KMS key was last used?
Call GetKeyLastUsage (or run aws kms get-key-last-usage --key-id). It returns the last successful cryptographic operation and its time since the key’s TrackingStartDate.
Do disabled KMS keys cost money?
AWS’s pricing lists no exception for disabled keys, only for keys scheduled for deletion, so plan on paying for a disabled key until its deletion is scheduled.
Can I delete an AWS managed KMS key?
No. You can only schedule deletion of customer managed keys. AWS managed keys aren’t charged for creation or storage, so the script skips them.
Can I recover a deleted KMS key?
No. You can cancel deletion during the 7 to 30 day waiting period, but once it ends the key and its metadata are gone, and data encrypted under it can’t be decrypted.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud