Secrets Manager
LocalEmu Secrets Manager implements all 23 operations. 20 of them are LocalEmu-patched: the gateway intercepts the call, runs custom validation and side-effect logic, then forwards to the moto-ext backend that stores the secret material. The remaining 3 (BatchGetSecretValue, GetRandomPassword, ListSecrets) pass through to moto unchanged. The standout feature is rotation: RotateSecret actually invokes your rotation Lambda four times (createSecret, setSecret, testSecret, finishSecret) in the same order and shape as AWS does, so you can validate the entire rotation lifecycle end to end before deploying.
Operation-level coverage: see the Secrets Manager coverage matrix.
Quick start
$ awsemu secretsmanager create-secret --name prod/db/password \
--secret-string '{"username":"appuser","password":"r3al-secret"}'
{
"ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:prod/db/password-aBcDef",
"Name": "prod/db/password",
"VersionId": "11111111-2222-3333-4444-555555555555"
}
$ awsemu secretsmanager get-secret-value --secret-id prod/db/password \
--query SecretString --output text
{"username":"appuser","password":"r3al-secret"}
$ awsemu secretsmanager update-secret --secret-id prod/db/password \
--secret-string '{"username":"appuser","password":"new-rotated-value"}' \
--query VersionId --output text
aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
# The previous version is preserved with stage AWSPREVIOUS
$ awsemu secretsmanager list-secret-version-ids --secret-id prod/db/password \
--query 'Versions[].{Id:VersionId,Stages:VersionStages}'
[
{"Id": "11111111-2222-...", "Stages": ["AWSPREVIOUS"]},
{"Id": "aaaaaaaa-bbbb-...", "Stages": ["AWSCURRENT"]}
] Secret names are validated against ^[A-Za-z0-9/_+=.@-]+$ at services/secretsmanager/provider.py:139. Each CreateSecret or PutSecretValue requires a ClientRequestToken (auto-generated by the SDK when not supplied) which becomes the new VersionId.
Architecture
Code lives at services/secretsmanager/. The provider class SecretsmanagerProvider at provider.py:104 is a thin layer over the moto-ext backend (moto.secretsmanager.models.SecretsManagerBackend). Two patterns coexist:
- •20
@handlerdecorators intercept the request, run LocalEmu-specific checks (secret-name regex, ClientRequestToken validation, cross-account default-KMS guard), then forward to moto viacall_motoor call methods on the backend directly. - •17
@patchdecorators monkey-patch moto'sFakeSecretandSecretsManagerBackendmethods to add behaviour moto lacks, most notably the Lambda-invoking rotation flow atprovider.py:774and theRotateImmediatelyrequest parameter atprovider.py:758.
Persistence works through the moto state registry. secretsmanager is registered in state/registry.py:41 as a tier-1 service (loads before Lambda and messaging). With PERSISTENCE=1 the entire backend, including every secret version, version stages, last-accessed dates, resource policies, and the queued rotation schedule, is dill-serialised on shutdown and restored on start.
Features supported
| Feature | Notes |
|---|---|
| Secret lifecycle | CreateSecret, DescribeSecret, UpdateSecret, DeleteSecret with recovery window or ForceDeleteWithoutRecovery, RestoreSecret. |
| Values | PutSecretValue, GetSecretValue by VersionId or VersionStage. SecretString and SecretBinary both supported. |
| Version stages | AWSCURRENT, AWSPENDING, AWSPREVIOUS tracked. Single-AWSPREVIOUS invariant enforced. UpdateSecretVersionStage moves labels atomically. |
ListSecretVersionIds | Paginated with MaxResults + NextToken. Per-version LastAccessedDate (date only, AWS shape). |
| Rotation | RotateSecret invokes the Lambda four times (createSecret, setSecret, testSecret, finishSecret). CancelRotateSecret tears down the schedule. AutomaticallyAfterDays in [1, 1000]. |
| Resource policies | PutResourcePolicy, GetResourcePolicy, DeleteResourcePolicy, ValidateResourcePolicy. |
| Tags | TagResource, UntagResource with secret-tag persistence across rotation. |
| Replication | ReplicateSecretToRegions, RemoveRegionsFromReplication, StopReplicationToReplica. Replica regions stored on the source; see Known limitations below for the data-copy nuance. |
| Cross-account KMS guard | GetSecretValue and PutSecretValue reject cross-account access when the secret uses the default AWS-managed KMS key (provider.py:124-134). |
| Moto pass-through | BatchGetSecretValue, GetRandomPassword, ListSecrets delegate straight to moto with no LocalEmu changes. |
| Persistence | Tier-1 in state/registry.py:41. All secrets, versions, stages, policies, tags, and rotation schedules restored when PERSISTENCE=1. |
Rotation with a real Lambda
RotateSecret at provider.py:774-906 validates the rotation Lambda via GetFunction, then drives the AWS-standard four-step protocol against it. The Lambda receives a payload {Step, SecretId, ClientRequestToken} for each call. If any step returns a FunctionError, the rotation is aborted and the secret is left in the AWSPENDING state for inspection.
# 1. Write a rotation function. AWS requires 4 step handlers:
# createSecret, setSecret, testSecret, finishSecret.
$ cat > rotate.py <<'PY'
import boto3, json, os
sm = boto3.client("secretsmanager", endpoint_url="http://host.docker.internal:4566")
def lambda_handler(event, _):
step = event["Step"]
sid = event["SecretId"]
new_token = event["ClientRequestToken"]
if step == "createSecret":
sm.put_secret_value(
SecretId=sid, ClientRequestToken=new_token,
SecretString=json.dumps({"password": f"rotated-{new_token[:8]}"}),
VersionStages=["AWSPENDING"],
)
elif step == "finishSecret":
cur = next(v["VersionId"] for v in sm.list_secret_version_ids(SecretId=sid)["Versions"]
if "AWSCURRENT" in v["VersionStages"])
sm.update_secret_version_stage(
SecretId=sid, VersionStage="AWSCURRENT",
MoveToVersionId=new_token, RemoveFromVersionId=cur,
)
PY
$ zip rotate.zip rotate.py
$ awsemu lambda create-function --function-name rotator \
--runtime python3.12 --handler rotate.lambda_handler \
--role arn:aws:iam::000000000000:role/lambda-role \
--zip-file fileb://rotate.zip --query FunctionArn --output text
arn:aws:lambda:us-east-1:000000000000:function:rotator
$ awsemu secretsmanager rotate-secret --secret-id prod/db/password \
--rotation-lambda-arn arn:aws:lambda:us-east-1:000000000000:function:rotator \
--rotation-rules AutomaticallyAfterDays=30 \
--query VersionId --output text
bbbbbbbb-cccc-dddd-eeee-ffffffffffff
# Verify rotation actually ran the Lambda (4 invocations, one per step)
$ awsemu secretsmanager get-secret-value --secret-id prod/db/password \
--query SecretString --output text
{"password": "rotated-bbbbbbbb"} Stuck-rotation detection is built in (provider.py:836-844): if a subsequent RotateSecret finds AWSPENDING attached to a version that is not also AWSCURRENT, the call returns InvalidRequestException with the message Previous rotation request is still in progress., matching AWS behaviour.
Version stages and rollback
Every successful write creates a new version with an explicit VersionId. The version graph keeps the prior AWSCURRENT tagged AWSPREVIOUS so a one-line rollback is always possible. Old versions with no stages are cleaned up automatically once 100 of them accumulate (MAX_OUTDATED_SECRET_VERSIONS=100 at provider.py:79, cleanup at provider.py:737-743).
# Promote the previous version back to AWSCURRENT (rollback)
$ CURRENT=$(awsemu secretsmanager list-secret-version-ids --secret-id prod/db/password \
--query 'Versions[?contains(VersionStages, `AWSCURRENT`)].VersionId | [0]' --output text)
$ PREV=$(awsemu secretsmanager list-secret-version-ids --secret-id prod/db/password \
--query 'Versions[?contains(VersionStages, `AWSPREVIOUS`)].VersionId | [0]' --output text)
$ awsemu secretsmanager update-secret-version-stage --secret-id prod/db/password \
--version-stage AWSCURRENT \
--remove-from-version-id $CURRENT --move-to-version-id $PREV
$ awsemu secretsmanager get-secret-value --secret-id prod/db/password \
--version-stage AWSCURRENT --query SecretString --output text
{"username":"appuser","password":"r3al-secret"} Resource policy management
The full resource-policy API is wired (PutResourcePolicy, GetResourcePolicy, DeleteResourcePolicy, ValidateResourcePolicy). The validator runs moto's IAM-policy syntax checker and returns PolicyValidationPassed. Policies are stored on the secret and returned verbatim. See Known limitations for the access-control enforcement note.
$ awsemu secretsmanager put-resource-policy --secret-id prod/db/password \
--resource-policy '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::000000000000:role/AppRole"},
"Action": "secretsmanager:GetSecretValue",
"Resource": "*"
}]
}'
$ awsemu secretsmanager get-resource-policy --secret-id prod/db/password \
--query ResourcePolicy --output text | python3 -m json.tool
{"Version": "2012-10-17", "Statement": [{...}]}
$ awsemu secretsmanager validate-resource-policy --secret-id prod/db/password \
--resource-policy file://policy.json --query PolicyValidationPassed
true Integration points
Other LocalEmu services fetch secrets from this backend.
| Caller | Where |
|---|---|
| EventBridge API destinations | services/events/api_destination.py: HTTP auth secret fetched at dispatch time and injected as the configured header. |
| EventBridge connections | services/events/connection.py: custom auth parameter resolution. |
| CloudFormation parameter resolution | services/cloudformation/engine/v2/resolving.py: resolves {{resolve:secretsmanager:...}} dynamic references during stack create/update. |
| CloudFormation dynamic refs | services/cloudformation/engine/template_deployer.py: legacy v1 engine path. |
| SSM | services/ssm/provider.py: GetParameter with type SecureString referencing a Secrets Manager ARN. |
Configuration
Secrets Manager has no service-specific environment variables. Persistence is enabled by the generic PERSISTENCE=1 flag; rotation reuses the standard Lambda invocation path (so anything that affects Lambda, e.g. LAMBDA_DOCKER_NETWORK, applies to rotation calls).
Known limitations
- •Scheduled auto-rotation is not enforced.
AutomaticallyAfterDaysandNextRotationDateare stored on the secret, but no background scheduler fires the Lambda when the date passes. Rotation runs only whenRotateSecretis called explicitly withRotateImmediately=true(the default). - •
KmsKeyIdis metadata only. Secret bytes are held in the moto backend in plaintext. TheKmsKeyIdfield is preserved, returned byDescribeSecret, and used for the cross-account default-KMS guard, but no KMS encrypt/decrypt is performed against the material. - •Replication is metadata-only.
ReplicateSecretToRegionsrecords the replica region list on the source secret. The secret bytes are not actually copied into the target region's backend, so aGetSecretValueagainst the replica region returnsResourceNotFoundExceptionuntil the secret is also created there. - •Resource policies are stored but not enforced.
PutResourcePolicysaves the JSON,GetResourcePolicyreturns it, butGetSecretValuedoes not deny based on principal. Use IAM policies (which LocalEmu does enforce via the IAM enforcement layer) for access control on the secret. - •
UpdateSecretskips the cross-account default-KMS guard. OnlyGetSecretValueandPutSecretValueapply the check atprovider.py:124-134.