To find old IAM access keys, list every user with ListUsers, list each user’s keys with ListAccessKeys and compare CreateDate with your limit, usually 90 days. Then call GetAccessKeyLastUsed for each key: no LastUsedDate means it has never been used. Deactivate flagged keys first; delete them only once nothing breaks.
Long-lived access keys are the IAM credential most likely to end up in a Git history, a CI log or a laptop backup. This example is for engineers who need to find old IAM access keys and keys nobody uses, then clean them up without taking down a forgotten cron job. The script uses AWS SDK for JavaScript v3, reads by default, and only changes anything when you pass two explicit flags.
The guide to analyze AWS security posture with an AI CLI asks “Which active access keys are older than 90 days, and when were they last used?” in plain English. This is the script behind that question, one of our AWS SDK v3 practical examples.
What counts as an old or unused access key?
Two separate thresholds, both configurable:
| Check | Default | Source field | Why |
|---|---|---|---|
| Age | older than 90 days | CreateDate from ListAccessKeys |
Rotation limits how long a leaked key stays valid |
| Idle | not used for 45 days | LastUsedDate from GetAccessKeyLastUsed |
An unused key is pure risk with no benefit |
| Never used | created over 45 days ago, no last-used date | LastUsedDate missing |
Usually created “just in case” and forgotten |
The defaults follow the CIS AWS Foundations Benchmark, which recommends rotating access keys every 90 days or less and disabling credentials unused for 45 days or more (recommendations 1.13 and 1.11 in v5.0.0). The CIS Amazon Web Services Foundations Benchmark has the full wording and audit steps.
Two details about the last-used data. IAM has tracked key use since 22 April 2015, so a key without a LastUsedDate hasn’t been used since then. And ServiceName tells you which service the key last called, for example s3, which is often the fastest clue to what depends on it. KMS keys keep a similar record, which the script to find unused customer managed KMS keys reads with GetKeyLastUsage.
Prerequisites
- Node.js 20 or later, npm and
tsx. - The
@aws-sdk/client-iampackage. - A profile in the account you’re auditing. IAM is global; the script uses
us-east-1for the endpoint unlessAWS_REGIONis set.
Which IAM permissions does it need?
The report needs three read actions. iam:UpdateAccessKey is only for --deactivate --apply; leave that statement out for an audit-only role. Replace 123456789012 with your account ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListAllUsers",
"Effect": "Allow",
"Action": "iam:ListUsers",
"Resource": "*"
},
{
"Sid": "ReadAccessKeys",
"Effect": "Allow",
"Action": ["iam:ListAccessKeys", "iam:GetAccessKeyLastUsed"],
"Resource": "arn:aws:iam::123456789012:user/*"
},
{
"Sid": "OptionalDeactivate",
"Effect": "Allow",
"Action": "iam:UpdateAccessKey",
"Resource": "arn:aws:iam::123456789012:user/*"
}
]
}
IAMReadOnlyAccess and SecurityAudit cover the read actions but not iam:UpdateAccessKey. To generate a policy from your own variant of the script, use the AI IAM policy generator for TypeScript and then review the generated policy for least privilege before attaching it.
The script to find old IAM access keys
// find-old-iam-access-keys.ts
// Reports IAM user access keys that are older than --max-age days or not used
// for --unused days (including keys never used). Read-only by default.
// With --deactivate it shows which Active keys it would set to Inactive (dry run);
// add --apply to make the change. Keys are never deleted.
// Usage: npx tsx find-old-iam-access-keys.ts [--max-age 90] [--unused 45] [--deactivate [--apply]]
import {
IAMClient,
GetAccessKeyLastUsedCommand,
UpdateAccessKeyCommand,
paginateListUsers,
paginateListAccessKeys,
} from "@aws-sdk/client-iam";
const DAY = 86_400_000;
function numberArg(name: string, fallback: number): number {
const i = process.argv.indexOf(name);
const value = i > -1 ? Number(process.argv[i + 1]) : fallback;
if (!Number.isFinite(value) || value < 1) throw new Error(`${name} must be a positive number`);
return value;
}
const maxAgeDays = numberArg("--max-age", 90);
const unusedDays = numberArg("--unused", 45);
const deactivate = process.argv.includes("--deactivate");
const apply = process.argv.includes("--apply");
const iam = new IAMClient({
region: process.env.AWS_REGION ?? "us-east-1",
maxAttempts: 8,
retryMode: "adaptive",
});
type Finding = {
user: string;
keyId: string;
status: string;
ageDays: number;
lastUsed: string;
lastService: string;
reasons: string;
};
async function main(): Promise<void> {
// Never deactivate the key this script is signing requests with.
const ownKeyId = (await iam.config.credentials()).accessKeyId;
const now = Date.now();
const findings: Finding[] = [];
let keysChecked = 0;
for await (const page of paginateListUsers({ client: iam }, {})) {
for (const user of page.Users ?? []) {
if (!user.UserName) continue;
const keys = paginateListAccessKeys({ client: iam }, { UserName: user.UserName });
for await (const keyPage of keys) {
for (const key of keyPage.AccessKeyMetadata ?? []) {
if (!key.AccessKeyId || !key.CreateDate) continue;
keysChecked++;
const { AccessKeyLastUsed: used } = await iam.send(
new GetAccessKeyLastUsedCommand({ AccessKeyId: key.AccessKeyId }),
);
const ageDays = Math.floor((now - key.CreateDate.getTime()) / DAY);
const lastUsed = used?.LastUsedDate;
const idleDays = lastUsed ? Math.floor((now - lastUsed.getTime()) / DAY) : ageDays;
const reasons: string[] = [];
if (ageDays > maxAgeDays) reasons.push(`older than ${maxAgeDays}d`);
if (!lastUsed && ageDays > unusedDays) reasons.push("never used");
else if (lastUsed && idleDays > unusedDays) reasons.push(`unused ${idleDays}d`);
if (reasons.length === 0) continue;
findings.push({
user: user.UserName,
keyId: key.AccessKeyId,
status: key.Status ?? "unknown",
ageDays,
lastUsed: lastUsed?.toISOString().slice(0, 10) ?? "never",
lastService: used?.ServiceName ?? "N/A",
reasons: reasons.join(", "),
});
}
}
}
}
console.table(findings);
console.log(`${keysChecked} access keys checked, ${findings.length} flagged.`);
if (!deactivate) return;
const targets = findings.filter((f) => f.status === "Active" && f.keyId !== ownKeyId);
console.log(`\n${apply ? "Deactivating" : "Dry run: would deactivate"} ${targets.length} active keys:`);
for (const f of targets) {
console.log(` ${f.user} ${f.keyId} (${f.reasons})`);
if (!apply) continue;
await iam.send(new UpdateAccessKeyCommand({ UserName: f.user, AccessKeyId: f.keyId, Status: "Inactive" }));
}
if (!apply && targets.length > 0) console.log("Re-run with --deactivate --apply to make the change.");
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
Safety rails built in: nothing changes without --deactivate --apply; only Active keys are touched; the key the script itself is signing with is skipped, read from iam.config.credentials(); and keys are set to Inactive, never deleted.
How do you run it?
npm install @aws-sdk/client-iam
npm install --save-dev tsx typescript
# Report only (defaults: older than 90 days, unused for 45)
AWS_PROFILE=security-audit npx tsx find-old-iam-access-keys.ts
# Stricter thresholds
AWS_PROFILE=security-audit npx tsx find-old-iam-access-keys.ts --max-age 60 --unused 30
# Dry run: show which active keys would be deactivated
AWS_PROFILE=iam-admin npx tsx find-old-iam-access-keys.ts --deactivate
# Make the change
AWS_PROFILE=iam-admin npx tsx find-old-iam-access-keys.ts --deactivate --apply
Sample output
┌─────────┬──────────────┬────────────────────────┬──────────┬─────────┬──────────────┬─────────────┬──────────────────────────────────┐
│ (index) │ user │ keyId │ status │ ageDays │ lastUsed │ lastService │ reasons │
├─────────┼──────────────┼────────────────────────┼──────────┼─────────┼──────────────┼─────────────┼──────────────────────────────────┤
│ 0 │ 'ci-deploy' │ 'AKIAIOSFODNN7EXAMPLE' │ 'Active' │ 412 │ '2026-09-26' │ 's3' │ 'older than 90d' │
│ 1 │ 'backup-old' │ 'AKIAI44QH8DHBEXAMPLE' │ 'Active' │ 730 │ '2025-03-02' │ 'ec2' │ 'older than 90d, unused 574d' │
│ 2 │ 'jsmith' │ 'AKIAJ5ZRZWPEXAMPLE22' │ 'Active' │ 120 │ 'never' │ 'N/A' │ 'older than 90d, never used' │
└─────────┴──────────────┴────────────────────────┴──────────┴─────────┴──────────────┴─────────────┴──────────────────────────────────┘
27 access keys checked, 3 flagged.
Dry run: would deactivate 3 active keys:
ci-deploy AKIAIOSFODNN7EXAMPLE (older than 90d)
backup-old AKIAI44QH8DHBEXAMPLE (older than 90d, unused 574d)
jsmith AKIAJ5ZRZWPEXAMPLE22 (older than 90d, never used)
Re-run with --deactivate --apply to make the change.
Key IDs and user names are placeholders. Read the rows differently: ci-deploy is old but used yesterday, so it needs rotation, not deactivation. backup-old and jsmith are safe candidates to deactivate. For mixed results like this, edit the list or run with a filter rather than applying blindly.
How do you rotate a key that’s still in use?
Deactivating a key that a pipeline depends on causes an outage. For keys that are old but active, rotate with the second key slot:
- Create a second keyA user can have two access keys at once, which exists for exactly this. Create the new one with
CreateAccessKeyor in the console. - Update every consumerPut the new key in the secret store, CI variables or config that uses the old one.
lastServicefrom the report tells you where to look first. Code that reads the key at runtime can get the secret value from Secrets Manager with AWS SDK v3 instead of holding a copy. - Deactivate the old keySet it to
Inactiveand wait. If something breaks, reactivate it in seconds; that’s why the script never deletes. - Confirm it’s unusedRerun the script after a few days. The old key’s last-used date should not move, and the new key’s should.
- Delete the old keyOnly now call
DeleteAccessKey. Deletion can’t be undone.
Better still, remove the need for the key. Workloads on AWS can use IAM roles, CI systems can use OIDC federation, and people can use IAM Identity Center; all of those issue temporary credentials. Keys used by workloads often sit in configuration rather than code; the scripts to find access keys and other secrets in Lambda environment variables and find access keys in ECS task definitions show which functions and tasks to move to a role. Roles pile up in their turn, and the script to find unused IAM roles with RoleLastUsed finds the ones nobody assumes. Before storing any key in code, see whether it’s safe to paste AWS code into an AI converter, which covers how our tools warn about access keys in pasted code. For the people who still sign in to the console, the companion script can find IAM users without MFA. If you suspect a key has already leaked, GuardDuty flags unusual API activity by IAM users, so check GuardDuty is enabled in every AWS Region the key could be used in.
Troubleshooting
AccessDeniedonGetAccessKeyLastUsed. The action is scoped to the user ARN, and a path such asuser/ci/deploystill matchesuser/*. If it’s still denied, a permissions boundary or SCP is likely; the steps to troubleshoot an AWS IAM access denied error walk through each layer, and you can check what your assumed role can actually do.- Throttling on large accounts. Each key costs one extra call. Raise
maxAttempts, or pull the IAM credential report once and read itsaccess_key_1_last_rotatedandaccess_key_1_last_used_datecolumns; it covers the first two keys per user. - Keys for the root user don’t appear.
ListUsersonly returns IAM users.GetAccountSummaryreportsAccountAccessKeysPresentfor root; root access keys should not exist at all. The script to check AWS root account MFA and access keys reports them, with when each was last used. - A deactivated key broke something. Reactivate it with
UpdateAccessKeyandStatus: "Active", then follow the rotation steps above.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which access keys are older than 90 days, and which were never used?” from a read-only profile. It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the findings; see how ChatWithCloud runs AWS SDK code on your machine. It executes changes without a confirmation step, so don’t ask it to deactivate keys from a profile that allows iam:UpdateAccessKey unless you mean it. Key IDs and user names are part of the results sent for processing; the ChatWithCloud security page lists exactly what is sent. For the network side of the same review, run the example to find security groups open to the internet on common ports.
Frequently asked questions
How do I find old IAM access keys with the AWS CLI?
Run aws iam list-access-keys --user-name NAME for each user and compare CreateDate, then aws iam get-access-key-last-used --access-key-id KEY. For the whole account at once, use the credential report.
Should I delete or deactivate unused access keys?
Deactivate first. An inactive key stops working immediately but can be reactivated. Delete it once you’re sure nothing depends on it.
What does a missing LastUsedDate mean?
The key hasn’t been used since IAM started tracking key use on 22 April 2015, which in practice means never.
How often should IAM access keys be rotated?
CIS recommends every 90 days or less. Replacing long-lived keys with roles or federation avoids the rotation work entirely.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud