AWS Security

AWS Secrets Manager Hands-On: Automatic Rotation Done Right

Quick take: AWS Secrets Manager stores a credential once, encrypts it with KMS, hands it to your application through a single GetSecretValue call, and — the part everyone gets wrong — rotates it on a schedule without downtime. The magic is not the vault; a dozen products can hold an encrypted string. The magic is versioning with staging labels: a secret is not one value but a series of versions, and three moving labels — AWSCURRENT, AWSPENDING, AWSPREVIOUS — decide which version each caller sees. Your app always reads AWSCURRENT and never knows a rotation happened underneath it. A rotation function creates a new password as AWSPENDING, sets it in the database, tests it, and then atomically moves the AWSCURRENT label onto it — four steps, in that exact order, createSecret → setSecret → testSecret → finishSecret. Get the order, the idempotency, and — above all — the network path right and rotation is invisible and boring. Get the network wrong (a rotation Lambda that can reach the database but not the Secrets Manager endpoint) and the rotation hangs for fifteen minutes and then fails, and you learn the hard way why every Secrets Manager war story is really a VPC story. This article takes rotation apart end to end — the labels, the four steps, managed vs custom rotation, single-user vs alternating-users, the endpoint reachability, KMS, cross-account, multi-Region replicas and caching — then rotates a real RDS credential hands-on in aws CLI and Terraform.

Storing a secret is easy. Changing it safely, on a schedule, while a live fleet is authenticating against it every second, is the hard problem — and it is the one Secrets Manager exists to solve. A static database password that never changes is a standing liability: it leaks into a .env file, a CI log, a Slack message, three laptops and a screenshot, and once it is out there is no expiry to save you. The security answer is rotation — change it often enough that a leaked copy is worthless within days — but rotation by hand is terrifying, because the instant you change the password in the database, every application still holding the old one starts failing authentication. Rotation “done right” means the credential changes and nothing breaks, and that requires a specific choreography of versions, labels and steps that this article is entirely about.

This is the deep, decision-by-decision treatment of automatic rotation for an engineer who has to run it in production, not just click “enable rotation” and hope. We build the mental model of a secret as a versioned object with staging labels, then walk the four-step rotation function line by line and show exactly how finishSecret moves the AWSCURRENT label. We separate managed rotation (AWS runs the function for RDS, Aurora, Redshift and DocumentDB) from custom rotation (you write a Lambda for anything else), and we make the single-user vs alternating-users choice concrete — why alternating-users is the only strategy that gives genuinely zero-downtime rotation. We spend real time on the rotation function’s network needs, because a function that cannot reach both the database and the Secrets Manager endpoint is the single most common cause of a rotation that hangs. Then KMS encryption, resource policies and cross-account retrieval, multi-Region replicated secrets, client-side caching (the SDK cache and the Lambda extension), and a clear-eyed Secrets Manager vs Parameter Store SecureString comparison so you know when the $0.40/month is worth it. The hands-on lab rotates a real RDS PostgreSQL credential — create the secret, attach managed alternating-users rotation on a 30-day schedule, force a rotation, watch the staging labels move version to version, retrieve AWSCURRENT, and wire an app to fetch it — with a full teardown and a 16-row troubleshooting playbook.

By the end you can enable rotation on purpose and reason about every failure mode: read describe-secret and know exactly which version each label points at, choose managed over custom and alternating over single-user for the right reasons, place the rotation function on a network path that actually completes, retrieve secrets cheaply with a cache, share one across accounts and Regions, and diagnose a stuck AWSPENDING in under a minute. This maps to the secrets and data-protection domains of SCS-C02 (Security Specialty), DVA-C02 (Developer Associate) and SAA-C03 (Solutions Architect Associate).

What problem this solves

The problem Secrets Manager rotation solves is a long-lived credential is a breach waiting for a date. Every static password, API key and database credential in your estate is a copy of a secret that has almost certainly escaped its intended home — into source control, a build log, an environment variable dumped to a crash report, a developer’s shell history. Nothing about a static secret expires, so a copy stolen today is still valid next year. The only durable defense is to change the secret often enough that a leaked copy is stale before an attacker can use it — and to do that without an outage every time.

What breaks without rotation done right is not the storing; it is the changing. A team hand-rotates a production database password at 2 a.m., updates the app config, and forgets one service that was still using the old value — instant partial outage, and now they are debugging auth failures under pressure. Or they wire up Secrets Manager rotation, the rotation Lambda gets attached to the VPC so it can reach the private RDS instance, and nobody realizes that putting a Lambda in a VPC removes its default internet route — so its calls to the Secrets Manager API time out, the rotation hangs for the full function timeout and then fails, and the secret is now stuck with a half-finished AWSPENDING version. Or the app caches the secret value on boot and never re-reads it, so the first rotation quietly breaks every running instance until it is redeployed. Or someone enables rotation on an unencrypted-cross-account secret and the retrieving account gets AccessDenied because the KMS key policy was never updated. Each of these is a specific, common, avoidable failure — and each is in this article.

Who hits this: every developer wiring an app to a database, every platform engineer asked to “make us pass the audit” (rotation is a control in PCI-DSS, SOC 2, HIPAA and every CIS benchmark), every security engineer chasing down where a leaked key came from, and every SRE who has watched a rotation take down a service because the app cached the old value. Here is the whole field in one frame — the questions enabling rotation forces you to answer whether or not you noticed answering them:

The rotation question The naive default What it costs to get wrong Where in this article
How does the app not break mid-rotation? “We’ll update config too” Partial outage every rotation Secrets, versions & staging labels
Managed or custom rotation? Write a Lambda by hand Maintaining rotation code you didn’t need to Managed rotation / Custom rotation
Single-user or alternating-users? Single-user (default-looking) A brief auth blip on every rotation Managed rotation for databases
Can the rotation function reach the DB and the SM API? Attach to the VPC, done Rotation hangs then fails; AWSPENDING stuck The rotation function’s network
Does the app cache the secret forever? Read once at boot First rotation breaks every running instance Retrieving secrets & caching
Which KMS key encrypts it? The default aws/secretsmanager Can’t share cross-account; no key audit Encryption, resource policies & cross-account
One Region or many? One Regional outage or latency for far clients Multi-Region replicated secrets
Secrets Manager or Parameter Store? Whatever’s habitual Paying $0.40/secret for values that never rotate, or hand-building rotation Secrets Manager vs Parameter Store

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account, the AWS CLI v2 configured with a non-root IAM identity, and — for the lab — a running RDS or Aurora instance in a VPC. If you do not have one, build it first with Launch Amazon RDS (MySQL & PostgreSQL) Hands-On; that article’s private, encrypted PostgreSQL instance is exactly the target we rotate credentials for here. You should be comfortable reading a Lambda handler in Python and a Terraform resource block, and you should understand what a VPC, subnet, security group and NAT gateway are, because the hardest part of rotation is a networking problem, not a cryptography one.

This sits in the Security track and pairs tightly with three siblings. Its encryption story is built on envelope encryption with KMS — the full mechanism is in AWS KMS Hands-On: Encryption Keys and Envelope Encryption, and you should read it if “data key” and “key policy” are fuzzy. Its cheaper, rotation-less cousin for plain configuration is covered in AWS Systems Manager Parameter Store Hands-On: Config and SecureString — the last deep section here is a direct comparison so you can put each value in the right store. And when a rotated credential still will not connect, the network diagnosis overlaps heavily with RDS Connection Timeouts: A Troubleshooting Playbook, because a rotation function that cannot reach the database fails for the exact same reasons an application cannot.

Here is the assumed knowledge, why each piece matters specifically for rotation, and where to shore it up:

You should know… Why it matters here Brush up
What a VPC, subnet, SG, NAT are The rotation function’s reachability is the whole problem RDS Connection Timeouts Playbook
How KMS envelope encryption works Secrets are envelope-encrypted; retrieval needs kms:Decrypt AWS KMS Hands-On: Envelope Encryption
Reading a Lambda handler The rotation contract is a four-branch handler Any Lambda primer
Basic RDS operation The lab rotates an RDS credential Launch Amazon RDS Hands-On
What Parameter Store is You must know when not to use Secrets Manager Parameter Store Hands-On
IAM policies & resource policies Cross-account access needs both identity and resource policy Any IAM primer

Core concepts

Eight mental models make every later decision obvious.

A secret is not a value — it is a container of versions. When you think “the database password,” Secrets Manager thinks “the secret kv/lab/appdb, which currently has versions v3, v2, v1, each holding a different value.” You never overwrite a secret in place during rotation; you add a new version and then point a label at it. This is the entire reason rotation can be safe: the old value and the new value co-exist for the duration of the rotation, so nothing is ever momentarily absent.

Staging labels are movable pointers, not values. Three labels matter. AWSCURRENT points at the version everyone should be using right now — a bare GetSecretValue returns it. AWSPENDING points at the brand-new version a rotation is in the middle of creating and validating, not yet live. AWSPREVIOUS points at the version that used to be AWSCURRENT, kept so you can roll back. A label lives on exactly one version at a time; rotation is, at its core, the act of moving the AWSCURRENT label from the old version to the new one.

The application only ever reads AWSCURRENT. This is the promise that makes rotation invisible. Your code calls GetSecretValue with no version specified, gets whatever AWSCURRENT points at, and connects. It does not know or care that yesterday AWSCURRENT pointed at a different version. As long as your app re-reads the secret (or caches it with a short TTL and re-reads on failure), rotation is transparent to it.

Rotation is a four-step function, always in the same order. Whether AWS runs it (managed) or you write it (custom), a rotation function implements four steps invoked one at a time by Secrets Manager: createSecret (generate the new value, store it as AWSPENDING), setSecret (change the credential in the actual database/service), testSecret (verify the new value works), finishSecret (move AWSCURRENT to the AWSPENDING version). The order is load-bearing: you create and test the new credential before you make it current, so a broken rotation never leaves you with a live-but-wrong secret.

Every step must be idempotent. Secrets Manager can invoke any step more than once (retries, at-least-once delivery). createSecret must check whether an AWSPENDING version already exists before generating another; finishSecret must tolerate the label already being where it belongs. A rotation function that assumes exactly-once invocation is a rotation function that eventually loops or double-rotates.

Managed rotation means AWS operates the function; custom means you do. For the four database families — RDS, Aurora, Redshift, DocumentDB — Secrets Manager provides the rotation logic so you do not write or maintain a Lambda. For everything else — a third-party API key, an OAuth client secret, a service account — you write a custom Lambda that implements the same four steps against that thing’s API.

The rotation function needs to reach two different things. To rotate a database password it must reach the database (to run ALTER USER) and the Secrets Manager API (to read the pending version, write versions, move labels). For a private database that means running inside the VPC — and a function inside a VPC has no default internet egress, so its calls to the public Secrets Manager endpoint need a NAT gateway or a PrivateLink interface endpoint. Forgetting the second reachability is the number-one rotation failure.

Secrets are envelope-encrypted with KMS. Secrets Manager calls kms:GenerateDataKey to get a data key, encrypts the secret value with it, and stores the encrypted data key alongside — classic envelope encryption. To read the secret, a principal needs both secretsmanager:GetSecretValue and kms:Decrypt on the key. That second permission is why so many “I have GetSecretValue, why is it denied?” tickets are really KMS tickets.

Here is every moving part of a rotating secret in one table, with who sets it and the gotcha that bites:

Building block What it is You set it… Gotcha
Secret The named container (kv/lab/appdb) At create The name is in the ARN with a random 6-char suffix
Version One immutable value + its ID On every PutSecretValue/rotation Unlabeled versions are eventually deleted
Staging label A movable pointer to a version Rotation moves them; you can too A label is on exactly one version at a time
AWSCURRENT The live version everyone reads finishSecret moves it Bare GetSecretValue returns this
AWSPENDING The in-flight new version createSecret sets it Stuck = a rotation didn’t finish
AWSPREVIOUS The last-current version (rollback) Secrets Manager moves it automatically You don’t set this by hand
Rotation function The four-step Lambda (managed or custom) Attach via rotate-secret Must reach DB and SM endpoint
RotationRules Schedule + window On enable AutomaticallyAfterDays or ScheduleExpression
KMS key Envelope-encryption key At create (default or CMK) Cross-account needs a CMK, not the default
Resource policy Who else may read the secret Optional Grants cross-account; pair with the key policy
Replica A read copy in another Region Optional Billed as a separate secret

Secrets, versions and staging labels

Everything about safe rotation starts here. Internalize the version-and-label model and the rest of the article is bookkeeping.

The three staging labels (and custom ones)

A staging label is a human-readable pointer to a specific version of a secret. Secrets Manager reserves three names and lets you invent your own:

Staging label Points at Who moves it A GetSecretValue for it returns
AWSCURRENT The version in active use finishSecret (or you) The value your app should use — the default
AWSPENDING The new version mid-rotation createSecret (or you) The not-yet-live candidate value
AWSPREVIOUS The version that was current Secrets Manager, automatically The prior value — for rollback
Custom (e.g. AWSPREVIOUS-blue) Whatever you attach it to You Whatever that version holds

Three rules govern them and explain almost every surprise:

Rule Consequence
A label lives on exactly one version at a time Moving AWSCURRENT to a new version removes it from the old one
The default GetSecretValue (no version) returns AWSCURRENT Your app never needs to know version IDs
A version with no staging labels is deprecated and eventually removed Old rotated-out versions self-clean; don’t rely on ancient ones
AWSPREVIOUS is assigned automatically when AWSCURRENT moves You never set AWSPREVIOUS yourself in normal rotation
You can attach custom labels to pin versions Useful for blue/green or manual promotion flows

How the labels move during one rotation

Watch the labels shift across a single rotation of a secret whose current value lives on version v1:

Moment v1 v2 (new) What happened
Before rotation AWSCURRENT Steady state; app reads v1
After createSecret AWSCURRENT AWSPENDING New value created, not yet live
After setSecret AWSCURRENT AWSPENDING New password now set in the DB
After testSecret AWSCURRENT AWSPENDING New value confirmed to log in
After finishSecret AWSPREVIOUS AWSCURRENT Label moved; app now reads v2
Next rotation finishes (unlabeled → deprecated) AWSPREVIOUS v1 loses its last label and is removed later

The crucial line is the last-but-one: finishSecret does not delete v1 or edit its value. It moves the AWSCURRENT label onto v2, and because v1 was the current version, Secrets Manager automatically drops AWSPREVIOUS onto it. Your application, which reads AWSCURRENT, seamlessly starts getting v2 on its next fetch. Nothing was ever missing.

The secret value: structured JSON, not a bare string

A secret’s value can be any string up to 64 KB, but for database credentials it is a JSON object with fields Secrets Manager and its rotation functions understand. Managed rotation requires specific keys:

JSON field Required for Meaning
username All DB secrets The database user this credential is for
password All DB secrets The current password
engine Managed rotation postgres / mysql / oracle / sqlserver / mariadb
host Managed rotation The DB endpoint DNS name
port Managed rotation 5432 / 3306 / etc.
dbname Optional The default database/schema
dbInstanceIdentifier / dbClusterIdentifier RDS/Aurora managed rotation Ties the secret to the DB resource
masterarn Alternating-users rotation ARN of the superuser secret used to alter the second user

Inspect and manipulate versions and labels with these operations — this is the whole surface you use to observe rotation:

Operation What it does
describe-secret Shows VersionIdsToStages, RotationEnabled, RotationRules, LastRotatedDate, NextRotationDate
list-secret-version-ids Lists every version and its staging labels
get-secret-value Returns a version’s value; defaults to AWSCURRENT, or pass --version-stage/--version-id
put-secret-value Adds a new version, optionally with staging labels
update-secret-version-stage Moves a staging label between versions (this is what finishSecret calls)
get-random-password Generates a strong password (used by createSecret)
# See exactly which version each label points at
aws secretsmanager describe-secret --secret-id kv/lab/appdb \
  --query 'VersionIdsToStages'
# e.g. { "v2...": ["AWSCURRENT"], "v1...": ["AWSPREVIOUS"] }

# Read AWSCURRENT (the default) and, for rollback, AWSPREVIOUS
aws secretsmanager get-secret-value --secret-id kv/lab/appdb --query SecretString --output text
aws secretsmanager get-secret-value --secret-id kv/lab/appdb --version-stage AWSPREVIOUS --query SecretString --output text

How automatic rotation works: the four-step function

The rotation function is the contract at the heart of Secrets Manager. Secrets Manager invokes it four times per rotation, once for each step, passing the step name in the event. Understanding this contract lets you read a managed rotation’s behavior and write a custom one with equal confidence.

The invocation event

Every step invocation carries the same shape:

Event field Meaning
Step One of createSecret, setSecret, testSecret, finishSecret
SecretId The ARN of the secret being rotated
ClientRequestToken The version ID of the new (AWSPENDING) version — use it on every version call
RotationToken Present for cross-account rotation; passed to GetSecretValue for the target secret

The four steps, in order

# Step What it does Reads Writes
1 createSecret Generate a new value (e.g. GetRandomPassword) and store it AWSCURRENT (to copy structure) New AWSPENDING version
2 setSecret Apply the new credential to the real service (e.g. ALTER USER) AWSPENDING (+ master secret for alternating) The database (not the secret)
3 testSecret Prove the new credential works AWSPENDING Nothing — it just connects and runs a test query
4 finishSecret Promote AWSPENDING to AWSCURRENT Current labels Moves the AWSCURRENT staging label

Read the steps as a safety property: you create the new secret and set it in the database and test that it works, and only then, in finish, do you flip the label that your application follows. If testSecret fails, AWSCURRENT never moves — your app keeps using the old, working credential, and the rotation is marked failed with a harmless orphaned AWSPENDING version you can clean up.

What each step must guard against (idempotency)

Because any step can be retried, each has a defensive check:

Step Idempotency guard
createSecret If an AWSPENDING version already exists, reuse it — do not generate another password
setSecret Setting the same password twice is harmless; tolerate “password unchanged”
testSecret Read-only — inherently safe to repeat
finishSecret If AWSCURRENT is already on the pending version, return success — the move already happened

A custom rotation handler, in skeleton

This is the shape of every rotation Lambda — the managed ones do the same thing behind the scenes. It is deliberately condensed to show the four branches and the label move:

import boto3, json

sm = boto3.client("secretsmanager")

def handler(event, context):
    secret_id = event["SecretId"]
    token     = event["ClientRequestToken"]   # the AWSPENDING version id
    step      = event["Step"]

    if step == "createSecret":
        # Idempotency: only create if AWSPENDING doesn't already exist
        try:
            sm.get_secret_value(SecretId=secret_id, VersionId=token, VersionStage="AWSPENDING")
            return  # already created
        except sm.exceptions.ResourceNotFoundException:
            pass
        current = json.loads(sm.get_secret_value(SecretId=secret_id, VersionStage="AWSCURRENT")["SecretString"])
        current["password"] = sm.get_random_password(PasswordLength=32, ExcludeCharacters='/@"\\\'')["RandomPassword"]
        sm.put_secret_value(SecretId=secret_id, ClientRequestToken=token,
                            SecretString=json.dumps(current), VersionStages=["AWSPENDING"])

    elif step == "setSecret":
        pending = json.loads(sm.get_secret_value(SecretId=secret_id, VersionId=token, VersionStage="AWSPENDING")["SecretString"])
        # ... connect to the DB (as itself for single-user, or via masterarn for alternating)
        # ... ALTER USER <user> WITH PASSWORD <pending['password']>

    elif step == "testSecret":
        pending = json.loads(sm.get_secret_value(SecretId=secret_id, VersionId=token, VersionStage="AWSPENDING")["SecretString"])
        # ... open a connection with pending creds and run "SELECT 1" — raise on failure

    elif step == "finishSecret":
        meta = sm.describe_secret(SecretId=secret_id)
        current_version = next(v for v, stages in meta["VersionIdsToStages"].items() if "AWSCURRENT" in stages)
        if current_version == token:
            return  # already finished
        sm.update_secret_version_stage(SecretId=secret_id, VersionStage="AWSCURRENT",
                                       MoveToVersionId=token, RemoveFromVersionId=current_version)

The last branch is the whole point: update_secret_version_stage with MoveToVersionId=<pending> and RemoveFromVersionId=<current> is the atomic act that makes the new version live. You do not touch AWSPREVIOUS — Secrets Manager assigns it to the version you just moved AWSCURRENT off of.

Managed rotation for databases: single-user vs alternating-users

For RDS, Aurora, Redshift and DocumentDB, you should not be writing rotation code at all. Secrets Manager provides the rotation logic; you turn it on, point it at the database, choose a strategy, and set a schedule. Two strategies exist, and the choice between them is the single most important production decision in this article.

The two strategies

Dimension Single-user Alternating-users
How it rotates Changes the one user’s password in place Alternates between two users (app and app_clone), flipping which is active
Users required 1 2 (the function clones the first into the second)
Master/superuser secret Not required (user changes its own password) Required (masterarn) — to create/alter the alternate user
Downtime window A brief window where in-flight connections can fail None — a valid credential is always live
Best for Dev, low-concurrency, tolerant apps Production, high-concurrency, zero-downtime requirements
Privilege model The rotating user needs ALTER USER on itself The master user needs to manage both app users
Complexity Lowest Slightly higher (two users, grants cloned)

Why alternating-users is genuinely zero-downtime

With single-user rotation, setSecret runs ALTER USER app WITH PASSWORD '<new>'. At that instant, any application instance still holding the old password — and there is always at least one, because caches and connection pools do not update in lockstep — will fail authentication on its next new connection until it re-reads the secret. The window is short, but it is real, and under high connection churn it produces a visible blip of auth errors on every rotation.

With alternating-users, there are two database users that are functional clones (same grants, same schema access). At any moment, AWSCURRENT points at whichever one is “active.” Rotation works on the inactive user: it sets a fresh password on app_clone, tests it, and only then moves AWSCURRENT to app_clone. The old user (app) and its password remain fully valid the entire time — nothing is altered on the credential the running fleet is currently using. Applications drain onto the new user naturally as they re-read the secret; there is never a moment when the credential in AWSCURRENT does not work. That is what “zero-downtime rotation” actually means, and it is why alternating-users is the correct default for anything in production.

The master/superuser secret

Alternating-users needs a master secret — a separate Secrets Manager secret holding a privileged database user (often the RDS master user) — because the rotation function cannot change a user’s password using a user that is mid-rotation; it needs a stable, privileged identity to create and alter the alternate user and clone its grants. You reference it by putting its ARN in the app secret’s masterarn field.

Master-secret fact Detail
Why needed To CREATE/ALTER the alternate app user and clone grants
What it holds A privileged DB user (e.g. the RDS master)
How it’s referenced masterarn field in the app secret’s JSON
Single-user need Not required — the user rotates its own password
Security note Scope it tightly; it is the most powerful secret in the chain

Supported managed engines and the schedule

Engine / service Managed rotation Single-user Alternating-users
Amazon RDS — PostgreSQL, MySQL, MariaDB, Oracle, SQL Server Yes Yes Yes
Amazon Aurora — PostgreSQL / MySQL Yes Yes Yes
Amazon Redshift Yes Yes Yes
Amazon DocumentDB Yes Yes Yes
Anything else (API keys, 3rd-party, non-DB) No — use custom via your code via your code

Rotation is governed by RotationRules, set when you enable it:

Setting Values Default Notes
AutomaticallyAfterDays 1–365 The simple “rotate every N days” schedule
ScheduleExpression rate() or cron() (UTC) Precise scheduling, e.g. cron(0 3 1 * ? *)
Duration 1h24h window Off The window rotation must start within
RotateImmediately true / false true On enable, rotate once now to prove it works
RotationEnabled true / false false until enabled Shown in describe-secret
# Enable managed rotation, alternating-users, every 30 days, in a 3-hour window.
# For alternating-users the app secret's JSON must carry "masterarn".
# (The ...MultiUser template is the alternating-users strategy; managed rotation
#  removes even the need to name a Lambda — you pick it in the console/RDS integration.)
aws secretsmanager rotate-secret \
  --secret-id kv/lab/appdb \
  --rotation-lambda-arn arn:aws:lambda:us-east-1:111122223333:function:SecretsManagerRDSPostgreSQLRotationMultiUser \
  --rotation-rules '{"AutomaticallyAfterDays":30,"Duration":"3h"}'

Custom rotation for everything else

Anything that is not one of the four managed database families needs a custom rotation function — you write a Lambda that implements the same createSecret → setSecret → testSecret → finishSecret contract against that thing’s API. The steps are identical in spirit; only setSecret and testSecret change, because “set the credential” and “test the credential” mean calling a different API.

Custom-rotation target setSecret calls… testSecret calls…
Third-party API key (Stripe, Datadog, etc.) The vendor’s “create/roll key” API A cheap authenticated read against the vendor
OAuth client secret The IdP’s rotate-client-secret API A token request with the new secret
A self-managed database (on EC2, on-prem) Your own ALTER USER over a connection A SELECT 1 with the new credentials
An HMAC signing key Your key-management endpoint A sign+verify round trip
A service account password The directory’s set-password API An authenticated bind

The four-step scaffolding to implement:

Step Custom implementation
createSecret Generate/obtain the new value; put-secret-value as AWSPENDING (idempotent)
setSecret Call the external service to make the new value valid there
testSecret Authenticate to the external service with the AWSPENDING value
finishSecret update-secret-version-stage to move AWSCURRENT onto the pending version

Two custom-rotation realities bite teams:

# Attach a custom rotation Lambda to a non-database secret
aws secretsmanager rotate-secret \
  --secret-id kv/lab/stripe-key \
  --rotation-lambda-arn arn:aws:lambda:us-east-1:111122223333:function:kv-stripe-rotator \
  --rotation-rules '{"AutomaticallyAfterDays":90}'
resource "aws_secretsmanager_secret_rotation" "stripe" {
  secret_id           = aws_secretsmanager_secret.stripe.id
  rotation_lambda_arn = aws_lambda_function.stripe_rotator.arn
  rotation_rules {
    automatically_after_days = 90
  }
}

# The Lambda needs permission for Secrets Manager to invoke it
resource "aws_lambda_permission" "allow_secretsmanager" {
  statement_id  = "AllowSecretsManagerInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.stripe_rotator.function_name
  principal     = "secretsmanager.amazonaws.com"
}

The rotation function’s network (the #1 cause of hangs)

This is where more rotations die than anywhere else, and it is not a cryptography problem — it is a routing problem. A rotation function that rotates a database password must reach two entirely different endpoints, and it is trivially easy to give it one and forget the other.

The two things every DB rotation function must reach

It must reach… To do what If it can’t…
The database (5432 / 3306 / …) Run ALTER USER, run the test query setSecret/testSecret fail with a connection timeout
The Secrets Manager API endpoint (443) Read AWSPENDING, PutSecretValue, move labels Rotation hangs at the first API call, then times out

The reason both are hard at once is a networking catch-22. If the database is private (as it should be), the function must be attached to the VPC to reach it. But a Lambda attached to a VPC uses that VPC’s routing for all egress — including its calls to the public Secrets Manager endpoint — and a private subnet has no direct internet route. So the very act of making the function able to reach the private database removes its ability to reach the Secrets Manager API, unless you also give it one of two routes.

The reachability matrix

Database placement Function placement Reaches DB? Reaches SM API? Verdict
Public (bad) No VPC Yes Yes (public egress) Works, but the DB shouldn’t be public
Private No VPC No Yes Fails at setSecret — can’t reach the DB
Private VPC, no NAT, no endpoint Yes No Hangs — can’t reach the SM API
Private VPC + NAT gateway Yes Yes (via NAT) Works
Private VPC + SM interface endpoint Yes Yes (via PrivateLink) Works — no NAT cost, stays private

The two fixes for the SM-endpoint reachability

Option How it works Cost Reach for it when
NAT gateway Private-subnet route 0.0.0.0/0 → NAT gives internet egress to the public SM endpoint ~$0.045/hr + data processing You already run a NAT for other egress
Secrets Manager interface endpoint (PrivateLink) An ENI in your subnets for com.amazonaws.<region>.secretsmanager; traffic never leaves AWS ~$0.01/hr/AZ + data You want no NAT, lowest cost, private-only

For the interface-endpoint route, the endpoint’s security group must allow inbound 443 from the rotation function’s SG, and — a subtle one — Private DNS must be enabled on the endpoint so the standard secretsmanager.<region>.amazonaws.com name resolves to the private ENI without any code change.

The security-group choreography

Both hops are also gated by security groups, and both must be open:

Security group Rule needed Direction
Rotation function SG Egress to DB port (5432/3306) Outbound
Rotation function SG Egress to 443 (SM endpoint / NAT) Outbound
Database SG Inbound DB port from the function SG Inbound
SM interface-endpoint SG Inbound 443 from the function SG Inbound
# Create a Secrets Manager interface endpoint so an in-VPC rotation function
# can reach the SM API without a NAT gateway.
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --vpc-endpoint-type Interface \
  --service-name com.amazonaws.us-east-1.secretsmanager \
  --subnet-ids subnet-0aaa1111 subnet-0bbb2222 \
  --security-group-ids sg-0endpoint443 \
  --private-dns-enabled
resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = var.vpc_id
  service_name        = "com.amazonaws.us-east-1.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = var.private_subnet_ids
  security_group_ids  = [aws_security_group.sm_endpoint.id]
  private_dns_enabled = true   # so the standard hostname resolves privately
}

The unmistakable signature of this failure: the rotation function’s CloudWatch logs show createSecret starting and then nothing — no error, just silence — until a Task timed out after 30.00 seconds line. The function reached the point of calling the Secrets Manager API and the call had nowhere to go. If you see that, you have an endpoint-reachability problem, every time.

Encryption, resource policies & cross-account

KMS encryption

Every secret is envelope-encrypted with a KMS key. You choose which one at creation:

Key choice What it is Cross-account? Cost Reach for it when
aws/secretsmanager (default) AWS-managed key, auto-created No No monthly key charge; requests billed Single-account secrets, simplest setup
Customer-managed CMK A key you create and control Yes ~$1/key/month + requests Cross-account, key-usage audit, custom rotation of the key, org policy

The permissions that trip people up: to read a secret encrypted with a CMK, a principal needs both secretsmanager:GetSecretValue and kms:Decrypt on that key. To rotate, the function additionally needs kms:GenerateDataKey. A great many “I have GetSecretValue, why AccessDenied?” incidents are missing kms:Decrypt.

Action Secrets Manager permission KMS permission
Read a secret secretsmanager:GetSecretValue kms:Decrypt
Write a version secretsmanager:PutSecretValue kms:GenerateDataKey, kms:Decrypt
Rotate secretsmanager:* (rotation set) kms:GenerateDataKey, kms:Decrypt
Cross-account read Resource policy grant on the secret Key policy grant kms:Decrypt to the other account

Resource policies

A resource policy attached to the secret grants principals — including principals in other accounts — access to it, complementing the identity policies in those accounts. Use validate-resource-policy and keep BlockPublicPolicy on to refuse a policy that would make the secret world-readable.

Resource-policy element Purpose
Principal Who (an account, role, or * gated by conditions)
Action Usually secretsmanager:GetSecretValue
Resource The secret ARN (* within the policy, meaning this secret)
Condition Tighten by VPC endpoint, source account, tags, PrincipalOrgID
BlockPublicPolicy Account setting that refuses a public resource policy

Cross-account retrieval

Sharing a secret with another account takes two grants, and the classic failure is doing only one:

Requirement Why
Secret resource policy grants the other account GetSecretValue Identity in the other account isn’t enough alone
The secret is encrypted with a CMK (not the default key) The default aws/secretsmanager key can’t be shared cross-account
The CMK key policy grants the other account kms:Decrypt Without it, the retrieve fails at decryption
The other account’s principal has an identity policy allowing the call Both sides must permit it
# Grant account 444455556666 read access to this secret (resource policy)
aws secretsmanager put-resource-policy \
  --secret-id kv/lab/appdb \
  --block-public-policy \
  --resource-policy '{
    "Version":"2012-10-17",
    "Statement":[{
      "Effect":"Allow",
      "Principal":{"AWS":"arn:aws:iam::444455556666:root"},
      "Action":"secretsmanager:GetSecretValue",
      "Resource":"*"
    }]
  }'
# ...and don't forget: the CMK key policy must also grant 444455556666 kms:Decrypt.

Multi-Region replicated secrets

A replicated secret is a read copy of a secret in another Region, kept in sync with the primary by Secrets Manager. It exists so a client (or a regional workload, or a disaster-recovery standby) can read the secret locally — lower latency, and no dependency on the primary Region being up.

Aspect Behavior
Source of truth The primary; replicas are read-only copies
Rotation Happens on the primary and propagates to replicas — you don’t rotate a replica
KMS key Each replica uses a Region-local key (default or a CMK in that Region)
ARN Each replica has its own ARN (same name, different Region)
Billing Each replica is billed as a separate secret (~$0.40/mo each)
Promotion You can promote a replica to a standalone primary (e.g. for DR)
Replica rule Detail
Add a Region replicate-secret-to-regions (or --add-replica-regions at create)
Client reads Read the local replica ARN in that Region — no cross-Region call
Rotation direction Primary rotates; replica reflects the new AWSCURRENT
Remove a replica remove-regions-from-replication
Key per Region Specify KmsKeyId per replica Region
# Replicate an existing secret into a second Region with a Region-local CMK
aws secretsmanager replicate-secret-to-regions \
  --secret-id kv/lab/appdb \
  --add-replica-regions Region=us-west-2,KmsKeyId=alias/kv-secrets-usw2
resource "aws_secretsmanager_secret" "appdb" {
  name       = "kv/lab/appdb"
  kms_key_id = aws_kms_key.secrets.arn

  replica {
    region     = "us-west-2"
    kms_key_id = aws_kms_key.secrets_usw2.arn
  }
}

Retrieving secrets & client-side caching

How you read a secret matters for latency, cost and correctness. A naive GetSecretValue on every request is slow (a network call in your hot path) and adds up in cost (billed per 10,000 calls). Caching fixes both — but a cache that never refreshes reintroduces the “app broke after rotation” bug. The right pattern is cache with a short TTL, always serve AWSCURRENT, and re-fetch on an auth failure.

The retrieval methods

Method What it is Best for
GetSecretValue (SDK/CLI) One secret, one call Simple, low-frequency reads
BatchGetSecretValue Several secrets in one call Fetching many secrets at startup
Caching client library In-process cache (Java/Python/Go/.NET/JS) Long-running services — cuts calls and latency
Parameters and Secrets Lambda Extension A layer with a local HTTP cache Lambda functions — cache across warm invocations

The SDK caching library

The AWS-provided caching libraries keep the secret in memory and refresh it on an interval (default about 1 hour), always returning the AWSCURRENT value. They turn thousands of API calls into a handful.

Caching-client setting Default Effect
Cache TTL / refresh interval ~3600s How often it re-fetches AWSCURRENT
Max cache size ~1024 secrets LRU eviction beyond this
Version stage AWSCURRENT What it serves (override for AWSPREVIOUS rollback)
Refresh on miss Yes Fetches when not cached

The Parameters and Secrets Lambda Extension

For Lambda, add the AWS-provided extension layer and your function calls a local HTTP endpoint instead of the SDK. The extension holds an in-memory cache that survives across warm invocations, so most invocations pay zero Secrets Manager latency or cost.

Extension detail Value
Local endpoint http://localhost:2773/secretsmanager/get?secretId=<name>
Auth header X-Aws-Parameters-Secrets-Token: $AWS_SESSION_TOKEN
PARAMETERS_SECRETS_EXTENSION_HTTP_PORT 2773 (default)
SECRETS_MANAGER_TTL Cache TTL in seconds (default 300)
PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE Max cached items (default 1000)
PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED true
# Fetching a secret via the Lambda extension (no SDK call, cached locally)
import os, urllib.request, json

def get_secret(name):
    req = urllib.request.Request(
        f"http://localhost:2773/secretsmanager/get?secretId={name}",
        headers={"X-Aws-Parameters-Secrets-Token": os.environ["AWS_SESSION_TOKEN"]})
    body = json.load(urllib.request.urlopen(req))
    return json.loads(body["SecretString"])

The correctness rule that ties this section to rotation: on an authentication failure, invalidate the cache and re-fetch. A rotation moved AWSCURRENT; a cache holding the old value will fail once, and a re-fetch will pick up the new value. Bake that into your connection error handling and rotation is fully transparent even with aggressive caching.

Secrets Manager vs Parameter Store SecureString

Not every value belongs in Secrets Manager. Systems Manager Parameter Store stores configuration and, as a SecureString, KMS-encrypted secrets too — for free at the standard tier. The dividing line is rotation and features: if a value needs built-in rotation, cross-account resource policies, or multi-Region replication, it belongs in Secrets Manager; if it is config or a rarely-changed secret, Parameter Store is the cheaper home. The full Parameter Store treatment is in AWS Systems Manager Parameter Store Hands-On.

Dimension Secrets Manager Parameter Store SecureString
Built-in rotation Yes — managed & custom No — build it yourself (EventBridge + Lambda)
Cost per secret/param $0.40/secret/month Free (standard); $0.05/mo (advanced)
API call cost $0.05 per 10,000 calls Free (standard); charged for higher-throughput
Max value size 64 KB 4 KB (standard) / 8 KB (advanced)
Cross-account access Resource policies (native) Via shared IAM roles / RAM (advanced)
Multi-Region replication Native replicas Roll your own
Versioning Versions + staging labels Version history (integer versions)
Encryption KMS (always) KMS for SecureString only
RDS integration Native (“manage master user password”) No
Hierarchical paths Slash-named, no tree queries Native /path/ hierarchy + GetParametersByPath
Random password generation GetRandomPassword No
Throughput default Higher 40 TPS (raise on advanced/high-throughput)

Which to use when

If the value… Use Because
Is a DB credential that must rotate Secrets Manager Managed rotation is the whole point
Is plain config (feature flags, URLs, ARNs) Parameter Store (String) Free, hierarchical, versioned
Is a secret that rarely/never rotates and is single-account Parameter Store SecureString KMS-encrypted and free
Must be shared cross-account by policy Secrets Manager Native resource policies
Must be replicated to many Regions Secrets Manager Native replicas
Is larger than 8 KB Secrets Manager 64 KB ceiling
Is one of hundreds of config values in a tree Parameter Store GetParametersByPath, no per-item fee

A common, sensible pattern: keep rotating database and third-party credentials in Secrets Manager, and keep the bulk of application configuration — the non-secret and rarely-changed values — in Parameter Store, referencing them by hierarchy. You pay the $0.40/month only for the values that actually earn it with rotation.

Architecture at a glance

Read the diagram left to right as the real rotation path you are about to build. On the left, your application calls GetSecretValue and gets back whatever AWSCURRENT points at — it caches that value briefly (the SDK cache or the Lambda extension on localhost:2773) and re-fetches on an auth failure, so it never sees a rotation happen. In the center sits the secret in Secrets Manager, envelope-encrypted by a KMS key, holding multiple versions with the movable staging labels. On schedule, Secrets Manager invokes the rotation function (managed for RDS/Aurora, or your custom Lambda), which runs the four steps createSecret → setSecret → testSecret → finishSecret. To do its job it runs inside your VPC — which means it must reach both the database (through security groups on the engine port) and the Secrets Manager API (through a NAT gateway or a PrivateLink interface endpoint on 443, the hop everyone forgets). On the right, the RDS/Aurora instance is where setSecret runs ALTER USER; with the alternating-users strategy a second user keeps a valid credential live the entire time, so rotation is zero-downtime. Each numbered badge marks a decision or a failure class, and the legend narrates each as symptom, how to confirm, and the fix.

AWS Secrets Manager automatic rotation path: an application reads the AWSCURRENT version through a short-TTL cache and never sees the change, while Secrets Manager (KMS-encrypted, holding versions with AWSCURRENT/AWSPENDING/AWSPREVIOUS staging labels) invokes a four-step rotation function that runs inside the VPC and must reach both the RDS/Aurora database on the engine port and the Secrets Manager API endpoint through a NAT gateway or PrivateLink interface endpoint on 443, running createSecret then setSecret then testSecret then finishSecret which moves the AWSCURRENT label onto the new version, with the alternating-users strategy keeping a second database user valid for zero-downtime rotation

Real-world scenario

Meridian Freight is a fictional logistics SaaS running a Django fleet on ECS behind an ALB, backed by a private Aurora PostgreSQL cluster. For two years the app connected with a single database password stored in an ECS task-definition environment variable — visible in the console, in CloudTrail RegisterTaskDefinition events, and in every developer’s aws ecs describe-task-definition output. A SOC 2 auditor flagged it: the credential had never been rotated, and at least nine people had seen it. The remediation ticket read simply “rotate DB credentials every 30 days, no downtime.” It took three attempts to get right, and each failure is a section of this article.

Attempt one — the hang. An engineer enabled rotation in the console with a single-user strategy, and the rotation function was attached to the Aurora VPC so it could reach the cluster. The first rotation started, createSecret ran, and then the function went silent for thirty seconds and failed with Task timed out. The secret was now stuck with an AWSPENDING version and RotationEnabled: true but LastRotatedDate unchanged. The cause was textbook: the function was in private subnets to reach Aurora, but those subnets had no route to the Secrets Manager API — no NAT, no interface endpoint — so PutSecretValue had nowhere to go. The fix was one resource: a Secrets Manager interface endpoint with private DNS enabled and its SG open to the function’s SG. The next rotation completed in eight seconds.

Attempt two — the blip. With rotation now completing, the team noticed a small spike of FATAL: password authentication failed errors in the app logs at exactly the rotation timestamp, every rotation. It was brief — a few seconds — but on a payments-adjacent service even a few seconds of auth errors triggered pages. The cause was the single-user strategy: setSecret changed the one user’s password in place, and the ECS tasks holding the old value in their pools failed until they re-read the secret. The fix was to switch to the alternating-users strategy, which required creating a master secret (the Aurora superuser) and adding its ARN as masterarn in the app secret. Now rotation flips between app_svc and app_svc_clone; the credential in AWSCURRENT is always valid, and the blip vanished.

Attempt three — the cache. Rotation was now clean, but one background worker — a nightly batch job that ran for hours — still failed after a rotation that happened mid-run. It had read the secret once at process start and cached it forever. The fix was the caching discipline this article preaches: use the SDK caching client with a short TTL, and, critically, invalidate and re-fetch on any authentication error. The batch job now catches the auth exception, re-reads AWSCURRENT, and reconnects; a rotation mid-run costs it one reconnect, not a failed job.

The finished design is exactly the lab below: an Aurora app secret with managed alternating-users rotation on a 30-day schedule, a master secret for the superuser, a Secrets Manager interface endpoint so the in-VPC rotation reaches the API privately, a customer CMK encrypting both secrets (so the security team gets kms:Decrypt audit trails in CloudTrail), and application code that reads AWSCURRENT through a short-TTL cache and re-fetches on failure. Rotation now runs monthly, invisibly, and the auditor’s finding is closed. The lesson the platform lead repeats: “Secrets Manager rotation is easy to turn on and easy to get subtly wrong — the failures are never in the crypto, they’re in the network and the cache.”

Advantages and disadvantages

Advantages Disadvantages
Built-in, scheduled rotation with a proven four-step contract $0.40/secret/month adds up across hundreds of secrets
The app reads only AWSCURRENT — rotation is transparent to it The rotation function’s VPC/endpoint networking is easy to get wrong
Managed rotation for RDS/Aurora/Redshift/DocumentDB — no code Custom rotation for anything else is real Lambda you must maintain
Alternating-users gives genuinely zero-downtime rotation Alternating-users needs a privileged master secret to manage
KMS envelope encryption, native cross-account resource policies Cross-account needs a CMK and two policy grants — a common miss
Native multi-Region replicas with per-Region keys Each replica is billed as a separate secret
Random password generation and RDS “manage master user password” Caching wrong reintroduces “broke after rotation”
Versioning + staging labels enable clean rollback (AWSPREVIOUS) More moving parts than a static Parameter Store SecureString

Secrets Manager wins whenever a credential must change on a schedule without an outage — database passwords, rotating API keys, anything a compliance control forces you to rotate. It loses its edge for plain configuration and rarely-changed single-account secrets, where Parameter Store’s free SecureString does the encryption job without the per-secret fee. Reach for Secrets Manager for the rotating credentials that earn the $0.40, and keep the rest in Parameter Store.

Hands-on lab

You will create a Secrets Manager secret for an RDS PostgreSQL application user, attach managed alternating-users rotation on a 30-day schedule, force a rotation and watch the staging labels move version to version, retrieve AWSCURRENT, and wire a small app snippet to fetch it — then tear it all down. Everything is aws CLI first, with a Terraform stack after. ⚠️ Secrets Manager ($0.40/secret/mo), the rotation Lambda, KMS, and any NAT/endpoint all incur charges — do the teardown.

Assumptions: AWS CLI v2 configured, and a running private RDS PostgreSQL instance (kv-lab-pg) in a VPC with two private subnets — build it with Launch Amazon RDS Hands-On if you don’t have one. Export a few variables:

export AWS_REGION=us-east-1
export VPC_ID=vpc-0abc123def456
export SUBNET_A=subnet-0aaa11112222       # private, AZ a
export SUBNET_B=subnet-0bbb33334444       # private, AZ b
export DB_ID=kv-lab-pg
export DB_ENDPOINT=$(aws rds describe-db-instances --db-instance-identifier "$DB_ID" \
  --query 'DBInstances[0].Endpoint.Address' --output text)
export DB_SG=$(aws rds describe-db-instances --db-instance-identifier "$DB_ID" \
  --query 'DBInstances[0].VpcSecurityGroups[0].VpcSecurityGroupId' --output text)
echo "DB_ENDPOINT=$DB_ENDPOINT  DB_SG=$DB_SG"

Step 1 — a customer CMK for the secrets

A CMK gives you kms:Decrypt audit trails and is required if you ever share cross-account.

KEY_ID=$(aws kms create-key --description "KloudVin lab secrets key" \
  --query 'KeyMetadata.KeyId' --output text)
aws kms create-alias --alias-name alias/kv-secrets --target-key-id "$KEY_ID"
echo "KEY_ID=$KEY_ID"

Expected: a key ID and the alias alias/kv-secrets.

Step 2 — the master (superuser) secret for alternating-users

Alternating-users rotation needs a privileged secret to create and alter the second app user. Use the RDS master credentials (here read from the RDS-managed master secret; substitute your own master user/password).

aws secretsmanager create-secret \
  --name kv/lab/pg-master \
  --description "PG superuser for alternating-users rotation" \
  --kms-key-id alias/kv-secrets \
  --secret-string "{\"engine\":\"postgres\",\"host\":\"$DB_ENDPOINT\",\"port\":5432,\"username\":\"kvadmin\",\"password\":\"REPLACE_WITH_MASTER_PW\",\"dbname\":\"appdb\",\"dbInstanceIdentifier\":\"$DB_ID\"}"

MASTER_ARN=$(aws secretsmanager describe-secret --secret-id kv/lab/pg-master \
  --query 'ARN' --output text)
echo "MASTER_ARN=$MASTER_ARN"

Expected: an ARN like ...secret:kv/lab/pg-master-AbCdEf.

Step 3 — the application secret (references the master via masterarn)

aws secretsmanager create-secret \
  --name kv/lab/appdb \
  --description "App DB credentials (rotated, alternating-users)" \
  --kms-key-id alias/kv-secrets \
  --secret-string "{\"engine\":\"postgres\",\"host\":\"$DB_ENDPOINT\",\"port\":5432,\"username\":\"app_svc\",\"password\":\"REPLACE_WITH_APP_PW\",\"dbname\":\"appdb\",\"dbInstanceIdentifier\":\"$DB_ID\",\"masterarn\":\"$MASTER_ARN\"}"

Expected: the app secret’s ARN. Note masterarn in the JSON — that is what makes alternating-users possible. (Create the app_svc role in the database first, e.g. CREATE ROLE app_svc LOGIN PASSWORD '...'.)

Step 4 — ensure the rotation function can reach the SM API (interface endpoint)

Because rotation runs in the VPC to reach the private DB, give it a private path to the Secrets Manager API so it never hangs:

EP_SG=$(aws ec2 create-security-group --group-name kv-sm-endpoint-sg \
  --description "SM interface endpoint SG" --vpc-id "$VPC_ID" --query GroupId --output text)
# Allow 443 into the endpoint from within the VPC (tighten to the function SG in prod)
aws ec2 authorize-security-group-ingress --group-id "$EP_SG" \
  --protocol tcp --port 443 --cidr 10.0.0.0/16

aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" --vpc-endpoint-type Interface \
  --service-name com.amazonaws.$AWS_REGION.secretsmanager \
  --subnet-ids "$SUBNET_A" "$SUBNET_B" \
  --security-group-ids "$EP_SG" --private-dns-enabled

Expected: a vpce-... endpoint ID moving to available. This is the single step that prevents the classic rotation hang.

Step 5 — attach managed alternating-users rotation, every 30 days

aws secretsmanager rotate-secret \
  --secret-id kv/lab/appdb \
  --rotation-lambda-arn arn:aws:lambda:$AWS_REGION:$(aws sts get-caller-identity --query Account --output text):function:SecretsManagerRDSPostgreSQLRotationMultiUser \
  --rotation-rules '{"AutomaticallyAfterDays":30,"Duration":"3h"}'

Expected: the call returns and RotateImmediately (default true) kicks off the first rotation. If you have not deployed the AWS rotation template Lambda, enable rotation from the RDS or Secrets Manager console (which deploys/attaches it for you) and choose Alternating users + 30 days — the console path is the managed-rotation experience and needs no Lambda maintenance.

Step 6 — force a rotation and watch the labels move

Force an immediate rotation and inspect the version-to-label map before and after:

# Before: note which version is AWSCURRENT
aws secretsmanager describe-secret --secret-id kv/lab/appdb --query 'VersionIdsToStages'

# Force a rotation now
aws secretsmanager rotate-secret --secret-id kv/lab/appdb

# Poll until LastRotatedDate updates (rotation completed)
aws secretsmanager describe-secret --secret-id kv/lab/appdb \
  --query '{Enabled:RotationEnabled,Last:LastRotatedDate,Next:NextRotationDate}'

# After: AWSCURRENT has moved to a new version; the old one now holds AWSPREVIOUS
aws secretsmanager describe-secret --secret-id kv/lab/appdb --query 'VersionIdsToStages'
aws secretsmanager list-secret-version-ids --secret-id kv/lab/appdb \
  --query 'Versions[].{Version:VersionId,Stages:VersionStages}'

Expected: before, one version has ["AWSCURRENT"]; after, a new version has ["AWSCURRENT"] and the previous one has ["AWSPREVIOUS"]. You have just watched finishSecret move the label.

Step 7 — retrieve AWSCURRENT (and AWSPREVIOUS for rollback)

# The value your app uses (AWSCURRENT is the default)
aws secretsmanager get-secret-value --secret-id kv/lab/appdb \
  --query SecretString --output text | jq '{username, host, port}'

# The prior value — how you'd roll back a bad rotation
aws secretsmanager get-secret-value --secret-id kv/lab/appdb \
  --version-stage AWSPREVIOUS --query SecretString --output text | jq '.username'

Expected: the current credential’s username (e.g. app_svc_clone after a rotation) and the previous one (e.g. app_svc) — proof the two app users alternate.

Step 8 — wire an app to fetch the secret

The application never references a version — it reads AWSCURRENT and re-fetches on auth failure:

import boto3, json, psycopg2

sm = boto3.client("secretsmanager")
_cache = {}

def get_creds(force=False):
    if force or "v" not in _cache:
        _cache["v"] = json.loads(sm.get_secret_value(SecretId="kv/lab/appdb")["SecretString"])
    return _cache["v"]

def connect():
    c = get_creds()
    try:
        return psycopg2.connect(host=c["host"], port=c["port"], dbname=c["dbname"],
                                user=c["username"], password=c["password"], sslmode="require")
    except psycopg2.OperationalError:
        c = get_creds(force=True)          # a rotation moved AWSCURRENT — re-fetch
        return psycopg2.connect(host=c["host"], port=c["port"], dbname=c["dbname"],
                                user=c["username"], password=c["password"], sslmode="require")

Expected: the app connects with the current credential and transparently recovers across a rotation by re-fetching on the first failure.

Step 9 — teardown (⚠️ removes everything)

# Turn rotation off, then delete the secrets (force-delete skips the recovery window)
aws secretsmanager cancel-rotate-secret --secret-id kv/lab/appdb
aws secretsmanager delete-secret --secret-id kv/lab/appdb --force-delete-without-recovery
aws secretsmanager delete-secret --secret-id kv/lab/pg-master --force-delete-without-recovery

# Remove the interface endpoint and its SG
VPCE=$(aws ec2 describe-vpc-endpoints --filters "Name=vpc-id,Values=$VPC_ID" \
  "Name=service-name,Values=com.amazonaws.$AWS_REGION.secretsmanager" \
  --query 'VpcEndpoints[0].VpcEndpointId' --output text)
aws ec2 delete-vpc-endpoints --vpc-endpoint-ids "$VPCE"
aws ec2 delete-security-group --group-id "$EP_SG"

# Schedule the CMK for deletion (7-30 day waiting period; keys can't be deleted instantly)
aws kms disable-key --key-id "$KEY_ID"
aws kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7

Expected: the secrets delete immediately (with --force-delete-without-recovery; without it they enter a 7–30 day recovery window and keep billing), the endpoint and SG are removed, and the CMK is scheduled for deletion. A secret left in the recovery window still costs $0.40/month — force-delete or wait it out.

The whole thing as Terraform

terraform {
  required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
}
provider "aws" { region = "us-east-1" }

variable "vpc_id"             { type = string }
variable "private_subnet_ids" { type = list(string) }
variable "db_endpoint"        { type = string }
variable "db_id"              { type = string }
variable "master_password"    { type = string, sensitive = true }
variable "app_password"       { type = string, sensitive = true }
variable "rotation_lambda_arn"{ type = string }   # the AWS PG MultiUser template

resource "aws_kms_key" "secrets" {
  description             = "KloudVin lab secrets key"
  deletion_window_in_days = 7
}
resource "aws_kms_alias" "secrets" {
  name          = "alias/kv-secrets"
  target_key_id = aws_kms_key.secrets.key_id
}

# Interface endpoint so in-VPC rotation reaches the SM API privately
resource "aws_security_group" "sm_endpoint" {
  name   = "kv-sm-endpoint-sg"
  vpc_id = var.vpc_id
  ingress { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["10.0.0.0/16"] }
}
resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = var.vpc_id
  service_name        = "com.amazonaws.us-east-1.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = var.private_subnet_ids
  security_group_ids  = [aws_security_group.sm_endpoint.id]
  private_dns_enabled = true
}

resource "aws_secretsmanager_secret" "master" {
  name       = "kv/lab/pg-master"
  kms_key_id = aws_kms_key.secrets.arn
}
resource "aws_secretsmanager_secret_version" "master" {
  secret_id     = aws_secretsmanager_secret.master.id
  secret_string = jsonencode({
    engine = "postgres", host = var.db_endpoint, port = 5432,
    username = "kvadmin", password = var.master_password,
    dbname = "appdb", dbInstanceIdentifier = var.db_id
  })
}

resource "aws_secretsmanager_secret" "appdb" {
  name       = "kv/lab/appdb"
  kms_key_id = aws_kms_key.secrets.arn
}
resource "aws_secretsmanager_secret_version" "appdb" {
  secret_id     = aws_secretsmanager_secret.appdb.id
  secret_string = jsonencode({
    engine = "postgres", host = var.db_endpoint, port = 5432,
    username = "app_svc", password = var.app_password,
    dbname = "appdb", dbInstanceIdentifier = var.db_id,
    masterarn = aws_secretsmanager_secret.master.arn   # enables alternating-users
  })
}

resource "aws_secretsmanager_secret_rotation" "appdb" {
  secret_id           = aws_secretsmanager_secret.appdb.id
  rotation_lambda_arn = var.rotation_lambda_arn
  rotation_rules {
    automatically_after_days = 30
    duration                 = "3h"
  }
}

output "app_secret_arn" { value = aws_secretsmanager_secret.appdb.arn }

terraform apply builds the identical stack; the aws_secretsmanager_secret_rotation resource enables rotation (rotating immediately by default) so you can watch the labels move exactly as in Step 6.

Common mistakes & troubleshooting

The rotation playbook. Symptom → root cause → the exact command or console path to confirm → the fix. The connectivity failures overlap with RDS Connection Timeouts Troubleshooting Playbook — a rotation function that can’t reach the DB fails for the same reasons an app can’t.

# Symptom Root cause Confirm (exact command / path) Fix
1 Rotation hangs ~30s then fails; logs stop after createSecret Function is in the VPC (to reach the DB) but has no route to the Secrets Manager API CloudWatch logs show a Task timed out; no PutSecretValue line Add a NAT gateway route or a Secrets Manager interface endpoint (private DNS on) to the function’s subnets
2 Rotation fails at setSecret/testSecret with a connection timeout Function can reach the SM API but not the database (SG/subnet) describe-security-groups — DB SG has no ingress from the function SG Allow the DB engine port from the function’s SG; ensure the function is in subnets that route to the DB
3 AWSPENDING is stuck on a version; rotation won’t progress A previous rotation didn’t finish (failed at set/test/finish) describe-secret --query VersionIdsToStages shows a lingering AWSPENDING Fix the root cause, then rotate-secret again; if truly orphaned, update-secret-version-stage --remove-from-version-id <v> to drop the stale label
4 App gets FATAL: password authentication failed right after a rotation App cached the old value / read AWSCURRENT once and never re-read Compare the app’s in-memory value to get-secret-value Re-fetch on auth failure; use a short cache TTL; never hard-code the value
5 App intermittently uses the wrong (old) password App explicitly pinned AWSPREVIOUS or a version ID Check the --version-stage/--version-id in the app’s call Read AWSCURRENT (the default) unless you’re deliberately rolling back
6 AccessDeniedException on GetSecretValue despite the IAM allow Missing kms:Decrypt on the CMK get-secret-value error mentions KMS; check the caller’s KMS perms Grant kms:Decrypt on the encrypting key to the principal
7 Cross-account retrieve fails with AccessDenied Only one of the two grants is in place, or the secret uses the default key Check the secret resource policy and the CMK key policy Grant GetSecretValue in the resource policy and kms:Decrypt in the key policy; re-encrypt with a CMK if it used the default key
8 Alternating-users rotation fails: “cannot alter user” / permission denied No master secret or the masterarn user lacks privilege get-secret-value on the app secret — is masterarn present and valid? Add a valid masterarn whose user can CREATE/ALTER the app users; grant it the needed DB privileges
9 Rotation Lambda ends with Task timed out after N seconds at the DB step Function timeout too low for a slow DB connection/ALTER CloudWatch Duration near the configured timeout Raise the Lambda timeout (e.g. 30s → 120s); check DB connection latency
10 RotationEnabled: true but LastRotatedDate never updates First rotation is failing silently each cycle describe-secret; open the rotation Lambda’s CloudWatch logs Diagnose the step it dies on (usually #1 or #2); fix connectivity/permissions
11 Secrets Manager can't invoke the rotation function Missing lambda:InvokeFunction permission for secretsmanager.amazonaws.com Lambda resource policy has no Secrets Manager principal Add the add-permission/aws_lambda_permission granting Secrets Manager invoke
12 ResourceExistsException / can’t create secret The name already exists (or is in the recovery window) describe-secret / list-secrets for the name Use a new name, or restore-secret if it’s soft-deleted
13 Deleted a secret but it’s still billing delete-secret without force enters a 7–30 day recovery window describe-secret --query DeletedDate is set delete-secret --force-delete-without-recovery, or wait out the window
14 Rotation succeeds but the app in another Region reads the old value App reads the primary cross-Region, or the replica lagged Confirm the app uses the local replica ARN Point regional apps at the local replica; replicas reflect the primary’s AWSCURRENT
15 GetSecretValue throttling / high cost Reading the secret on every request with no cache CloudTrail volume of GetSecretValue; the bill Add the caching client or the Lambda extension (localhost:2773) with a TTL
16 MalformedPolicyDocumentException / policy rejected on put-resource-policy BlockPublicPolicy refused a too-broad policy (working as intended) The error names the offending statement Scope the Principal/add a Condition; never make a secret public

Rotation status & fields you’ll read

describe-secret field Meaning
RotationEnabled Whether rotation is on
RotationLambdaARN The function doing the rotation
RotationRules AutomaticallyAfterDays / ScheduleExpression / Duration
LastRotatedDate When the last successful rotation completed
NextRotationDate When the next one is scheduled
VersionIdsToStages The version → staging-label map (your primary diagnostic)
DeletedDate Set if the secret is in the recovery window

CloudWatch log signatures to recognize

Log signature (rotation Lambda) What it tells you
createSecret line, then silence, then Task timed out Can’t reach the SM API (endpoint/NAT) — playbook #1
Error at setSecret: connection timeout to the DB Can’t reach the DB (SG/subnet) — playbook #2
testSecret fails: authentication failed New password wasn’t actually set, or wrong user — check setSecret
AccessDeniedException ... kms:Decrypt Missing KMS permission — playbook #6
finishSecret runs but labels don’t move Idempotency bug or a permissions gap on UpdateSecretVersionStage

The nastiest three, in prose

The VPC endpoint catch-22. This is the failure that defines Secrets Manager rotation. To rotate a private database’s password, the rotation function must live in the VPC — and the moment it does, it can no longer reach the public Secrets Manager API unless you give it a NAT route or a PrivateLink interface endpoint. The tell is unmistakable: CloudWatch shows createSecret starting and then nothing for the entire function timeout, ending in Task timed out. There is no error message because the API call is not being rejected, it is going into a void. The interface endpoint is the better fix — it is cheaper than a NAT, keeps traffic on the AWS network, and needs only that its security group allow 443 from the function and that Private DNS be enabled so the standard hostname resolves to the private ENI.

The cache that outlives the rotation. The whole point of AWSCURRENT is that the app can read it obliviously — but only if the app actually re-reads it. A service that fetches the secret once at boot and caches it forever will sail through the first rotation with no trouble at all, right up until it needs a new database connection with a password that was rotated out from under it. The fix is a discipline, not a feature: cache with a short TTL, and treat any authentication failure as a signal to invalidate the cache and re-fetch AWSCURRENT before retrying. Do that and even a long-running batch job survives a mid-run rotation with a single reconnect.

Cross-account: one grant is never enough. Sharing a secret across accounts fails in a way that looks like an IAM problem but is half a KMS problem. You need four things aligned: the secret’s resource policy granting the other account GetSecretValue, the other account’s identity policy allowing the call, the secret encrypted with a customer CMK (the default aws/secretsmanager key cannot be shared), and that CMK’s key policy granting the other account kms:Decrypt. Teams routinely set the resource policy, watch it still fail with AccessDenied, and burn an afternoon before realizing the decrypt grant on the key was never added. If it uses the default key, you cannot share it at all until you re-encrypt with a CMK.

Best practices

Security notes

Rotation is a security control, and its own configuration is a security surface:

Cost & sizing

Secrets Manager billing is simple but easy to under-estimate at scale — it is per secret, and replicas count:

Cost driver How it’s billed Notes / how to control
Stored secret $0.40 per secret per month (prorated) The main lever; each replica is a separate $0.40
API calls $0.05 per 10,000 calls GetSecretValue in a hot loop adds up — cache it
Replicas $0.40/month each Region Only replicate where a local read is needed
KMS (default key) No key-month charge; requests billed Decrypt on retrieve, GenerateDataKey on write
KMS (CMK) ~$1/key/month + requests Buys cross-account + key audit
Rotation Lambda Standard Lambda pricing A few invocations per rotation — negligible
Interface endpoint ~$0.01/hr/AZ + data Cheaper than NAT for private API access
NAT gateway (if used) ~$0.045/hr + data processing Only if you don’t use an interface endpoint
Secret in recovery window Still $0.40/month until purged Force-delete labs; don’t leave soft-deleted secrets

Rough monthly figures (us-east-1, order-of-magnitude — confirm in the calculator; ~₹85/USD):

Setup Secrets Rough USD/mo Rough INR/mo
One rotating DB secret (+ master) 2 ~$0.80 + calls ~₹70
20 rotating app secrets, cached reads 20 ~$8 + minimal calls ~₹680
20 secrets, uncached (1M reads/mo) 20 ~$8 + ~$5 calls ~₹1,100
20 secrets replicated to 1 extra Region 40 ~$16 + calls ~₹1,360
CMK for the set 1 key ~$1 + requests ~₹85

Secrets Manager has no free tier beyond a 30-day trial per secret, unlike Parameter Store’s free standard tier. The cost-control levers are: cache reads (turns the per-call charge to near zero), keep only genuinely-rotating secrets here (put config in Parameter Store), replicate only where a local read is required, and force-delete secrets you’re done with so they stop billing during the recovery window.

Interview & exam questions

1. What do the AWSCURRENT, AWSPENDING and AWSPREVIOUS staging labels point at, and which does a default GetSecretValue return? They are movable pointers to secret versions: AWSCURRENT is the live version (and the default a bare GetSecretValue returns), AWSPENDING is the new version mid-rotation, and AWSPREVIOUS is the last-current version kept for rollback. AWSPREVIOUS is assigned automatically when AWSCURRENT moves. (SCS-C02, DVA-C02)

2. Walk the four steps of a rotation function and name the one that makes the new secret live. createSecret (store the new value as AWSPENDING), setSecret (change it in the database/service), testSecret (verify it works), finishSecret (move the AWSCURRENT label onto the pending version). finishSecret is the step that makes it live. (DVA-C02, SCS-C02)

3. Why does alternating-users rotation give zero downtime and single-user does not? Single-user changes the one user’s password in place, so instances holding the old value fail until they re-read. Alternating-users keeps two users and rotates the inactive one, so the credential in AWSCURRENT is always valid — nothing the running fleet is using is ever altered. (SCS-C02, SAP)

4. A rotation hangs for the function’s whole timeout then fails, with logs stopping after createSecret. What’s wrong? The function is in the VPC to reach a private DB but has no route to the Secrets Manager API endpoint. Add a NAT gateway route or a Secrets Manager interface (PrivateLink) endpoint with private DNS, and open its security group on 443. (SCS-C02, ANS)

5. What two reachability targets must a database rotation function have? The database (to run ALTER USER and the test query) and the Secrets Manager API endpoint (to read the pending version, write versions, and move labels). Missing the second is the classic hang. (SCS-C02)

6. An app gets auth failures immediately after every rotation. Root cause and fix? It cached the secret and didn’t re-read AWSCURRENT. Cache with a short TTL and re-fetch on any authentication failure before retrying the connection. (DVA-C02)

7. You have secretsmanager:GetSecretValue but still get AccessDenied. Why? The secret is encrypted with a KMS key and the principal lacks kms:Decrypt on it. Grant kms:Decrypt on the encrypting key. (SCS-C02)

8. What’s required to share a secret with another account? A resource policy on the secret granting the account GetSecretValue, the secret encrypted with a customer CMK (not the default key), and that CMK’s key policy granting the account kms:Decrypt, plus the account’s own identity policy. (SCS-C02, SAP)

9. When would you use custom rotation instead of managed? For anything that is not RDS, Aurora, Redshift or DocumentDB — third-party API keys, OAuth client secrets, self-managed databases — you write a Lambda implementing the same four-step contract against that service’s API. (DVA-C02)

10. Secrets Manager vs Parameter Store SecureString — the deciding factors? Secrets Manager has built-in rotation, native cross-account resource policies, multi-Region replicas and a 64 KB size, at $0.40/secret/month. Parameter Store SecureString is free (standard), KMS-encrypted, hierarchical, but has no native rotation and smaller size. Use Secrets Manager for rotating/shared secrets, Parameter Store for config and rarely-changed secrets. (SCS-C02, SAA-C03)

11. How do multi-Region replicas behave for rotation? Rotation happens on the primary and the new AWSCURRENT propagates to the read-only replicas; you don’t rotate a replica. Each replica uses a Region-local KMS key and is billed as a separate secret; you can promote one to standalone for DR. (SAP, SCS-C02)

12. What is AWSPENDING stuck on a version telling you? A previous rotation started (createSecret ran) but never finished — it failed at set/test/finish. Diagnose the failing step (usually connectivity), fix it, and re-run rotation; remove the stale label only if the version is truly orphaned. (SCS-C02)

Quick check

  1. Which staging label does a default GetSecretValue (no version specified) return?
  2. Name the four rotation steps in order, and say which one moves AWSCURRENT.
  3. A DB rotation function is attached to the VPC and can reach the database, but rotation hangs. What is it almost certainly missing?
  4. Why is alternating-users the zero-downtime strategy?
  5. You get AccessDenied on GetSecretValue despite an IAM allow for it. What permission is likely missing?

Answers

  1. AWSCURRENT — the live version everyone should use.
  2. createSecret → setSecret → testSecret → finishSecret; finishSecret moves the AWSCURRENT label onto the pending version.
  3. A route to the Secrets Manager API endpoint — a NAT gateway or a PrivateLink interface endpoint (with its SG open on 443 and private DNS enabled). In the VPC it lost default internet egress.
  4. Because it rotates a second, inactive user and only then moves AWSCURRENT to it — the credential the running fleet is using is never altered, so a valid credential is live the entire time.
  5. kms:Decrypt on the KMS key that encrypts the secret — retrieval needs both GetSecretValue and kms:Decrypt.

Glossary

Term Definition
Secret A named, versioned container in Secrets Manager holding an encrypted value (string or JSON) up to 64 KB.
Version One immutable value of a secret, identified by a version ID; rotation adds versions rather than overwriting.
Staging label A movable pointer to a version; a label lives on exactly one version at a time.
AWSCURRENT The staging label for the live version; the default a bare GetSecretValue returns.
AWSPENDING The staging label for the new version being created and tested during a rotation.
AWSPREVIOUS The staging label for the last-current version, assigned automatically for rollback.
Rotation function The Lambda implementing createSecret → setSecret → testSecret → finishSecret; AWS-managed or custom.
Managed rotation Rotation operated by AWS for RDS, Aurora, Redshift and DocumentDB — no Lambda to maintain.
Custom rotation A Lambda you write to rotate anything else against its own API, using the same four-step contract.
Single-user strategy Rotates one user’s password in place; a brief auth-blip window.
Alternating-users strategy Rotates between two cloned users for genuinely zero-downtime rotation; needs a master secret.
Master secret (masterarn) A privileged secret used by alternating-users rotation to create and alter the app users.
RotationRules The schedule: AutomaticallyAfterDays or ScheduleExpression, plus an optional Duration window.
Interface endpoint (PrivateLink) A private ENI for the Secrets Manager API so in-VPC rotation/retrieval avoids the internet.
Envelope encryption Secrets Manager encrypts the value with a KMS data key and stores the encrypted data key alongside.
Resource policy A policy on the secret granting other principals/accounts access, complementing identity policies.
Replicated secret A read-only copy of a secret in another Region, kept in sync from the primary.
Caching client / Lambda extension Client-side caches that serve AWSCURRENT with a short TTL to cut latency and API cost.

Next steps

AWSSecrets ManagerRotationRDSKMSLambdaParameter StoreSecurity
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading