Photo by Kelly Sikkema on Unsplash
Secrets Manager rotation not enabled means RotationEnabled is false on the secret, so its value only changes when someone updates it by hand. ListSecrets returns that flag with LastRotatedDate, NextRotationDate and the version stages, which is enough to find secrets that never rotate, rotations that are overdue and rotations that failed halfway.
Turning rotation on is half the job. A rotation function that fails every night leaves the secret on its old value and the console still says rotation is enabled. Security Hub checks both halves separately, and most accounts have secrets failing each one.
This example is for engineers who own secrets in AWS Secrets Manager and need to know which ones don’t rotate, which ones should have rotated and didn’t, and which ones are stuck. It never reads a secret value. The script to find unused Secrets Manager secrets shows rotation as one column next to last access; use it first to delete what nobody reads, then use this one on what’s left.
Why does “rotation not enabled” matter in Secrets Manager?
A secret that never changes stays valid for as long as anyone holds a copy of it, in a log, a laptop or a former contractor’s shell history. Regular rotation limits how long a stolen credential works, which is the reasoning in the OWASP Secrets Management Cheat Sheet. AWS Security Hub CSPM turns it into three controls, each backed by an AWS Config rule:
| Control | AWS Config rule | Fails when |
|---|---|---|
| SecretsManager.1 | secretsmanager-rotation-enabled-check |
Automatic rotation isn’t configured |
| SecretsManager.2 | secretsmanager-scheduled-rotation-success-check |
Rotation is on but didn’t happen as scheduled |
| SecretsManager.4 | secretsmanager-secret-periodic-rotation |
Not rotated within maxDaysSinceRotation (default 90) |
The script reports the same three conditions without needing AWS Config, and adds one the controls don’t: a rotation stuck with an orphaned AWSPENDING version. If you want the controls themselves, check Security Hub is enabled in every Region and check AWS Config is recording in all Regions.
Managed rotation or a Lambda function: which does a secret use?
| How it rotates | Typical secrets | What you see in ListSecrets |
|---|---|---|
| Managed rotation | RDS, Aurora and DocumentDB master user passwords, Redshift admin passwords, ECS Service Connect TLS | OwningService set (for example rds), no Lambda ARN |
| Lambda rotation function | Application database users, API keys, anything else | RotationLambdaARN set |
| None | Secrets created by hand or by IaC without a schedule | RotationEnabled false |
With managed rotation, the owning service updates both the secret and the database, and no Lambda function is involved. You can still change its schedule. For everything else, rotation is a Lambda function that runs four steps (createSecret, setSecret, testSecret, finishSecret) and moves the AWSCURRENT label to the new version at the end.
How do you tell a rotation failed?
Three signals, all in the ListSecrets response. NextRotationDate in the past means the scheduled rotation didn’t complete. No LastRotatedDate on a secret older than its interval means it never rotated at all. And a version that carries AWSPENDING but not AWSCURRENT means a rotation started and never finished; until that’s cleaned up, the next RotateSecret call assumes a rotation is still in progress and returns an error.
What does the script do?
- Lists every secret
paginateListSecretsin each Region you pass. It never callsGetSecretValue. - Classifies rotationOff, managed by a service (
OwningService), or a named Lambda function, with the schedule fromAutomaticallyAfterDaysorScheduleExpression. - Finds failuresOverdue (
NextRotationDatemore than a day past), never rotated, older than--max-days, or an orphanedAWSPENDINGversion. - Retries, if askedWith
--apply, it callsRotateSecretwith only the secret ID, which starts a rotation with the stored configuration. It does this only for overdue secrets that use a Lambda function and aren’t stuck, and never switches rotation on for a secret that has it off.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-secrets-manager. - A read-only profile for the report and a separate one for
--apply; the guide to AWS SDK v3 credential providers shows how to switch between them. - Access to the rotation functions’ logs, because a retry that fails again fails for the same reason.
Which IAM permissions does it need?
The report needs only secretsmanager:ListSecrets, which returns metadata, never values. The other two statements are for --apply: RotateSecret also requires permission to invoke the rotation function.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReportRotationHealth",
"Effect": "Allow",
"Action": "secretsmanager:ListSecrets",
"Resource": "*"
},
{
"Sid": "RetryRotationOnlyWithApply",
"Effect": "Allow",
"Action": "secretsmanager:RotateSecret",
"Resource": "arn:aws:secretsmanager:*:*:secret:*"
},
{
"Sid": "InvokeRotationFunctionsOnlyWithApply",
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:*:*:function:*"
}
]
}
Narrow the Lambda resource to your rotation functions’ ARNs in production. The IAM policy generator for TypeScript code drafts a policy from the script if you change it.
The script to find Secrets Manager secrets without rotation
// find-secrets-without-rotation.ts
// Reports rotation health for every Secrets Manager secret in the chosen Regions: rotation off, overdue,
// never completed, older than --max-days, or stuck with a pending version. It never reads secret values.
// With --apply it retries rotation (RotateSecret with the stored configuration) only for secrets that already
// have rotation enabled with a Lambda function and are overdue. It never turns rotation on by itself.
// Usage:
// npx tsx find-secrets-without-rotation.ts [--regions us-east-1,eu-west-1] [--max-days 90] [--apply]
import {
SecretsManagerClient,
RotateSecretCommand,
paginateListSecrets,
type SecretListEntry,
} from "@aws-sdk/client-secrets-manager";
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 = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
.split(",")
.map((r) => r.trim())
.filter(Boolean);
const maxDays = Number(flag("--max-days") ?? "90");
const apply = args.includes("--apply");
const DAY_MS = 86_400_000;
const GRACE_MS = DAY_MS; // a rotation window can end up to a day after the scheduled date
interface Row {
Region: string;
Secret: string;
Rotation: string;
Schedule: string;
LastRotated: string;
NextRotation: string;
Findings: string;
}
interface Checked {
row: Row;
retryable: boolean;
arn: string;
}
const day = (d: Date | undefined): string => (d ? d.toISOString().slice(0, 10) : "-");
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// Interval in days from AutomaticallyAfterDays or a rate() expression; cron() schedules return undefined.
function intervalDays(s: SecretListEntry): number | undefined {
const rules = s.RotationRules;
if (rules?.AutomaticallyAfterDays) return rules.AutomaticallyAfterDays;
const m = rules?.ScheduleExpression?.match(/^rate\((\d+)\s+(day|days|hour|hours)\)$/);
if (!m) return undefined;
return m[2].startsWith("hour") ? Number(m[1]) / 24 : Number(m[1]);
}
// A version labelled AWSPENDING that is not also AWSCURRENT means a rotation started and never finished.
function stuckPending(s: SecretListEntry): boolean {
return Object.values(s.SecretVersionsToStages ?? {}).some(
(stages) => stages.includes("AWSPENDING") && !stages.includes("AWSCURRENT"),
);
}
function check(region: string, s: SecretListEntry, now: number): Checked {
const findings: string[] = [];
const managedBy = s.OwningService;
const interval = intervalDays(s);
const last = s.LastRotatedDate ?? undefined;
const reference = (last ?? s.CreatedDate)?.getTime();
const ageDays = reference !== undefined ? Math.floor((now - reference) / DAY_MS) : undefined;
let overdue = false;
if (!s.RotationEnabled) {
findings.push(s.RotationLambdaARN ? "rotation turned off (a Lambda function is still configured)" : "rotation not enabled");
} else {
if (s.NextRotationDate && s.NextRotationDate.getTime() + GRACE_MS < now) {
overdue = true;
findings.push(`overdue: next rotation was due ${day(s.NextRotationDate)}`);
}
if (!last && interval !== undefined && ageDays !== undefined && ageDays > interval + 1) {
overdue = true;
findings.push("never rotated since creation");
}
if (stuckPending(s)) findings.push("AWSPENDING version left behind: check the rotation function logs");
}
if (ageDays !== undefined && ageDays > maxDays) findings.push(`no rotation recorded for ${ageDays} days (policy ${maxDays})`);
const schedule = s.RotationRules?.ScheduleExpression ?? (interval ? `every ${interval} days` : "-");
const rotation = !s.RotationEnabled
? "OFF"
: managedBy
? `managed by ${managedBy}`
: s.RotationLambdaARN
? `Lambda ${s.RotationLambdaARN.split(":").pop()}`
: "on";
return {
arn: s.ARN ?? s.Name ?? "",
// Only retry secrets that already rotate through a Lambda function and aren't stuck mid-rotation.
retryable: Boolean(s.RotationEnabled && s.RotationLambdaARN && !managedBy && overdue && !stuckPending(s)),
row: {
Region: region,
Secret: s.Name ?? "?",
Rotation: rotation,
Schedule: schedule,
LastRotated: day(last),
NextRotation: day(s.NextRotationDate),
Findings: findings.join("; ") || "ok",
},
};
}
async function scanRegion(region: string): Promise<Checked[]> {
const sm = new SecretsManagerClient({ region });
const out: Checked[] = [];
const now = Date.now();
for await (const page of paginateListSecrets({ client: sm }, { MaxResults: 100 })) {
for (const s of page.SecretList ?? []) out.push(check(region, s, now));
}
return out;
}
async function main(): Promise<void> {
const all: Checked[] = [];
for (const region of regions) {
try {
all.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${errText(err)}`);
process.exitCode = 1;
}
}
const flagged = all.filter((c) => c.row.Findings !== "ok");
console.table(flagged.map((c) => c.row));
console.log(`${all.length} secrets checked, ${flagged.length} with rotation findings.`);
const retry = flagged.filter((c) => c.retryable);
if (!apply) {
console.log(`${retry.length} overdue secrets could be retried with --apply. Nothing was modified.`);
return;
}
for (const c of retry) {
try {
// No RotationRules or RotationLambdaARN: rotate now with the configuration already stored on the secret.
const res = await new SecretsManagerClient({ region: c.row.Region }).send(new RotateSecretCommand({ SecretId: c.arn }));
console.log(`Started rotation of ${c.row.Secret} (${c.row.Region}), new version ${res.VersionId ?? "?"}`);
} catch (err) {
console.error(`Could not rotate ${c.row.Secret}: ${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-secrets-manager
npm install --save-dev tsx typescript @types/node
# Report only
AWS_PROFILE=readonly npx tsx find-secrets-without-rotation.ts --regions us-east-1,eu-west-1 --max-days 90
# Retry overdue Lambda rotations
AWS_PROFILE=secrets-admin npx tsx find-secrets-without-rotation.ts --regions us-east-1 --apply
Sample output
┌─────────┬─────────────┬───────────────────────┬───────────────────────────────────────┬───────────────────────┬──────────────┬──────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Secret │ Rotation │ Schedule │ LastRotated │ NextRotation │ Findings │
├─────────┼─────────────┼───────────────────────┼───────────────────────────────────────┼───────────────────────┼──────────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'prod/stripe-api-key' │ 'OFF' │ '-' │ '-' │ '-' │ 'rotation not enabled; no rotation recorded for 400 days (policy 90)' │
│ 1 │ 'us-east-1' │ 'prod/orders-db' │ 'Lambda SecretsManagermysql-rotation' │ 'every 30 days' │ '2026-07-15' │ '2026-08-14' │ 'overdue: next rotation was due 2026-08-14' │
│ 2 │ 'us-east-1' │ 'staging/redis-auth' │ 'Lambda redis-rotator' │ 'cron(0 4 ? * SUN *)' │ '2026-09-08' │ '2026-09-22' │ 'overdue: next rotation was due 2026-09-22; AWSPENDING version left behind: check the rotation function logs' │
│ 3 │ 'us-east-1' │ 'legacy/ftp' │ 'OFF' │ '-' │ '2026-03-02' │ '-' │ 'rotation turned off (a Lambda function is still configured); no rotation recorded for 210 days (policy 90)' │
└─────────┴─────────────┴───────────────────────┴───────────────────────────────────────┴───────────────────────┴──────────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
5 secrets checked, 4 with rotation findings.
Started rotation of prod/orders-db (us-east-1), new version c1d2e3f4-0000-4000-8000-000000000000
Names are illustrative. prod/orders-db missed its schedule, so --apply retried it. staging/redis-auth was skipped because an orphaned AWSPENDING version would make the retry fail. The RDS-managed secret rds!db-1a2b3c4d is healthy and doesn’t appear. prod/stripe-api-key and legacy/ftp need a decision, not a retry.
How do you fix each finding?
Rotation not enabled
For a database credential, prefer managed rotation where the service offers it, for example by letting RDS manage the master user password. For an application user or an API key, attach a rotation function and a schedule:
aws secretsmanager rotate-secret --secret-id prod/orders-app-user --rotation-lambda-arn arn:aws:lambda:us-east-1:111122223333:function:orders-rotation --rotation-rules '{"ScheduleExpression": "rate(30 days)"}'
That call rotates immediately by default. Add --no-rotate-immediately to run only the function’s testSecret step now and wait for the schedule. Schedules can be as frequent as every four hours. Third-party API keys need a function that calls the vendor’s API to issue a new key; if the vendor has no such API, record the manual rotation date and let the --max-days check remind you. When you do replace a value yourself, the guide to create and update secrets with AWS SDK v3 shows how PutSecretValue moves the AWSCURRENT and AWSPREVIOUS labels.
Overdue or stuck rotation
Read the rotation function’s CloudWatch logs for the failed step; the guide to investigate Lambda errors with CloudWatch covers the queries. The usual causes are a function that can’t reach the database from its VPC, a database user without permission to change its password, and a function without access to the secret’s KMS key. For a stuck rotation, find the orphaned version with aws secretsmanager list-secret-version-ids and remove its AWSPENDING label with update-secret-version-stage before retrying.
Rotation turned off with a function still attached
Someone called CancelRotateSecret, often to stop a failing rotation. Fix the function, then call RotateSecret again to turn rotation back on. The script won’t do this for you, because re-enabling a rotation someone deliberately stopped needs a human decision.
Troubleshooting
- A managed secret shows as overdue. Check the owning service first; for RDS, the instance’s
MasterUserSecretstatus. Don’t attach a Lambda function to a managed secret. - The retry fails with a rotation-in-progress error. Another rotation is running, or an
AWSPENDINGversion is left over. Wait for it, or clean up the label. AccessDeniedExceptionon--apply. The profile needslambda:InvokeFunctionon the rotation function as well assecretsmanager:RotateSecret.
Rotating a secret doesn’t rotate the KMS key that encrypts it; the script to find KMS keys without rotation handles that side. Long-lived IAM access keys are the other credential that ages quietly: find old and unused IAM access keys. And secrets pasted into configuration never rotate at all, so find secrets in Lambda environment variables and move them into Secrets Manager.
Ask ChatWithCloud instead
For a one-off check, ask ChatWithCloud “Which Secrets Manager secrets in eu-west-1 have rotation turned off?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and answers from the metadata, one profile and Region per session. SDK v2 reached end of support in September 2025, so newer fields can be missing; how ChatWithCloud executes SDK code on your machine explains what’s sent to the model. It runs changes without a confirmation step, so use a read-only AWS profile with ChatWithCloud for questions like this one.
Frequently asked questions
How do I list every secret with Secrets Manager rotation not enabled?
Call ListSecrets and filter on RotationEnabled not being true, or run aws secretsmanager list-secrets --query 'SecretList[?RotationEnabled!=`true`].Name'. The script adds overdue and stuck rotations on top.
How often can Secrets Manager rotate a secret?
As often as every four hours, using a rate() or cron() schedule expression, or every N days with AutomaticallyAfterDays.
Do RDS-managed secrets need a rotation Lambda?
No. Secrets that RDS, Aurora, DocumentDB or Redshift manage use managed rotation, where the service rotates both the secret and the password without a Lambda function.
Does calling RotateSecret without parameters change the schedule?
No. With only SecretId, it starts a rotation using the configuration already stored on the secret.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud