Photo by rc.xyz NFT gallery on Unsplash
To find unused Secrets Manager secrets, call ListSecrets in each Region and compare every secret’s LastAccessedDate with today. The field has day granularity and is missing when the secret was never retrieved in that Region, so fall back to CreatedDate. Secrets idle for 90 days or more are candidates for deletion with a recovery window.
Secrets pile up. Every proof of concept, rotated vendor key and decommissioned database leaves one behind, and each costs money every month whether anything reads it or not. Old credentials are also a security problem: a secret nobody uses is one nobody watches. This example is for engineers who want to find unused Secrets Manager secrets across an account and clean them up without breaking an application that reads one twice a year.
You’ll get a TypeScript script for the AWS SDK for JavaScript v3. It reads metadata only: it never calls GetSecretValue, so it can’t print or leak a secret value. It reports by default and schedules deletion only with --apply. For the reading side of the same service, the guide to get a Secrets Manager secret value with AWS SDK v3 shows how applications fetch and cache secrets.
How much does an unused secret cost?
From the AWS Secrets Manager pricing page and the AWS Price List for US East (N. Virginia), checked September 2026:
| Item | Price |
|---|---|
| Secret | $0.40 per secret per month, prorated for secrets stored less than a month |
| Replica secret in another Region | Billed as a separate secret: $0.40 per month |
| API calls | $0.05 per 10,000 |
| Secret scheduled for deletion | No charge |
The unit price is small; the count isn’t always. 150 forgotten secrets cost $60 a month, or $720 a year, and a secret replicated to two other Regions counts three times: $1.20 a month. Scheduling deletion stops the charge, even though the secret can still be restored during the recovery window.
What does LastAccessedDate tell you?
ListSecrets returns each secret’s metadata (never the SecretString or SecretBinary), including:
LastAccessedDate: the date the secret was last retrieved in that Region, truncated to midnight, so you get a day, not a time. It’s omitted if the secret has never been retrieved in the Region.CreatedDateandLastChangedDate: when it was created and last modified in any way.RotationEnabledandLastRotatedDate: whether scheduled rotation is on, and when it last completed.OwningService: set when another AWS service created the secret, for example a database’s managed master password.PrimaryRegion: differs from the current Region for replica secrets.
Because access is tracked per Region, a secret replicated to a Region where the application reads it will look idle in its primary Region. Read the report per Region before you act, and treat day granularity as good enough for a 90-day threshold, not for a 1-day one. ListSecrets is also eventually consistent and may not reflect the last five minutes.
What does the script do?
- Lists Regions
DescribeRegions, or the Regions you pass with--regions=. - Reads metadata only
paginateListSecretswith 100 secrets per page. Secrets already scheduled for deletion aren’t listed by default. - Measures idle daysDays since
LastAccessedDate, or sinceCreatedDatefor secrets never retrieved. At or above--days(default 90) the secret isUNUSED. - Protects special casesSecrets with an
OwningServiceare markedmanagedand replicas are markedreplica; neither is deleted. - Schedules deletion on requestWith
--apply,DeleteSecretwithRecoveryWindowInDays(7 to 30, default 30). It never setsForceDeleteWithoutRecovery.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-secrets-managerand@aws-sdk/client-ec2. - An AWS profile, configured as in the guide to AWS SDK v3 credential providers such as fromIni and fromSSO.
Which IAM permissions does it need?
There’s deliberately no secretsmanager:GetSecretValue: an audit role shouldn’t be able to read secrets. The second statement is for --apply only. Replace the account ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListSecretMetadata",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"secretsmanager:ListSecrets"
],
"Resource": "*"
},
{
"Sid": "ScheduleDeletionApplyOnly",
"Effect": "Allow",
"Action": "secretsmanager:DeleteSecret",
"Resource": "arn:aws:secretsmanager:*:111122223333:secret:*"
}
]
}
A secret’s resource policy can still deny DeleteSecret; the script reports the error name and moves on.
The script to find unused Secrets Manager secrets
// find-unused-secrets-manager-secrets.ts
// Lists Secrets Manager secrets in each Region and reports the ones nobody has retrieved for --days days,
// using LastAccessedDate from ListSecrets. It never calls GetSecretValue and never prints a secret value.
// Dry run by default: with --apply it schedules deletion with a recovery window (never a forced delete).
// Usage: npx tsx find-unused-secrets-manager-secrets.ts [--regions=us-east-1] [--days=90] [--apply] [--recovery-days=30]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import { SecretsManagerClient, paginateListSecrets, DeleteSecretCommand } from "@aws-sdk/client-secrets-manager";
const PRICE_PER_SECRET_MONTH = 0.4; // USD, us-east-1, checked September 2026; replicas are billed as separate secrets
const args = process.argv.slice(2);
const flag = (name: string) => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const apply = args.includes("--apply");
const days = Number(flag("days") ?? "90");
const recoveryDays = Number(flag("recovery-days") ?? "30");
const regionArg = flag("regions")?.split(",").map((s) => s.trim()).filter(Boolean);
if (!Number.isInteger(days) || days < 1) {
console.error("--days must be a positive whole number");
process.exit(1);
}
if (!Number.isInteger(recoveryDays) || recoveryDays < 7 || recoveryDays > 30) {
console.error("--recovery-days must be from 7 to 30");
process.exit(1);
}
type Status = "UNUSED" | "in use" | "managed" | "replica" | "scheduled";
interface Row {
Region: string;
Secret: string;
LastAccessed: string;
Created: string;
IdleDays: number;
Rotation: string;
Status: Status;
Note: string;
}
const DAY_MS = 86_400_000;
const isoDay = (d?: Date) => (d ? d.toISOString().slice(0, 10) : "never");
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg;
const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
async function scanRegion(region: string): Promise<Row[]> {
const sm = new SecretsManagerClient({ region });
const rows: Row[] = [];
const arns = new Map<Row, string>(); // full ARNs for DeleteSecret, kept out of the table
const now = Date.now();
// ListSecrets returns metadata only: no SecretString or SecretBinary.
for await (const page of paginateListSecrets({ client: sm }, { MaxResults: 100 })) {
for (const s of page.SecretList ?? []) {
// LastAccessedDate has day granularity and is omitted if the secret was never retrieved in this Region.
const since = s.LastAccessedDate ?? s.CreatedDate;
const idle = since ? Math.floor((now - since.getTime()) / DAY_MS) : 0;
const row: Row = {
Region: region,
Secret: s.Name ?? s.ARN ?? "?",
LastAccessed: isoDay(s.LastAccessedDate),
Created: isoDay(s.CreatedDate),
IdleDays: idle,
Rotation: s.RotationEnabled ? `on, last ${isoDay(s.LastRotatedDate)}` : "off",
Status: idle >= days ? "UNUSED" : "in use",
Note: "",
};
if (s.OwningService) {
row.Status = "managed";
row.Note = `created by ${s.OwningService}; remove it through that service`;
} else if (s.PrimaryRegion && s.PrimaryRegion !== region) {
row.Status = "replica";
row.Note = `replica of ${s.PrimaryRegion}; remove the replica from the primary`;
}
rows.push(row);
if (s.ARN) arns.set(row, s.ARN);
}
}
if (apply) {
for (const row of rows.filter((r) => r.Status === "UNUSED")) {
try {
const out = await sm.send(new DeleteSecretCommand({ SecretId: arns.get(row) ?? row.Secret, RecoveryWindowInDays: recoveryDays }));
row.Status = "scheduled";
row.Note = `deletion on ${isoDay(out.DeletionDate)}; RestoreSecret cancels it`;
} catch (err) {
// For example InvalidRequestException when the secret still has replicas in other Regions.
row.Note = `not deleted: ${err instanceof Error ? err.name : String(err)}`;
}
}
}
return rows;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of await listRegions()) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? err.name : String(err)}`);
}
}
rows.sort((a, b) => Number(b.Status === "UNUSED") - Number(a.Status === "UNUSED") || b.IdleDays - a.IdleDays);
console.table(rows);
const unused = rows.filter((r) => r.Status === "UNUSED").length;
const scheduled = rows.filter((r) => r.Status === "scheduled").length;
const monthly = (unused + scheduled) * PRICE_PER_SECRET_MONTH;
console.log(`${rows.length} secret(s); ${unused + scheduled} not retrieved in ${days}+ days, about $${monthly.toFixed(2)}/month.`);
if (!apply && unused) console.log(`Dry run. Re-run with --apply to schedule deletion with a ${recoveryDays}-day recovery window.`);
if (scheduled) console.log(`${scheduled} secret(s) scheduled for deletion. No secret values were read or printed.`);
}
main().catch((err) => {
console.error(err instanceof Error ? `${err.name}: ${err.message}` : err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-secrets-manager @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# Report secrets not retrieved in 90+ days, every enabled Region
AWS_PROFILE=security-audit npx tsx find-unused-secrets-manager-secrets.ts
# Stricter threshold, one Region, schedule deletion with the shortest recovery window
AWS_PROFILE=secrets-admin npx tsx find-unused-secrets-manager-secrets.ts --regions=us-east-1 --days=180 --apply --recovery-days=7
Start with a long recovery window. Thirty days covers monthly batch jobs; a quarterly job needs you to look beyond the report.
Sample output
┌─────────┬─────────────┬───────────────────────────────────────────────┬──────────────┬──────────────┬──────────┬───────────────────────┬───────────┬─────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Secret │ LastAccessed │ Created │ IdleDays │ Rotation │ Status │ Note │
├─────────┼─────────────┼───────────────────────────────────────────────┼──────────────┼──────────────┼──────────┼───────────────────────┼───────────┼─────────────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'poc/openai-key' │ '2025-06-18' │ '2025-05-02' │ 466 │ 'off' │ 'UNUSED' │ '' │
│ 1 │ 'us-east-1' │ 'legacy/mysql-reporting' │ '2025-08-30' │ '2023-11-14' │ 393 │ 'on, last 2026-09-01' │ 'UNUSED' │ '' │
│ 2 │ 'us-east-1' │ 'ci/deploy-webhook' │ 'never' │ '2026-03-09' │ 202 │ 'off' │ 'UNUSED' │ '' │
│ 3 │ 'eu-west-1' │ 'shared/partner-api-key' │ 'never' │ '2025-04-03' │ 542 │ 'off' │ 'replica' │ 'replica of us-east-1; remove the replica from the primary' │
│ 4 │ 'us-east-1' │ 'prod/orders-db' │ '2026-09-26' │ '2024-02-20' │ 1 │ 'on, last 2026-08-28' │ 'in use' │ '' │
│ 5 │ 'us-east-1' │ 'rds!db-3f9c2a1e-7b4d-4e8f-a6c2-1d9e5b7f3a08' │ '2026-09-27' │ '2025-01-12' │ 0 │ 'on, last 2026-09-20' │ 'managed' │ 'created by rds; remove it through that service' │
└─────────┴─────────────┴───────────────────────────────────────────────┴──────────────┴──────────────┴──────────┴───────────────────────┴───────────┴─────────────────────────────────────────────────────────────┘
14 secret(s); 3 not retrieved in 90+ days, about $1.20/month.
Dry run. Re-run with --apply to schedule deletion with a 30-day recovery window.
The names are illustrative. poc/openai-key and legacy/mysql-reporting have been idle for over a year and are safe candidates. ci/deploy-webhook was created and never retrieved at all, which usually means the pipeline reads a different secret. The rds!db-… secret belongs to RDS and the eu-west-1 row is a replica, so the script leaves both alone.
How do you delete a secret safely?
- Search for the nameLook for the secret name or ARN in your infrastructure code, task definitions and function configuration. The scripts to find plaintext secrets in ECS task definitions and find secrets in Lambda environment variables also show which workloads reference Secrets Manager ARNs.
- Schedule, don’t forceA scheduled secret can’t be retrieved, so anything still using it fails loudly while you can still run
RestoreSecret. AWS suggests a CloudWatch alarm on attempts to access a secret scheduled for deletion. - Revoke at the sourceDeleting the secret doesn’t disable the credential it held. Remove the database user, API key or access key it contained; for IAM users, the script to find IAM access keys older than 90 days or never used finds the matching keys.
- Remove replicas firstA primary secret with replicas can’t be deleted: remove the replica Regions, then delete the primary.
The OWASP Secrets Management Cheat Sheet describes the same lifecycle: secrets should exist only as long as necessary and be revoked when they’re no longer required. Configuration values that don’t need rotation can live in Parameter Store instead; the guide to get SSM Parameter Store values with AWS SDK v3 covers SecureString parameters.
Troubleshooting
InvalidRequestExceptionon delete. The secret still has replicas, is managed by another service, or is already scheduled for deletion.- A secret shows
neverbut the app uses it. The app reads it in another Region, or reads a replica. CheckLastAccessedfor the same name in every Region. - Rotation-enabled secrets. A secret with rotation on but no readers is still rotating a live credential. Turn off rotation and revoke the credential as part of the cleanup. Secret rotation is separate from rotating the KMS key that encrypts the secret; the script to find KMS keys without automatic rotation and enable it handles that side.
- Region errors. An SCP may block Secrets Manager in unused Regions; the error name prints on stderr and the scan continues.
To see what secrets cost in total, the script to find your most expensive AWS service with Cost Explorer breaks the bill down by service.
Ask ChatWithCloud instead
ChatWithCloud turns a plain-English question into AWS SDK for JavaScript v2 code, runs it on your machine with your profile and sends the JSON result to the AI model to write the answer. Metadata questions work well, for example “Which Secrets Manager secrets in us-east-1 haven’t been accessed in 90 days?” Keep secret values out of the conversation: don’t ask it to show a value, because the result would be sent for processing. Use a read-only AWS profile for ChatWithCloud without GetSecretValue, and read the ChatWithCloud security model first.
Frequently asked questions
How do I know when a Secrets Manager secret was last used?
Read LastAccessedDate from ListSecrets or DescribeSecret. It shows the day of the last retrieval in that Region, and it’s absent when the secret was never retrieved there. CloudTrail GetSecretValue events give the exact time and caller.
Do I pay for a secret scheduled for deletion?
No. AWS doesn’t charge for secrets marked for deletion, and you can restore one with RestoreSecret until the recovery window ends.
What is the minimum recovery window for DeleteSecret?
7 days; the maximum and default is 30. ForceDeleteWithoutRecovery skips the window, and the secret can’t be recovered afterwards.
Does deleting a secret revoke the password inside it?
No. Secrets Manager only stops storing it. Disable or change the credential in the database or service it belongs to.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud