{"id":165606,"date":"2026-04-11T02:27:36","date_gmt":"2026-04-10T23:27:36","guid":{"rendered":"https:\/\/computingforgeeks.com\/?p=165606"},"modified":"2026-04-11T02:27:36","modified_gmt":"2026-04-10T23:27:36","slug":"aws-secrets-manager-rotation-guide","status":"publish","type":"post","link":"https:\/\/computingforgeeks.com\/aws-secrets-manager-rotation-guide\/","title":{"rendered":"Configure AWS Secrets Manager: Rotation, IAM, and ECS"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Hardcoded database passwords in a <code>.env<\/code> file committed to Git is still how a surprising number of teams ship software in 2026. It worked for the first release, nobody rotated anything, and now the credential lives in five repos, two CI systems, and a Slack thread from 2023. AWS Secrets Manager is the native fix. It stores secrets encrypted with KMS, rotates them on a schedule, audits every read in CloudTrail, and hands the value to your workload at runtime over an IAM-authenticated API call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide walks through the full workflow: creating secrets from the CLI, retrieving them safely in scripts, versioning and rollback, identity-based and resource-based IAM policies, customer-managed KMS keys, cross-region replication, ECS task injection, automatic rotation for RDS, auditing who read a secret with CloudTrail, real cost math against Parameter Store, and a tested Terraform module you can drop into a real project. Every command and output below came from a live eu-west-1 account. If you also run workloads on EKS, pair this with our <a href=\"https:\/\/computingforgeeks.com\/iam-roles-for-service-accounts-irsa-eks-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">IRSA guide<\/a> because Pod Identity and IRSA are how most Kubernetes workloads end up calling Secrets Manager.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Verified April 2026 with AWS CLI v2.22, Terraform 1.5, and Secrets Manager in eu-west-1<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Secrets Manager vs Parameter Store: decide before you build<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every Secrets Manager article that skips this section is dodging the first question every engineer asks. Both services store sensitive data. Only one of them charges you, and only one of them rotates credentials for you. Pick the wrong service at day zero and you will either rewrite your bootstrap code in six months or pay $4,000 a year for config you could have stored free.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Feature<\/th><th>Secrets Manager<\/th><th>Parameter Store (Standard)<\/th><th>Parameter Store (Advanced)<\/th><\/tr><\/thead><tbody><tr><td>Storage cost<\/td><td>$0.40 per secret per month<\/td><td>Free<\/td><td>$0.05 per parameter per month<\/td><\/tr><tr><td>API cost<\/td><td>$0.05 per 10,000 calls<\/td><td>Free up to account limits<\/td><td>$0.05 per 10,000 higher-throughput calls<\/td><\/tr><tr><td>Max value size<\/td><td>64 KB<\/td><td>4 KB<\/td><td>8 KB<\/td><\/tr><tr><td>Max items per account per region<\/td><td>500,000<\/td><td>10,000<\/td><td>100,000<\/td><\/tr><tr><td>Default throughput<\/td><td>10,000 TPS<\/td><td>40 TPS (scalable)<\/td><td>Up to 10,000 TPS<\/td><\/tr><tr><td>Encryption<\/td><td>KMS, mandatory<\/td><td>Optional SecureString<\/td><td>Optional SecureString<\/td><\/tr><tr><td>Automatic rotation<\/td><td>Yes, built-in<\/td><td>Not supported<\/td><td>Not supported<\/td><\/tr><tr><td>Managed RDS rotation<\/td><td>Yes<\/td><td>No<\/td><td>No<\/td><\/tr><tr><td>Cross-region replication<\/td><td>Native<\/td><td>Manual<\/td><td>Manual<\/td><\/tr><tr><td>Cross-account sharing<\/td><td>Resource policies<\/td><td>Not supported<\/td><td>Via AWS RAM<\/td><\/tr><tr><td>Generate random passwords<\/td><td>Yes (get-random-password)<\/td><td>No<\/td><td>No<\/td><\/tr><tr><td>Reference from Parameter Store<\/td><td>N\/A<\/td><td>Yes, via \/aws\/reference\/secretsmanager\/<\/td><td>Same<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The rule of thumb is simple. Database credentials, OAuth tokens, third-party API keys that must rotate on a schedule, cross-account secrets, and anything under PCI or HIPAA audit go in Secrets Manager. Feature flags, app config, non-rotating bootstrap values, and anything read thousands of times per second at container start belong in Parameter Store SecureString. If a secret costs more than about $5 a month in API calls on Secrets Manager because your app re-reads it on every request, you are holding it wrong. Cache the value in process memory with a short TTL, or move it to Parameter Store.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How Secrets Manager actually works<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager is a regional service. A secret created in eu-west-1 does not exist in us-east-1 until you explicitly replicate it. Every secret is encrypted at rest with a KMS key. By default that key is the AWS-managed <code>aws\/secretsmanager<\/code> key (free). For production workloads with compliance requirements, bring your own customer-managed KMS key so the key policy becomes a second audit layer on top of the resource policy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every secret has a current value plus a short history of previous values, tagged with staging labels. The three built-in labels are <code>AWSCURRENT<\/code> (the active value your applications read), <code>AWSPREVIOUS<\/code> (the last value, kept for rollback), and <code>AWSPENDING<\/code> (a new value being tested during rotation). You can also create custom staging labels if you need more than three pointers. Applications should always read the default label, which is <code>AWSCURRENT<\/code>, and only reach for <code>AWSPREVIOUS<\/code> explicitly during a rollback scenario.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rotation is a four-step Lambda lifecycle: createSecret generates the new credential and stores it as AWSPENDING, setSecret updates the target system, testSecret verifies the new credential works, and finishSecret promotes AWSPENDING to AWSCURRENT while demoting the old value to AWSPREVIOUS. For RDS, Aurora, DocumentDB, Redshift admin passwords, and ECS Service Connect private CA certificates, AWS runs managed rotation on your behalf and you never touch Lambda at all. For anything else, AWS ships a rotation template and you customize setSecret and testSecret for your target system. The minimum rotation interval is every 4 hours, and managed rotation typically finishes in under a minute.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One common trap: ElastiCache and MemoryDB are not managed rotation targets. They ship with rotation Lambda templates only, which is different from &#8220;managed&#8221; in AWS documentation terms. If you read another tutorial claiming managed rotation for Redis, that tutorial is wrong.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Prerequisites<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>An AWS account with an IAM admin user or role. Test account spend for this walkthrough stays under $1 if you destroy everything when done.<\/li>\n<li>AWS CLI v2.22 or newer, configured with credentials for the target region. Run <code>aws --version<\/code> to verify.<\/li>\n<li>Terraform 1.5 or newer for the IaC section.<\/li>\n<li>A workstation with Python 3 and <code>jq<\/code> for JSON parsing in shell scripts.<\/li>\n<li>Tested on: AWS CLI 2.22.0, Terraform 1.5.7, Rocky Linux 10.1 workstation, eu-west-1 region, April 2026.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Set your default region so you do not have to repeat it on every command.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>export AWS_DEFAULT_REGION=eu-west-1\naws sts get-caller-identity<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The identity check confirms which account you are about to modify. Never run Secrets Manager commands against an account you have not verified, especially in shared tooling environments.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Create your first secret<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager accepts two flavors of secret payload: a plain string or a JSON document. Use JSON whenever you have more than one field (username, password, host, port, database name) because it keeps related credentials in one atomic version. Start with a plain-string API key so you see the basic shape of the API.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager create-secret \\\n  --name \"computingforgeeks\/demo\/api-key\" \\\n  --description \"Plain string API key for the computingforgeeks Secrets Manager demo\" \\\n  --secret-string \"sk-example-1234567890abcdefghijklmnop\" \\\n  --tags Key=Environment,Value=demo Key=Application,Value=cfg-blog \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The API returns the full ARN, the friendly name, and a version ID for the value you just stored.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n    \"ARN\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/api-key-UFlApf\",\n    \"Name\": \"computingforgeeks\/demo\/api-key\",\n    \"VersionId\": \"d8aacfec-0da8-4d64-b25e-771e5eb64206\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The six-character suffix (<code>UFlApf<\/code> in this case) is appended automatically by Secrets Manager. It exists to prevent accidental IAM wildcards from matching a recreated secret with the same name. Always reference your secrets by the friendly name in code, never the full ARN, otherwise you hardcode this random suffix and any recreate-delete cycle will break your app.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now create a realistic database credential secret with a structured JSON payload. This is the shape you will use 90% of the time in production.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager create-secret \\\n  --name \"computingforgeeks\/demo\/db-credentials\" \\\n  --description \"PostgreSQL credentials for the demo application\" \\\n  --secret-string '{\"username\":\"appuser\",\"password\":\"S3cur3-D3mo-Pa55w0rd!\",\"host\":\"db.prod.example.com\",\"port\":5432,\"dbname\":\"appdb\",\"engine\":\"postgres\"}' \\\n  --tags Key=Environment,Value=production Key=Application,Value=api Key=Component,Value=database \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tagging every secret with <code>Environment<\/code>, <code>Application<\/code>, and <code>Component<\/code> (or similar) is not optional. Tags drive cost allocation, IAM conditions, automated cleanup, and the answer to the question &#8220;which team owns this credential?&#8221; that will come up during the next audit. Set a convention and enforce it with SCPs or Terraform module defaults.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inspect the secret metadata without revealing the value. The describe-secret call is safe to run from any IAM principal with <code>secretsmanager:DescribeSecret<\/code> and is the right way to build a secret inventory or validate tags.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager describe-secret \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Describe never returns the secret value, only metadata. That is exactly what you want for monitoring scripts.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n    \"ARN\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY\",\n    \"Name\": \"computingforgeeks\/demo\/db-credentials\",\n    \"Description\": \"PostgreSQL credentials for the demo application\",\n    \"LastChangedDate\": 1775844659.55,\n    \"Tags\": [\n        {\"Key\": \"Component\", \"Value\": \"database\"},\n        {\"Key\": \"Environment\", \"Value\": \"production\"},\n        {\"Key\": \"Application\", \"Value\": \"api\"}\n    ],\n    \"VersionIdsToStages\": {\n        \"9213e49c-4499-4818-aec3-5101753ff66e\": [\"AWSCURRENT\"]\n    },\n    \"CreatedDate\": 1775844659.521\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Notice the <code>VersionIdsToStages<\/code> block. Right now the only version ID is labeled <code>AWSCURRENT<\/code>. After the first rotation that block will also include an <code>AWSPREVIOUS<\/code> entry. This map is how Secrets Manager tracks the rollback history and how your applications pin to a specific version if they ever need to.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Retrieve a secret<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The GetSecretValue API is what applications hit at startup. It returns the version stages, the string payload, and the created date.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The default output wraps your value in a metadata envelope.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n    \"ARN\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY\",\n    \"Name\": \"computingforgeeks\/demo\/db-credentials\",\n    \"VersionId\": \"9213e49c-4499-4818-aec3-5101753ff66e\",\n    \"SecretString\": \"{\\\"username\\\":\\\"appuser\\\",\\\"password\\\":\\\"S3cur3-D3mo-Pa55w0rd!\\\",\\\"host\\\":\\\"db.prod.example.com\\\",\\\"port\\\":5432,\\\"dbname\\\":\\\"appdb\\\",\\\"engine\\\":\\\"postgres\\\"}\",\n    \"VersionStages\": [\"AWSCURRENT\"],\n    \"CreatedDate\": 1775844659.545\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For shell scripts you only want the <code>SecretString<\/code> field. Use the <code>--query<\/code> and <code>--output text<\/code> combo so you get the raw JSON back without the envelope.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-west-1 \\\n  --query SecretString \\\n  --output text<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now the output is exactly the payload you stored, ready for downstream parsing.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\"username\":\"appuser\",\"password\":\"S3cur3-D3mo-Pa55w0rd!\",\"host\":\"db.prod.example.com\",\"port\":5432,\"dbname\":\"appdb\",\"engine\":\"postgres\"}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Most of the time you want a single key, not the whole JSON blob. Pipe through Python&#8217;s built-in <code>json<\/code> module for a dependency-free extractor.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-west-1 \\\n  --query SecretString \\\n  --output text | python3 -c \"import sys,json; print(json.loads(sys.stdin.read())['password'])\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That one-liner is the most common pattern in ops scripts. It works without <code>jq<\/code>, which matters on minimal container images and fresh EC2 instances. If you prefer <code>jq<\/code>, the equivalent is <code>jq -r .password<\/code> and is one fewer character.<\/p>\n\n\n<!-- wp:heading>\n\n<h3 class=\"wp-block-heading\">Loading multiple fields as environment variables<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Bootstrap scripts for containers and systemd units often need several values exported as env vars. Fetch the secret once, then parse each field. Calling Secrets Manager once and extracting multiple fields locally is cheaper than calling the API for each field, and the API rate limits are per secret per second, so fewer calls is always better.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>#!\/bin\/bash\nset -euo pipefail\n\nSECRET_JSON=$(aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-west-1 \\\n  --query SecretString \\\n  --output text)\n\nexport DB_USER=$(echo \"$SECRET_JSON\" | python3 -c \"import sys,json; print(json.load(sys.stdin)['username'])\")\nexport DB_PASSWORD=$(echo \"$SECRET_JSON\" | python3 -c \"import sys,json; print(json.load(sys.stdin)['password'])\")\nexport DB_HOST=$(echo \"$SECRET_JSON\" | python3 -c \"import sys,json; print(json.load(sys.stdin)['host'])\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Drop that snippet into an EC2 userdata script or a container entrypoint. The IAM role attached to the instance or task must grant <code>secretsmanager:GetSecretValue<\/code> on the specific secret ARN, and <code>kms:Decrypt<\/code> on the KMS key if you use a customer-managed CMK.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Versioning, staging labels, and rollback<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When you update a secret with <code>put-secret-value<\/code>, Secrets Manager does not overwrite the old value. It creates a new version, promotes it to <code>AWSCURRENT<\/code>, and demotes the old version to <code>AWSPREVIOUS<\/code>. This is the native rollback mechanism.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager put-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --secret-string '{\"username\":\"appuser\",\"password\":\"N3w-R0tated-Pa55!\",\"host\":\"db.prod.example.com\",\"port\":5432,\"dbname\":\"appdb\",\"engine\":\"postgres\"}' \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Describe the secret again and you can see both versions now exist with different staging labels.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n    \"289ca0ea-9524-41a7-8ab5-8df7e605acef\": [\"AWSCURRENT\"],\n    \"9213e49c-4499-4818-aec3-5101753ff66e\": [\"AWSPREVIOUS\"]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the new credential breaks production, you do not need a backup. Secrets Manager still holds the old value. Pull it back with an explicit version stage request.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --version-stage AWSPREVIOUS \\\n  --region eu-west-1 \\\n  --query 'SecretString' --output text<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That returns the previous JSON blob. To actually roll back, push the previous value forward with another <code>put-secret-value<\/code> call so it becomes <code>AWSCURRENT<\/code> again. Your applications will pick up the restored value on their next cache refresh or on the next startup.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">IAM identity-based policies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every production secret needs a tight identity-based policy. Wildcards like <code>secretsmanager:*<\/code> on <code>*<\/code> are an audit finding waiting to happen. The minimum-viable read policy grants <code>GetSecretValue<\/code> and <code>DescribeSecret<\/code> on a single secret ARN, plus <code>kms:Decrypt<\/code> on the KMS key if the secret uses a customer-managed CMK.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the policy document for a read-only role.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim secret-reader-policy.json<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste the following policy. The wildcard suffix (<code>-??????<\/code>) matches the six random characters Secrets Manager appends to every ARN, so the policy keeps working if the secret is ever recreated with the same name.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"ReadDbCredentials\",\n      \"Effect\": \"Allow\",\n      \"Action\": [\n        \"secretsmanager:GetSecretValue\",\n        \"secretsmanager:DescribeSecret\"\n      ],\n      \"Resource\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-??????\"\n    },\n    {\n      \"Sid\": \"DecryptWithCMK\",\n      \"Effect\": \"Allow\",\n      \"Action\": \"kms:Decrypt\",\n      \"Condition\": {\n        \"StringEquals\": {\n          \"kms:ViaService\": \"secretsmanager.eu-west-1.amazonaws.com\"\n        }\n      },\n      \"Resource\": \"arn:aws:kms:eu-west-1:123456789012:key\/REPLACE-WITH-CMK-KEY-ID\"\n    }\n  ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Attach that policy to the IAM role your workload assumes (the ECS task role, the Lambda execution role, the EKS Pod Identity role, or the EC2 instance role). Never attach read policies directly to IAM users.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A write-and-rotate policy is similar but adds <code>PutSecretValue<\/code>, <code>UpdateSecretVersionStage<\/code>, and <code>RotateSecret<\/code>. Keep rotation permissions separate from read permissions: the Lambda that rotates a secret should not be the same role your application uses to read the secret. Splitting the roles makes the CloudTrail audit trail legible and limits blast radius if one credential leaks.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Resource-based policies for cross-account sharing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sometimes a secret lives in Account A (the owner) and needs to be read by a workload in Account B (the consumer). Secrets Manager supports this natively with resource-based policies, which is cleaner than a cross-account <code>sts:AssumeRole<\/code> dance and produces a better audit trail. Both accounts need IAM configured: the owning account grants access via the secret's resource policy, and the consuming account attaches a read policy to the workload role.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Write the resource policy to a file.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim secret-resource-policy.json<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Grant the root of Account B permission to get and describe the secret. The condition limits the principal to the <code>AWSCURRENT<\/code> staging label, which is a nice defense against a consumer accidentally (or maliciously) reading expired rotated credentials.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n  \"Version\": \"2012-10-17\",\n  \"Statement\": [\n    {\n      \"Sid\": \"AllowCrossAccountRead\",\n      \"Effect\": \"Allow\",\n      \"Principal\": {\n        \"AWS\": \"arn:aws:iam::210987654321:root\"\n      },\n      \"Action\": [\n        \"secretsmanager:GetSecretValue\",\n        \"secretsmanager:DescribeSecret\"\n      ],\n      \"Resource\": \"*\",\n      \"Condition\": {\n        \"StringEquals\": {\n          \"secretsmanager:VersionStage\": \"AWSCURRENT\"\n        }\n      }\n    }\n  ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Apply the policy to the secret.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager put-resource-policy \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --resource-policy file:\/\/secret-resource-policy.json \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the put call succeeds you will get back the secret's ARN and name. If it fails you almost certainly hit the cross-account gotcha that breaks half the tutorials out there.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>An error occurred (MalformedPolicyDocumentException) when calling the PutResourcePolicy operation: This resource policy contains an unsupported principal.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That error means the principal ARN you referenced does not exist in AWS. It is not a syntax error, it is a validation failure. AWS actually resolves the principal at policy-put time and rejects anything it cannot find. The fix is to either reference the account root (<code>arn:aws:iam::210987654321:root<\/code>), which always exists, or use a real role ARN that you have already created in the consuming account. Never use a role ARN in the policy before the role exists on the other side.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is a second gotcha that catches everyone eventually. If the secret is encrypted with a customer-managed KMS key (which is almost mandatory for cross-account sharing), the consuming account also needs <code>kms:Decrypt<\/code> granted in the CMK's key policy. Add a statement to the key policy in Account A allowing Account B to decrypt, and attach a matching <code>kms:Decrypt<\/code> statement to the workload role in Account B. Without the CMK policy change, the consumer hits <code>AccessDeniedException<\/code> on GetSecretValue even though the secret policy looks fine.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Automatic password generation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager ships with a random password generator that respects complexity policies and excludes characters you pick. It is a free feature and you should use it for any secret you create programmatically: your Terraform modules, your rotation Lambdas, your bootstrapping scripts. Never generate passwords with <code>openssl rand<\/code> unless you enjoy debugging shell-escaping bugs.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-random-password \\\n  --password-length 32 \\\n  --exclude-characters '\"@\/\\\\' \\\n  --require-each-included-type \\\n  --region eu-west-1 \\\n  --query RandomPassword \\\n  --output text<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The result is a fresh 32-character password with at least one lowercase, uppercase, digit, and symbol (<code>--require-each-included-type<\/code>), and no double-quote, at-sign, forward-slash, or backslash. Those four exclusions are the usual culprits for breaking connection strings and YAML files.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>R^z{#}_UQE-rczr94jJkaF=nHkuHWNqA<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Feed that into the next <code>put-secret-value<\/code> call. You now have a version-controlled, audit-logged, rotation-safe password pipeline with zero custom code.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Customer-managed KMS keys<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The AWS-managed <code>aws\/secretsmanager<\/code> key is fine for dev and staging. For production, anything under PCI or HIPAA audit, or anything that crosses account boundaries, create a customer-managed CMK. The key policy becomes an orthogonal control plane: even if someone compromises an IAM role, they cannot decrypt the secret without also having <code>kms:Decrypt<\/code> on the CMK.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>KMS_KEY_ID=$(aws kms create-key \\\n  --description \"Secrets Manager demo CMK\" \\\n  --key-usage ENCRYPT_DECRYPT \\\n  --region eu-west-1 \\\n  --query 'KeyMetadata.KeyId' --output text)\n\naws kms create-alias \\\n  --alias-name alias\/computingforgeeks-demo \\\n  --target-key-id $KMS_KEY_ID \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Aliases make your infrastructure code readable. Reference keys by alias, not by key ID, so that emergency key rotation or regional failover does not force you to rewrite every Terraform module.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create a new secret pinned to the CMK by its alias.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager create-secret \\\n  --name \"computingforgeeks\/demo\/encrypted-with-cmk\" \\\n  --kms-key-id alias\/computingforgeeks-demo \\\n  --secret-string '{\"key\":\"encrypted-with-cmk\"}' \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every read, rotate, and put on this secret now also logs a <code>Decrypt<\/code> call against the CMK in CloudTrail. Your auditors will thank you. Your bill will go up by a few cents because CMKs cost $1 per month per key, plus KMS API call charges, which is trivial compared to the security improvement.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Automatic rotation for RDS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Rotation is the feature you actually bought Secrets Manager for. Everything above this section you could technically do with Parameter Store SecureString and some custom code. Rotation is where Secrets Manager stops being a key-value store and starts being a credential lifecycle manager.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For RDS (MySQL, PostgreSQL, MariaDB, Oracle, SQL Server), Aurora master credentials, DocumentDB master credentials, Redshift admin passwords, and ECS Service Connect private CA certs, AWS runs <em>managed rotation<\/em>. You never see a Lambda function. AWS picks a new password, updates RDS, validates, and promotes the new version, all as an AWS-internal operation. You just turn it on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under the hood every rotation follows the same four-step Lambda lifecycle, whether it is managed or custom. Understanding the four steps is critical because when a custom rotation fails, it fails at exactly one of them:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>createSecret<\/strong>: generate the new credential with <code>get-random-password<\/code> and store it as a new version with the <code>AWSPENDING<\/code> staging label. This step is idempotent and AWS's template handles it for you.<\/li>\n<li><strong>setSecret<\/strong>: connect to the target system (the RDS instance, the SaaS API, whatever holds the real credential) and update the credential there. This is the only step that is always custom because only you know how to talk to your target.<\/li>\n<li><strong>testSecret<\/strong>: verify the new credential works by making a real test call against the target. For a database, this is usually a simple connect-and-select-1 probe.<\/li>\n<li><strong>finishSecret<\/strong>: move the <code>AWSCURRENT<\/code> staging label from the old version to the new one. The old version becomes <code>AWSPREVIOUS<\/code>, and the staging label cycle completes. AWS's template handles this for you.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">For a managed RDS target, enabling rotation is a one-line call once you have your secret attached to the database. The secret value must be a JSON document with the expected schema (<code>username<\/code>, <code>password<\/code>, <code>engine<\/code>, <code>host<\/code>, <code>port<\/code>, <code>dbname<\/code>), which is why the JSON pattern from earlier in this guide matters.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager rotate-secret \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --rotation-rules AutomaticallyAfterDays=30 \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a custom rotation against a non-RDS target, AWS publishes ready-made templates in the <a href=\"https:\/\/docs.aws.amazon.com\/secretsmanager\/latest\/userguide\/rotate-secrets.html\" target=\"_blank\" rel=\"noreferrer noopener\">rotate-secrets documentation<\/a> via the Serverless Application Repository. You deploy the template, edit <code>setSecret<\/code> and <code>testSecret<\/code> for your specific target, attach the Lambda to the secret with <code>rotate-secret --rotation-lambda-arn<\/code>, and Secrets Manager handles the schedule via EventBridge. Minimum rotation interval is every 4 hours. Most teams pick between 30 and 90 days for database credentials.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For zero-downtime rotation in production, use the alternating-users strategy. You create two database users, flip between them on each rotation, and the application always has at least one working set of credentials because the old set keeps working until the next cycle. Single-user rotation is simpler but has a brief window where in-flight connections still hold the old credential while new connections pick up the new one, which can cause errors on slow transactions.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">ECS task definition secrets injection<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">ECS can inject Secrets Manager values into container environment variables at task launch time without your application needing an SDK call. Define a <code>secrets<\/code> array in the container definition with a <code>name<\/code> (the env var) and <code>valueFrom<\/code> (the secret ARN or JSON key reference).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the task definition JSON.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim nginx-task-def.json<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The key detail is the <code>valueFrom<\/code> field with the <code>jsonKey::<\/code> suffix. If the secret is a plain string, reference the ARN directly. If the secret is a JSON document (as ours is), append <code>:key::<\/code> to pick a specific field. The double-colon at the end is not a typo, it is the version stage separator that you leave empty to get <code>AWSCURRENT<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n  \"family\": \"cfg-demo-api\",\n  \"networkMode\": \"awsvpc\",\n  \"requiresCompatibilities\": [\"FARGATE\"],\n  \"cpu\": \"256\",\n  \"memory\": \"512\",\n  \"executionRoleArn\": \"arn:aws:iam::123456789012:role\/ecsTaskExecutionRole\",\n  \"taskRoleArn\": \"arn:aws:iam::123456789012:role\/cfg-demo-task-role\",\n  \"containerDefinitions\": [\n    {\n      \"name\": \"api\",\n      \"image\": \"123456789012.dkr.ecr.eu-west-1.amazonaws.com\/cfg-demo:latest\",\n      \"essential\": true,\n      \"portMappings\": [{\"containerPort\": 8080, \"protocol\": \"tcp\"}],\n      \"secrets\": [\n        {\n          \"name\": \"DB_USERNAME\",\n          \"valueFrom\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY:username::\"\n        },\n        {\n          \"name\": \"DB_PASSWORD\",\n          \"valueFrom\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY:password::\"\n        },\n        {\n          \"name\": \"DB_HOST\",\n          \"valueFrom\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY:host::\"\n        }\n      ],\n      \"logConfiguration\": {\n        \"logDriver\": \"awslogs\",\n        \"options\": {\n          \"awslogs-group\": \"\/ecs\/cfg-demo-api\",\n          \"awslogs-region\": \"eu-west-1\",\n          \"awslogs-stream-prefix\": \"ecs\"\n        }\n      }\n    }\n  ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Register the task definition and launch it.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws ecs register-task-definition \\\n  --cli-input-json file:\/\/nginx-task-def.json \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two IAM things have to be right or the task will never start. The <strong>task execution role<\/strong> (<code>ecsTaskExecutionRole<\/code>) needs <code>secretsmanager:GetSecretValue<\/code> on the referenced secrets and <code>kms:Decrypt<\/code> on the CMK if used, because the ECS agent itself reads the secret before starting the container. The <strong>task role<\/strong> is separate and only matters if the container code also calls AWS APIs at runtime. Mixing the two up is the most common ECS secrets injection bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you see <code>ResourceInitializationError: unable to pull secrets or registry auth: execution resource retrieval failed: unable to retrieve secret from asm<\/code> in the stopped-task reason string, your task execution role is missing the permission. Fix the execution role, then force a new deployment. ECS does not auto-pick-up IAM changes on running tasks.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">EKS access patterns<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">EKS has three decent patterns for reading Secrets Manager, each with trade-offs. The short version: use External Secrets Operator with Pod Identity unless you have a specific reason not to, because it is the only one that syncs secrets into Kubernetes objects without giving every pod direct IAM credentials.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>External Secrets Operator (ESO)<\/strong> with Pod Identity or IRSA. A controller in the cluster fetches from Secrets Manager and creates matching Kubernetes <code>Secret<\/code> objects. Your application pods just mount the Kubernetes Secret as usual. This is the most popular pattern because it fits the standard Kubernetes mental model and supports refresh intervals.<\/li>\n<li><strong>AWS Secrets and Configuration Provider (ASCP) for the Kubernetes Secrets Store CSI Driver<\/strong>. Mounts secrets as files in the pod at start-time. Ideal for apps that read secrets from disk and restart to pick up changes.<\/li>\n<li><strong>Direct SDK calls<\/strong> from application code using IRSA or Pod Identity. Simplest for a new app, but means your app needs the AWS SDK and has to handle retries, caching, and error paths itself.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If you are not already using IRSA or Pod Identity on your cluster, read our <a href=\"https:\/\/computingforgeeks.com\/iam-roles-for-service-accounts-irsa-eks-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">IRSA guide<\/a> and our <a href=\"https:\/\/computingforgeeks.com\/amazon-eks-pod-identity-complete-guide\/\" target=\"_blank\" rel=\"noreferrer noopener\">Pod Identity guide<\/a> first. The IAM binding between pods and AWS roles is the foundation every one of these patterns is built on, and misconfiguring it is the root cause of most \"pod can't read secret\" incidents.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Multi-region replication<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets are regional by default. For DR and for global applications reading the same secret from multiple regions, Secrets Manager has native replication. One CLI call and the secret is mirrored to additional regions. Writes still happen in the primary region. Replicas are read-only until promoted.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager replicate-secret-to-regions \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --add-replica-regions Region=eu-central-1 \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The initial replication status comes back as <code>InProgress<\/code>, and usually flips to <code>InSync<\/code> within a few seconds.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>{\n    \"ARN\": \"arn:aws:secretsmanager:eu-west-1:123456789012:secret:computingforgeeks\/demo\/db-credentials-9NWLWY\",\n    \"ReplicationStatus\": [\n        {\n            \"Region\": \"eu-central-1\",\n            \"KmsKeyId\": \"alias\/aws\/secretsmanager\",\n            \"Status\": \"InProgress\"\n        }\n    ]\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Verify the replica is readable in the target region.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager describe-secret \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region eu-central-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things to remember about replicas. First, each replica is billed as a full secret at $0.40 per month, on top of the primary. Replicating one secret to three regions costs $1.60 per month total, not $0.40. Second, each replica in a region that uses a customer-managed CMK needs its own CMK in that region and its own KMS key policy entry. Replication does not carry the CMK across regions for you.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Auditing secret access with CloudTrail<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every API call to Secrets Manager (CreateSecret, GetSecretValue, PutSecretValue, DescribeSecret, RotateSecret, DeleteSecret, PutResourcePolicy, ReplicateSecretToRegions, and friends) is recorded in CloudTrail. The audit story is \"who read this secret in the last 24 hours, and from where?\" and most teams never actually run that query until an incident forces them to. Run it preemptively.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws cloudtrail lookup-events \\\n  --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue \\\n  --max-results 5 \\\n  --region eu-west-1 \\\n  --query 'Events[*].[EventTime,Username,EventName]' \\\n  --output table<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The table output surfaces the last five GetSecretValue events with the IAM principal that made each call. This is the output from our test account after running the earlier steps in this guide, and it contains something interesting.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>------------------------------------------------------------------------------------------------------\n|                                            LookupEvents                                            |\n+--------------+------------------------------------------------------------------+------------------+\n|  1775844889.0|  jkmutai                                                         |  GetSecretValue  |\n|  1775844721.0|  eks-pod-identi-external-s-5dac0917-6c74-4742-b6e9-a0d470b7109a  |  GetSecretValue  |\n|  1775844707.0|  jkmutai                                                         |  GetSecretValue  |\n+------------------------------------------------------------------------------------------------------<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The first and third rows are the IAM admin user running CLI commands. The middle row is far more interesting: <code>eks-pod-identi-external-s-5dac0917-...<\/code> is the session name of a Pod Identity credential being assumed by External Secrets Operator inside our EKS cluster. That one row proves three things at once. ESO is reachable from the cluster, the Pod Identity mapping is functioning, and the resolved role actually had permission to GetSecretValue on this secret. No application logs needed. CloudTrail shows the full chain from cluster to secret.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For deeper queries (who has read a specific secret in the last 30 days, what source IPs have called GetSecretValue, which role accessed more than N secrets in an hour), use CloudTrail Lake with SQL or Athena over your CloudTrail S3 bucket. Those queries are where the real security signal lives, and they are worth wiring into your SIEM or your incident-response runbooks.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Cost analysis and the Parameter Store breakeven<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager charges $0.40 per secret per month plus $0.05 per 10,000 API calls. No minimums, no volume tiers. A realistic production workload with 20 secrets read once per minute per region works out to this: 20 \u00d7 $0.40 = $8.00 for storage, plus (20 \u00d7 60 \u00d7 24 \u00d7 30) \/ 10,000 \u00d7 $0.05 = $4.32 for API calls. Total: <strong>$12.32 per month<\/strong>, or about $148 per year. That is genuinely cheap compared to the cost of a leaked credential.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the same 20 secrets in Parameter Store SecureString: <strong>$0 for storage and $0 for API calls<\/strong>, because SecureString is free at the Standard tier. The only \"cost\" is KMS decrypt calls, which run at $0.03 per 10,000, or about $2.59 for the same traffic pattern. Total: around $2.59 per month.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the question is: is the Secrets Manager premium ($12.32 vs $2.59, about $10 per month in this example) worth it? If you need rotation, resource policies, or cross-region replication, yes, it is a bargain. If you do not, you are paying $120 per year for nothing. The honest breakeven is \"need at least one feature Parameter Store does not have, or you are wasting money.\"<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two more cost items that bite people. VPC endpoints (PrivateLink) for Secrets Manager cost <strong>$0.01 per hour per AZ<\/strong>, which is about $7.30 per month per AZ. A 3-AZ production deployment adds $21.90 per month in fixed infrastructure just for the endpoint. Only enable it if you are saving more than that in NAT Gateway data processing charges, or if you have a compliance requirement to keep the traffic off the public internet. Replicas cost another $0.40 per month each. Plan both before you turn them on.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Complete Terraform module<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The CLI is fine for exploring and for one-off ops work. Everything that ships to production should live in Terraform (or CDK, or Pulumi, pick your flavor) so that the creation, update, and destroy path is version-controlled and reviewable. Here is the tested module that generated a real secret during this article.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Create the module directory and start with <code>main.tf<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim main.tf<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste the following. The module generates a strong random password at apply time and stores it in Secrets Manager with a 7-day recovery window. Customer-managed KMS and cross-account resource policy are included but commented so you can enable them per environment.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>terraform {\n  required_version = \"&gt;= 1.5\"\n  required_providers {\n    aws = {\n      source  = \"hashicorp\/aws\"\n      version = \"~&gt; 5.70\"\n    }\n    random = {\n      source  = \"hashicorp\/random\"\n      version = \"~&gt; 3.6\"\n    }\n  }\n}\n\nprovider \"aws\" {\n  region = var.region\n}\n\nresource \"random_password\" \"db\" {\n  length           = 32\n  special          = true\n  override_special = \"!#$%&amp;*()-_=+[]{}&lt;&gt;:?\"\n  min_lower        = 2\n  min_upper        = 2\n  min_numeric      = 2\n  min_special      = 2\n}\n\nresource \"aws_secretsmanager_secret\" \"db\" {\n  name        = \"${var.name_prefix}\/db-credentials\"\n  description = \"Database credentials managed by Terraform\"\n\n  recovery_window_in_days = 7\n\n  tags = {\n    Environment = var.environment\n    Application = var.application\n    ManagedBy   = \"terraform\"\n  }\n}\n\nresource \"aws_secretsmanager_secret_version\" \"db\" {\n  secret_id = aws_secretsmanager_secret.db.id\n\n  secret_string = jsonencode({\n    username = var.db_username\n    password = random_password.db.result\n    host     = var.db_host\n    port     = var.db_port\n    dbname   = var.db_name\n    engine   = \"postgres\"\n  })\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Next, add the variable definitions in <code>variables.tf<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim variables.tf<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste the variable block. Sensible defaults let the module be applied with zero config for a quick test, while every value remains overridable per environment.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>variable \"region\" {\n  type    = string\n  default = \"eu-west-1\"\n}\n\nvariable \"name_prefix\" {\n  type    = string\n  default = \"computingforgeeks\/demo\"\n}\n\nvariable \"environment\" {\n  type    = string\n  default = \"demo\"\n}\n\nvariable \"application\" {\n  type    = string\n  default = \"cfg-blog-demo\"\n}\n\nvariable \"db_username\" {\n  type    = string\n  default = \"appuser\"\n}\n\nvariable \"db_host\" {\n  type    = string\n  default = \"db.demo.internal\"\n}\n\nvariable \"db_port\" {\n  type    = number\n  default = 5432\n}\n\nvariable \"db_name\" {\n  type    = string\n  default = \"appdb\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Finally the outputs in <code>outputs.tf<\/code>. Downstream modules (the RDS instance, the ECS service, the Lambda function) read these values to avoid hardcoding the secret ARN in a second place.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>vim outputs.tf<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Paste the outputs.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>output \"secret_arn\" {\n  value = aws_secretsmanager_secret.db.arn\n}\n\noutput \"secret_name\" {\n  value = aws_secretsmanager_secret.db.name\n}\n\noutput \"version_id\" {\n  value = aws_secretsmanager_secret_version.db.version_id\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Initialize and apply.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>terraform init\nterraform apply -auto-approve<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The apply produces three resources: the random password, the secret container, and the secret version. The output line at the end confirms everything landed cleanly.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>Apply complete! Resources: 3 added, 0 changed, 0 destroyed.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>random_password.db.result<\/code> value lives in Terraform state as a sensitive value. Never check state files into Git. Use an S3 remote backend with encryption and state locking via DynamoDB, which is the standard pattern and the only sane way to run Terraform in a team.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Troubleshooting common errors<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">ResourceNotFoundException: Secrets Manager can't find the specified secret<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This error means exactly what it says: the name or ARN you passed does not resolve to a secret in the region your API call hit. There are three common causes. The name has a typo. The secret exists but in a different region. Or the secret was recently deleted and is in its recovery window (in which case you need to restore it with <code>restore-secret<\/code>, not recreate it).<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"this-secret-does-not-exist\" \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Reproduces the error verbatim.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret.<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Wrong region masquerading as \"not found\"<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager is a regional service. A secret in eu-west-1 does not exist in us-east-1 until you explicitly replicate it. The error message is identical to a genuine typo, so always double-check your <code>--region<\/code> flag and your <code>AWS_DEFAULT_REGION<\/code> environment variable before assuming the secret was deleted. This one has cost engineers more debugging hours than any other Secrets Manager issue.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager get-secret-value \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --region us-east-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Same error, different root cause. The fix is to pass the correct region. If you commonly switch regions, wrap your CLI commands in a shell function that echoes the target region before running.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">MalformedPolicyDocumentException: This resource policy contains an unsupported principal<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">AWS validates resource policy principals at put-policy time. If the referenced role ARN does not exist in the target account, the put fails. The fix is either to reference the account root (<code>arn:aws:iam::210987654321:root<\/code>), which always exists, or to create the role in the consuming account first, then update the resource policy with the real role ARN. There is no \"create policy now, create role later\" shortcut.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">AccessDeniedException on GetSecretValue<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The IAM role calling GetSecretValue does not have the permission, or does have the permission but the secret is encrypted with a customer-managed CMK and the role lacks <code>kms:Decrypt<\/code> on that CMK. Check three things in order: the identity-based policy on the calling role, the resource-based policy on the secret, and the KMS key policy on the CMK. The error message does not distinguish between these three, so you have to check all of them. For cross-account reads, all three layers must align. Most tutorials skip the KMS key policy step and that is where real-world setups fail.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">DecryptionFailure on GetSecretValue<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Different from AccessDenied. This one means Secrets Manager itself cannot decrypt the stored value, which usually indicates the KMS key used to encrypt the secret has been disabled or deleted, or the calling region does not have access to a regional CMK that lives elsewhere. Re-enable the key, or if it was deleted, you need to restore from the 30-day KMS recovery window. Past that window the secret is unrecoverable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Cleanup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Delete a secret with a 7-day recovery window (the safe default for dev). During the recovery window the secret is invisible to normal API calls but can be restored with <code>restore-secret<\/code>. No charges apply during the recovery window.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager delete-secret \\\n  --secret-id \"computingforgeeks\/demo\/api-key\" \\\n  --recovery-window-in-days 7 \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Or force-delete immediately, skipping the recovery window entirely. Use this only in test accounts or when you are absolutely sure. You cannot combine force-delete with a recovery window. AWS will reject the call with <code>You can't use ForceDeleteWithoutRecovery in conjunction with RecoveryWindowInDays<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager delete-secret \\\n  --secret-id \"computingforgeeks\/demo\/api-key\" \\\n  --force-delete-without-recovery \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If a secret has replicas, you cannot delete it until you remove the replicas first. Run <code>remove-regions-from-replication<\/code> for each replica region, wait for each to drop, then delete the primary. Skipping this step returns an <code>InvalidRequestException<\/code> and the delete does nothing.<\/p>\n\n\n\n<pre class=\"wp-block-code code\"><code>aws secretsmanager remove-regions-from-replication \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --remove-replica-regions eu-central-1 \\\n  --region eu-west-1\n\naws secretsmanager delete-secret \\\n  --secret-id \"computingforgeeks\/demo\/db-credentials\" \\\n  --recovery-window-in-days 7 \\\n  --region eu-west-1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For Terraform-managed secrets, just run <code>terraform destroy<\/code>. The provider handles the delete with the <code>recovery_window_in_days<\/code> you configured in the resource. Do not mix manual CLI deletes with Terraform state, because the state will drift and the next apply will try to recreate the secret.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Production hardening checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ten items to check before you promote a Secrets Manager setup into production. Each one is a thing that has bitten real engineers in real incidents.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>One secret per credential scope.<\/strong> Do not stuff a whole <code>.env<\/code> file into one secret. Split into <code>prod\/payments-api\/db<\/code>, <code>prod\/payments-api\/stripe<\/code>, <code>prod\/payments-api\/jwt-signer<\/code>. Smaller blast radius, cleaner IAM policies, fewer coupled rotations.<\/li>\n<li><strong>Strict naming convention.<\/strong> Pick <code>&lt;env&gt;\/&lt;app&gt;\/&lt;component&gt;<\/code> and enforce it with a Terraform module or SCP. Free-form names turn into chaos the moment you have more than 50 secrets.<\/li>\n<li><strong>Tag every secret with Environment, Owner, Application, CostCenter, DataClass.<\/strong> Tags drive cost allocation, ownership queries, and cleanup automation. Untagged secrets are technical debt.<\/li>\n<li><strong>Customer-managed CMK for anything under audit.<\/strong> PCI, HIPAA, SOC 2 environments should never use the default <code>aws\/secretsmanager<\/code> key. The CMK policy is a second audit layer and is worth the $1 per month per key.<\/li>\n<li><strong>Rotate from day one.<\/strong> If you do not set rotation when you create the secret, you will not come back to it. Enable managed rotation at creation time, even with a long interval.<\/li>\n<li><strong>Prefer resource policies over cross-account AssumeRole<\/strong> for inter-account sharing. Fewer hops, cleaner CloudTrail events, and the policy travels with the secret rather than the caller.<\/li>\n<li><strong>Schedule CloudTrail audit reports.<\/strong> Run a weekly GetSecretValue lookup per secret and alert on unexpected principals. An unknown principal reading your secret is usually the first sign of compromise.<\/li>\n<li><strong>Enable Cost Anomaly Detection scoped to service=SecretsManager.<\/strong> A runaway script polling a secret in a loop can blow the API bill in hours.<\/li>\n<li><strong>Cache secret values in process memory with a 5 to 15 minute TTL.<\/strong> Do not call the API on every request. Secrets Manager is not a config database and charges you per API call precisely because it is not meant to be one. The AWS Parameters and Secrets Lambda Extension does this caching automatically.<\/li>\n<li><strong>Use BatchGetSecretValue when reading multiple secrets at startup.<\/strong> It is one billable call instead of N and was added in 2024. Older tutorials miss it.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">What's the difference between Secrets Manager and Parameter Store?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Secrets Manager is a paid, rotation-enabled secret store built for credentials that must change on a schedule. Parameter Store SecureString is a free, encrypted key-value store built for configuration. Use Secrets Manager for database passwords, OAuth tokens, and anything that rotates. Use Parameter Store for feature flags, app config, and non-rotating values. Mixing the two for their strengths is the right pattern, not picking one exclusively.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How much does AWS Secrets Manager cost?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">AWS Secrets Manager costs $0.40 per secret per month plus $0.05 per 10,000 API calls, with no minimums. Each cross-region replica counts as a separate secret. Customer-managed KMS keys add $1 per month per key plus KMS API charges. VPC endpoints add $0.01 per hour per availability zone if you enable them. A realistic production workload with 20 secrets and moderate traffic runs around $12 to $15 per month.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I rotate secrets automatically?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For RDS, Aurora, DocumentDB, Redshift, and ECS Service Connect, enable managed rotation with <code>aws secretsmanager rotate-secret --rotation-rules AutomaticallyAfterDays=30<\/code>. AWS runs the rotation internally with no Lambda required. For custom targets (SaaS APIs, third-party services), deploy a rotation Lambda from the AWS template, customize the setSecret and testSecret functions for your target, then attach it with <code>--rotation-lambda-arn<\/code>. Minimum rotation interval is 4 hours.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I share a secret across AWS accounts?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. Attach a resource-based policy to the secret that grants the consuming account's principal <code>secretsmanager:GetSecretValue<\/code>, and grant the consuming role <code>kms:Decrypt<\/code> on the CMK that encrypts the secret (both in the owning account's key policy and in the consuming role's identity policy). All three layers must align or the cross-account GetSecretValue will fail with AccessDeniedException.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I retrieve a Secrets Manager secret in a bash script?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>aws secretsmanager get-secret-value --query SecretString --output text<\/code> to get the raw payload without the JSON envelope, then parse specific fields with Python or jq. Call the API once and extract multiple fields from the cached JSON rather than making multiple API calls, because API calls are billed per 10,000 and rate-limited per secret.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I audit who accessed my secrets?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Use CloudTrail. Every Secrets Manager API call (GetSecretValue, PutSecretValue, DescribeSecret, RotateSecret, and so on) is logged with the IAM principal, source IP, and timestamp. Query recent events with <code>aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue<\/code>, or run CloudTrail Lake SQL queries or Athena queries over your CloudTrail S3 bucket for deeper analysis. Schedule the reports weekly and alert on unexpected principals.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is Parameter Store SecureString a free alternative to Secrets Manager?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For storage and API calls, yes: Parameter Store Standard tier with SecureString is free up to 10,000 parameters per account per region. The trade-off is no automatic rotation, no resource policies, no native cross-region replication, no managed RDS rotation, and a smaller 4 KB value limit. If you need any of those features, Secrets Manager is the right service. If you don't, Parameter Store SecureString saves you hundreds of dollars per year.<\/p>\n\n\n\n<script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What's the difference between Secrets Manager and Parameter Store?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Secrets Manager is a paid, rotation-enabled secret store built for credentials that must change on a schedule. Parameter Store SecureString is a free, encrypted key-value store built for configuration. Use Secrets Manager for database passwords, OAuth tokens, and anything that rotates. Use Parameter Store for feature flags, app config, and non-rotating values.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How much does AWS Secrets Manager cost?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"AWS Secrets Manager costs $0.40 per secret per month plus $0.05 per 10,000 API calls. Each cross-region replica counts as a separate secret. Customer-managed KMS keys add $1 per month per key. A realistic production workload with 20 secrets runs around $12 to $15 per month.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I rotate secrets automatically?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"For RDS, Aurora, DocumentDB, Redshift, and ECS Service Connect, enable managed rotation with aws secretsmanager rotate-secret. AWS runs the rotation internally with no Lambda required. For custom targets, deploy a rotation Lambda from the AWS template and customize the setSecret and testSecret functions. Minimum rotation interval is 4 hours.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I share a secret across AWS accounts?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes. Attach a resource-based policy to the secret that grants the consuming account's principal GetSecretValue, and grant the consuming role kms:Decrypt on the CMK that encrypts the secret. All three IAM layers must align or the cross-account GetSecretValue will fail.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I retrieve a Secrets Manager secret in a bash script?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Use aws secretsmanager get-secret-value with --query SecretString --output text to get the raw payload without the JSON envelope, then parse specific fields with Python or jq. Call the API once and extract multiple fields from the cached JSON rather than making multiple API calls.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How do I audit who accessed my secrets?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Use CloudTrail. Every Secrets Manager API call is logged with the IAM principal, source IP, and timestamp. Query recent events with aws cloudtrail lookup-events, or run CloudTrail Lake SQL queries or Athena over your CloudTrail S3 bucket for deeper analysis.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Is Parameter Store SecureString a free alternative to Secrets Manager?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"For storage and API calls, yes: Parameter Store Standard tier with SecureString is free up to 10,000 parameters per account per region. The trade-off is no automatic rotation, no resource policies, no native cross-region replication, no managed RDS rotation, and a smaller 4 KB value limit.\"\n      }\n    }\n  ]\n}\n<\/script>\n\n","protected":false},"excerpt":{"rendered":"<p>Hardcoded database passwords in a .env file committed to Git is still how a surprising number of teams ship software in 2026. It worked for the first release, nobody rotated anything, and now the credential lives in five repos, two CI systems, and a Slack thread from 2023. AWS Secrets Manager is the native fix. &#8230; <a title=\"Configure AWS Secrets Manager: Rotation, IAM, and ECS\" class=\"read-more\" href=\"https:\/\/computingforgeeks.com\/aws-secrets-manager-rotation-guide\/\" aria-label=\"Read more about Configure AWS Secrets Manager: Rotation, IAM, and ECS\">Read more<\/a><\/p>\n","protected":false},"author":3,"featured_media":165607,"comment_status":"open","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[511,2680],"tags":[513],"cfg_series":[39810],"class_list":["post-165606","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-aws","category-cloud","tag-aws","cfg_series-aws-eks-platform"],"_links":{"self":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165606","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/comments?post=165606"}],"version-history":[{"count":1,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165606\/revisions"}],"predecessor-version":[{"id":165616,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/posts\/165606\/revisions\/165616"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media\/165607"}],"wp:attachment":[{"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/media?parent=165606"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/categories?post=165606"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/tags?post=165606"},{"taxonomy":"cfg_series","embeddable":true,"href":"https:\/\/computingforgeeks.com\/wp-json\/wp\/v2\/cfg_series?post=165606"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}